feat: reuse single comment
This commit is contained in:
@@ -16,8 +16,8 @@ private OpenCode server on the same Docker network as Gitea.
|
|||||||
| PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. |
|
| PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. |
|
||||||
|
|
||||||
The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Each
|
The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Each
|
||||||
accepted command gets separate queued and started comments. Final plans, PR results, failures, and
|
accepted command gets one Gitea comment, which is updated as the job moves from queued to started
|
||||||
remaining review findings are posted separately.
|
and then to its final result or failure. Remaining review findings are included in the final update.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ class GiteaClient:
|
|||||||
)
|
)
|
||||||
return int(response.json()["id"])
|
return int(response.json()["id"])
|
||||||
|
|
||||||
|
async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> None:
|
||||||
|
await self._request(
|
||||||
|
"PATCH",
|
||||||
|
f"/repos/{owner}/{repo}/issues/comments/{comment_id}",
|
||||||
|
json={"body": body},
|
||||||
|
)
|
||||||
|
|
||||||
async def create_pull_request(
|
async def create_pull_request(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
owner: str,
|
||||||
|
|||||||
+25
-17
@@ -61,28 +61,28 @@ class Worker:
|
|||||||
log.info("job started", extra=extra)
|
log.info("job started", extra=extra)
|
||||||
try:
|
try:
|
||||||
if job.accepted_comment_id is None:
|
if job.accepted_comment_id is None:
|
||||||
accepted_id = await self.gitea.create_comment(
|
job.accepted_comment_id = await self.gitea.create_comment(
|
||||||
job.repo_owner,
|
job.repo_owner,
|
||||||
job.repo_name,
|
job.repo_name,
|
||||||
job.issue_number,
|
job.issue_number,
|
||||||
f"Agent job `{job.id}` queued (`{job.kind}`).",
|
f"Agent job `{job.id}` started (`{job.kind}`).",
|
||||||
)
|
)
|
||||||
await self.storage.set_job_comment(
|
await self.storage.set_job_comment(
|
||||||
job.id, "accepted_comment_id", accepted_id
|
job.id, "accepted_comment_id", job.accepted_comment_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.gitea.update_comment(
|
||||||
|
job.repo_owner,
|
||||||
|
job.repo_name,
|
||||||
|
job.accepted_comment_id,
|
||||||
|
f"Agent job `{job.id}` started (`{job.kind}`).",
|
||||||
)
|
)
|
||||||
comment_id = await self.gitea.create_comment(
|
|
||||||
job.repo_owner,
|
|
||||||
job.repo_name,
|
|
||||||
job.issue_number,
|
|
||||||
f"Agent job `{job.id}` started (`{job.kind}`).",
|
|
||||||
)
|
|
||||||
await self.storage.set_job_comment(job.id, "started_comment_id", comment_id)
|
|
||||||
await self.dispatcher.dispatch(job)
|
await self.dispatcher.dispatch(job)
|
||||||
except JobRejected as exc:
|
except JobRejected as exc:
|
||||||
await self._safe_update_job(
|
await self._safe_update_job(
|
||||||
job, status=JobStatus.REJECTED, stage="rejected", error=str(exc)
|
job, status=JobStatus.REJECTED, stage="rejected", error=str(exc)
|
||||||
)
|
)
|
||||||
await self._safe_comment(job, f"Agent job `{job.id}` was rejected: {exc}")
|
await self._safe_job_comment(job, f"Agent job `{job.id}` was rejected: {exc}")
|
||||||
log.info("job rejected", extra={**extra, "stage": "rejected"})
|
log.info("job rejected", extra={**extra, "stage": "rejected"})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed_stage = await self._safe_job_stage(job)
|
failed_stage = await self._safe_job_stage(job)
|
||||||
@@ -90,7 +90,7 @@ class Worker:
|
|||||||
job, status=JobStatus.FAILED, stage="failed", error=_safe_error(exc)
|
job, status=JobStatus.FAILED, stage="failed", error=_safe_error(exc)
|
||||||
)
|
)
|
||||||
await self._safe_fail_workflow(job)
|
await self._safe_fail_workflow(job)
|
||||||
await self._safe_comment(
|
await self._safe_job_comment(
|
||||||
job,
|
job,
|
||||||
f"Agent job `{job.id}` failed during `{failed_stage}`: {_safe_error(exc)}",
|
f"Agent job `{job.id}` failed during `{failed_stage}`: {_safe_error(exc)}",
|
||||||
)
|
)
|
||||||
@@ -111,7 +111,7 @@ class Worker:
|
|||||||
for job in jobs:
|
for job in jobs:
|
||||||
await self._abort_job_sessions(job)
|
await self._abort_job_sessions(job)
|
||||||
await self._safe_fail_workflow(job)
|
await self._safe_fail_workflow(job)
|
||||||
await self._safe_comment(
|
await self._safe_job_comment(
|
||||||
job,
|
job,
|
||||||
f"Agent job `{job.id}` failed because the service restarted during execution.",
|
f"Agent job `{job.id}` failed because the service restarted during execution.",
|
||||||
)
|
)
|
||||||
@@ -136,11 +136,19 @@ class Worker:
|
|||||||
for session_id, workspace in sessions:
|
for session_id, workspace in sessions:
|
||||||
await self.opencode.abort(session_id, workspace)
|
await self.opencode.abort(session_id, workspace)
|
||||||
|
|
||||||
async def _safe_comment(self, job: Job, body: str) -> None:
|
async def _safe_job_comment(self, job: Job, body: str) -> None:
|
||||||
try:
|
try:
|
||||||
await self.gitea.create_comment(
|
if job.accepted_comment_id is None:
|
||||||
job.repo_owner, job.repo_name, job.issue_number, body
|
job.accepted_comment_id = await self.gitea.create_comment(
|
||||||
)
|
job.repo_owner, job.repo_name, job.issue_number, body
|
||||||
|
)
|
||||||
|
await self.storage.set_job_comment(
|
||||||
|
job.id, "accepted_comment_id", job.accepted_comment_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.gitea.update_comment(
|
||||||
|
job.repo_owner, job.repo_name, job.accepted_comment_id, body
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("could not publish job status", extra={"job_id": job.id})
|
log.exception("could not publish job status", extra={"job_id": job.id})
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from agentci.adapters.gitea import GiteaClient
|
|||||||
from agentci.adapters.opencode import OpenCodeClient
|
from agentci.adapters.opencode import OpenCodeClient
|
||||||
from agentci.adapters.storage import Storage
|
from agentci.adapters.storage import Storage
|
||||||
from agentci.config import Settings
|
from agentci.config import Settings
|
||||||
from agentci.domain.models import ReviewReport
|
from agentci.domain.models import Job, ReviewReport
|
||||||
from agentci.prompts import PromptLibrary
|
from agentci.prompts import PromptLibrary
|
||||||
from agentci.workflows.context import ContextBuilder
|
from agentci.workflows.context import ContextBuilder
|
||||||
|
|
||||||
@@ -69,3 +69,11 @@ def report_for_prompt(report_json_value: str | None) -> str:
|
|||||||
|
|
||||||
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
|
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
|
||||||
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
|
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
|
||||||
|
|
||||||
|
|
||||||
|
async def update_job_comment(deps: Dependencies, job: Job, body: str) -> None:
|
||||||
|
if job.accepted_comment_id is None:
|
||||||
|
raise RuntimeError("Expected a persisted Gitea job comment ID")
|
||||||
|
await deps.gitea.update_comment(
|
||||||
|
job.repo_owner, job.repo_name, job.accepted_comment_id, body
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from agentci.workflows.common import (
|
|||||||
agent_comment,
|
agent_comment,
|
||||||
report_json,
|
report_json,
|
||||||
review_markdown,
|
review_markdown,
|
||||||
|
update_job_comment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,17 +126,11 @@ class ImplementWorkflow:
|
|||||||
f"{job.repo_name}/pulls/{pull.number}"
|
f"{job.repo_name}/pulls/{pull.number}"
|
||||||
)
|
)
|
||||||
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
|
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
|
||||||
await self.deps.gitea.create_comment(
|
body = agent_comment("implementation", workflow.id, body)
|
||||||
job.repo_owner,
|
|
||||||
job.repo_name,
|
|
||||||
job.issue_number,
|
|
||||||
agent_comment("implementation", workflow.id, body),
|
|
||||||
)
|
|
||||||
remaining = review_markdown(report)
|
remaining = review_markdown(report)
|
||||||
if remaining:
|
if remaining:
|
||||||
await self.deps.gitea.create_comment(
|
body = f"{body}\n\n{remaining}"
|
||||||
job.repo_owner, job.repo_name, pull.number, remaining
|
await update_job_comment(self.deps, job, body)
|
||||||
)
|
|
||||||
|
|
||||||
async def _reject_duplicate(self, job: Job) -> None:
|
async def _reject_duplicate(self, job: Job) -> None:
|
||||||
workflows = await self.deps.storage.implementation_workflows(
|
workflows = await self.deps.storage.implementation_workflows(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from agentci.workflows.common import (
|
|||||||
report_json,
|
report_json,
|
||||||
required_session,
|
required_session,
|
||||||
review_markdown,
|
review_markdown,
|
||||||
|
update_job_comment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,11 +98,8 @@ class PlanWorkflow:
|
|||||||
schema_name="discussion.json",
|
schema_name="discussion.json",
|
||||||
result_type=DiscussionReply,
|
result_type=DiscussionReply,
|
||||||
)
|
)
|
||||||
await self.deps.gitea.create_comment(
|
await update_job_comment(
|
||||||
job.repo_owner,
|
self.deps, job, agent_comment("discussion", workflow.id, reply.markdown)
|
||||||
job.repo_name,
|
|
||||||
job.issue_number,
|
|
||||||
agent_comment("discussion", workflow.id, reply.markdown),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def iterate(self, job: Job) -> None:
|
async def iterate(self, job: Job) -> None:
|
||||||
@@ -213,17 +211,11 @@ class PlanWorkflow:
|
|||||||
workflow.review_json = report_json(report)
|
workflow.review_json = report_json(report)
|
||||||
workflow.status = WorkflowStatus.COMPLETED
|
workflow.status = WorkflowStatus.COMPLETED
|
||||||
await self.deps.storage.update_workflow(workflow)
|
await self.deps.storage.update_workflow(workflow)
|
||||||
await self.deps.gitea.create_comment(
|
body = agent_comment("plan", workflow.id, artifact.plan_markdown)
|
||||||
job.repo_owner,
|
|
||||||
job.repo_name,
|
|
||||||
job.issue_number,
|
|
||||||
agent_comment("plan", workflow.id, artifact.plan_markdown),
|
|
||||||
)
|
|
||||||
remaining = review_markdown(report)
|
remaining = review_markdown(report)
|
||||||
if remaining:
|
if remaining:
|
||||||
await self.deps.gitea.create_comment(
|
body = f"{body}\n\n{remaining}"
|
||||||
job.repo_owner, job.repo_name, job.issue_number, remaining
|
await update_job_comment(self.deps, job, body)
|
||||||
)
|
|
||||||
|
|
||||||
async def _latest_plan(self, job: Job) -> Workflow:
|
async def _latest_plan(self, job: Job) -> Workflow:
|
||||||
workflow = await self.deps.storage.latest_workflow(
|
workflow = await self.deps.storage.latest_workflow(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from agentci.workflows.common import (
|
|||||||
report_for_prompt,
|
report_for_prompt,
|
||||||
report_json,
|
report_json,
|
||||||
review_markdown,
|
review_markdown,
|
||||||
|
update_job_comment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,17 +89,13 @@ class PullRequestWorkflow:
|
|||||||
workflow.artifact = result.model_dump_json()
|
workflow.artifact = result.model_dump_json()
|
||||||
workflow.review_json = report_json(report)
|
workflow.review_json = report_json(report)
|
||||||
await self.deps.storage.update_workflow(workflow)
|
await self.deps.storage.update_workflow(workflow)
|
||||||
await self.deps.gitea.create_comment(
|
body = agent_comment(
|
||||||
job.repo_owner,
|
"iteration", workflow.id, result_comment(result, sha=sha)
|
||||||
job.repo_name,
|
|
||||||
pull_number,
|
|
||||||
agent_comment("iteration", workflow.id, result_comment(result, sha=sha)),
|
|
||||||
)
|
)
|
||||||
remaining = review_markdown(report)
|
remaining = review_markdown(report)
|
||||||
if remaining:
|
if remaining:
|
||||||
await self.deps.gitea.create_comment(
|
body = f"{body}\n\n{remaining}"
|
||||||
job.repo_owner, job.repo_name, pull_number, remaining
|
await update_job_comment(self.deps, job, body)
|
||||||
)
|
|
||||||
|
|
||||||
async def fix(self, job: Job) -> None:
|
async def fix(self, job: Job) -> None:
|
||||||
pull_number = _pull_number(job)
|
pull_number = _pull_number(job)
|
||||||
@@ -145,10 +142,9 @@ class PullRequestWorkflow:
|
|||||||
set_upstream=False,
|
set_upstream=False,
|
||||||
commit_prefix="agent fix",
|
commit_prefix="agent fix",
|
||||||
)
|
)
|
||||||
await self.deps.gitea.create_comment(
|
await update_job_comment(
|
||||||
job.repo_owner,
|
self.deps,
|
||||||
job.repo_name,
|
job,
|
||||||
pull_number,
|
|
||||||
agent_comment("fix", job.id, result_comment(result, sha=sha)),
|
agent_comment("fix", job.id, result_comment(result, sha=sha)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from agentci.adapters.gitea import GiteaClient
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_updates_issue_comment_by_id() -> None:
|
||||||
|
route = respx.patch(
|
||||||
|
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
|
||||||
|
).mock(return_value=httpx.Response(200, json={"id": 17}))
|
||||||
|
client = GiteaClient("https://gitea.example", "secret")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.update_comment("org", "repo", 17, "updated status")
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
assert route.called
|
||||||
|
assert json.loads(route.calls[0].request.content) == {"body": "updated status"}
|
||||||
+87
-1
@@ -1,7 +1,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agentci.domain.models import Job, JobKind, Workflow, WorkflowKind
|
from agentci.domain.models import Job, JobKind, JobStatus, Workflow, WorkflowKind
|
||||||
from agentci.worker import Worker
|
from agentci.worker import Worker
|
||||||
|
from agentci.workflows.common import JobRejected
|
||||||
|
|
||||||
|
|
||||||
class FakeStorage:
|
class FakeStorage:
|
||||||
@@ -20,6 +21,47 @@ class FakeOpenCode:
|
|||||||
self.aborted.add((session_id, workspace))
|
self.aborted.add((session_id, workspace))
|
||||||
|
|
||||||
|
|
||||||
|
class JobStorage(FakeStorage):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(None)
|
||||||
|
self.updates: list[tuple[JobStatus | None, str | None, str | None]] = []
|
||||||
|
self.comment_ids: list[int] = []
|
||||||
|
|
||||||
|
async def update_job(self, _job_id: str, **values) -> None:
|
||||||
|
self.updates.append(
|
||||||
|
(values.get("status"), values.get("stage"), values.get("error"))
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_job_comment(self, _job_id: str, _column: str, comment_id: int) -> None:
|
||||||
|
self.comment_ids.append(comment_id)
|
||||||
|
|
||||||
|
async def job_stage(self, _job_id: str) -> str:
|
||||||
|
return "working"
|
||||||
|
|
||||||
|
async def fail_job_workflow(self, _job_id: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGitea:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.created: list[str] = []
|
||||||
|
self.updated: list[tuple[int, str]] = []
|
||||||
|
|
||||||
|
async def create_comment(self, _owner: str, _repo: str, _number: int, body: str) -> int:
|
||||||
|
self.created.append(body)
|
||||||
|
return 42
|
||||||
|
|
||||||
|
async def update_comment(
|
||||||
|
self, _owner: str, _repo: str, comment_id: int, body: str
|
||||||
|
) -> None:
|
||||||
|
self.updated.append((comment_id, body))
|
||||||
|
|
||||||
|
|
||||||
|
class RejectingDispatcher:
|
||||||
|
async def dispatch(self, _job: Job) -> None:
|
||||||
|
raise JobRejected("not applicable")
|
||||||
|
|
||||||
|
|
||||||
def job(*, workflow_id: str | None, runtime_session_id: str | None = None) -> Job:
|
def job(*, workflow_id: str | None, runtime_session_id: str | None = None) -> Job:
|
||||||
return Job(
|
return Job(
|
||||||
id="job",
|
id="job",
|
||||||
@@ -78,3 +120,47 @@ async def test_recovery_aborts_one_shot_fix_session(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert opencode.aborted == {("fix-session", tmp_path / "fix-job" / "repo")}
|
assert opencode.aborted == {("fix-session", tmp_path / "fix-job" / "repo")}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_job_status_updates_existing_gitea_comment(tmp_path: Path) -> None:
|
||||||
|
storage = JobStorage()
|
||||||
|
gitea = FakeGitea()
|
||||||
|
active_job = job(workflow_id=None)
|
||||||
|
active_job.accepted_comment_id = 41
|
||||||
|
value = Worker(
|
||||||
|
storage=storage, # type: ignore[arg-type]
|
||||||
|
gitea=gitea, # type: ignore[arg-type]
|
||||||
|
opencode=FakeOpenCode(), # type: ignore[arg-type]
|
||||||
|
dispatcher=RejectingDispatcher(), # type: ignore[arg-type]
|
||||||
|
poll_seconds=1,
|
||||||
|
workspaces_dir=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
await value._run_job(active_job)
|
||||||
|
|
||||||
|
assert gitea.created == []
|
||||||
|
assert [comment_id for comment_id, _body in gitea.updated] == [41, 41]
|
||||||
|
assert "started" in gitea.updated[0][1]
|
||||||
|
assert "rejected" in gitea.updated[1][1]
|
||||||
|
assert storage.updates[-1][:2] == (JobStatus.REJECTED, "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_worker_creates_only_one_comment_when_queue_comment_is_missing(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
storage = JobStorage()
|
||||||
|
gitea = FakeGitea()
|
||||||
|
value = Worker(
|
||||||
|
storage=storage, # type: ignore[arg-type]
|
||||||
|
gitea=gitea, # type: ignore[arg-type]
|
||||||
|
opencode=FakeOpenCode(), # type: ignore[arg-type]
|
||||||
|
dispatcher=RejectingDispatcher(), # type: ignore[arg-type]
|
||||||
|
poll_seconds=1,
|
||||||
|
workspaces_dir=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
await value._run_job(job(workflow_id=None))
|
||||||
|
|
||||||
|
assert len(gitea.created) == 1
|
||||||
|
assert storage.comment_ids == [42]
|
||||||
|
assert [comment_id for comment_id, _body in gitea.updated] == [42]
|
||||||
|
|||||||
Reference in New Issue
Block a user