From 73045258fa831956a4df842e419c58d7d57cc596 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Tue, 21 Jul 2026 00:54:46 +0200 Subject: [PATCH] feat: reuse single comment --- README.md | 4 +- src/agentci/adapters/gitea.py | 7 +++ src/agentci/worker.py | 42 +++++++------ src/agentci/workflows/common.py | 10 ++- src/agentci/workflows/implement.py | 13 ++-- src/agentci/workflows/plan.py | 20 ++---- src/agentci/workflows/pull_request.py | 20 +++--- tests/test_gitea.py | 22 +++++++ tests/test_worker.py | 88 ++++++++++++++++++++++++++- 9 files changed, 170 insertions(+), 56 deletions(-) create mode 100644 tests/test_gitea.py diff --git a/README.md b/README.md index 10a5654..56c0e07 100644 --- a/README.md +++ b/README.md @@ -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. | 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 -remaining review findings are posted separately. +accepted command gets one Gitea comment, which is updated as the job moves from queued to started +and then to its final result or failure. Remaining review findings are included in the final update. ## Deploy diff --git a/src/agentci/adapters/gitea.py b/src/agentci/adapters/gitea.py index 2e20e22..a766ed9 100644 --- a/src/agentci/adapters/gitea.py +++ b/src/agentci/adapters/gitea.py @@ -105,6 +105,13 @@ class GiteaClient: ) 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( self, owner: str, diff --git a/src/agentci/worker.py b/src/agentci/worker.py index 898cfee..9e1b5b4 100644 --- a/src/agentci/worker.py +++ b/src/agentci/worker.py @@ -61,28 +61,28 @@ class Worker: log.info("job started", extra=extra) try: 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_name, 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( - 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) except JobRejected as exc: await self._safe_update_job( 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"}) except Exception as exc: failed_stage = await self._safe_job_stage(job) @@ -90,7 +90,7 @@ class Worker: job, status=JobStatus.FAILED, stage="failed", error=_safe_error(exc) ) await self._safe_fail_workflow(job) - await self._safe_comment( + await self._safe_job_comment( job, f"Agent job `{job.id}` failed during `{failed_stage}`: {_safe_error(exc)}", ) @@ -111,7 +111,7 @@ class Worker: for job in jobs: await self._abort_job_sessions(job) await self._safe_fail_workflow(job) - await self._safe_comment( + await self._safe_job_comment( job, f"Agent job `{job.id}` failed because the service restarted during execution.", ) @@ -136,11 +136,19 @@ class Worker: for session_id, workspace in sessions: 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: - await self.gitea.create_comment( - job.repo_owner, job.repo_name, job.issue_number, body - ) + if job.accepted_comment_id is None: + 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: log.exception("could not publish job status", extra={"job_id": job.id}) diff --git a/src/agentci/workflows/common.py b/src/agentci/workflows/common.py index 1d543a3..9afff2e 100644 --- a/src/agentci/workflows/common.py +++ b/src/agentci/workflows/common.py @@ -9,7 +9,7 @@ from agentci.adapters.gitea import GiteaClient from agentci.adapters.opencode import OpenCodeClient from agentci.adapters.storage import Storage 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.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: return f"\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 + ) diff --git a/src/agentci/workflows/implement.py b/src/agentci/workflows/implement.py index ff5943a..661d65e 100644 --- a/src/agentci/workflows/implement.py +++ b/src/agentci/workflows/implement.py @@ -17,6 +17,7 @@ from agentci.workflows.common import ( agent_comment, report_json, review_markdown, + update_job_comment, ) @@ -125,17 +126,11 @@ class ImplementWorkflow: f"{job.repo_name}/pulls/{pull.number}" ) body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}" - await self.deps.gitea.create_comment( - job.repo_owner, - job.repo_name, - job.issue_number, - agent_comment("implementation", workflow.id, body), - ) + body = agent_comment("implementation", workflow.id, body) remaining = review_markdown(report) if remaining: - await self.deps.gitea.create_comment( - job.repo_owner, job.repo_name, pull.number, remaining - ) + body = f"{body}\n\n{remaining}" + await update_job_comment(self.deps, job, body) async def _reject_duplicate(self, job: Job) -> None: workflows = await self.deps.storage.implementation_workflows( diff --git a/src/agentci/workflows/plan.py b/src/agentci/workflows/plan.py index d13cd50..3b727fa 100644 --- a/src/agentci/workflows/plan.py +++ b/src/agentci/workflows/plan.py @@ -19,6 +19,7 @@ from agentci.workflows.common import ( report_json, required_session, review_markdown, + update_job_comment, ) @@ -97,11 +98,8 @@ class PlanWorkflow: schema_name="discussion.json", result_type=DiscussionReply, ) - await self.deps.gitea.create_comment( - job.repo_owner, - job.repo_name, - job.issue_number, - agent_comment("discussion", workflow.id, reply.markdown), + await update_job_comment( + self.deps, job, agent_comment("discussion", workflow.id, reply.markdown) ) async def iterate(self, job: Job) -> None: @@ -213,17 +211,11 @@ class PlanWorkflow: workflow.review_json = report_json(report) workflow.status = WorkflowStatus.COMPLETED await self.deps.storage.update_workflow(workflow) - await self.deps.gitea.create_comment( - job.repo_owner, - job.repo_name, - job.issue_number, - agent_comment("plan", workflow.id, artifact.plan_markdown), - ) + body = agent_comment("plan", workflow.id, artifact.plan_markdown) remaining = review_markdown(report) if remaining: - await self.deps.gitea.create_comment( - job.repo_owner, job.repo_name, job.issue_number, remaining - ) + body = f"{body}\n\n{remaining}" + await update_job_comment(self.deps, job, body) async def _latest_plan(self, job: Job) -> Workflow: workflow = await self.deps.storage.latest_workflow( diff --git a/src/agentci/workflows/pull_request.py b/src/agentci/workflows/pull_request.py index f0cb1e6..f95dbcb 100644 --- a/src/agentci/workflows/pull_request.py +++ b/src/agentci/workflows/pull_request.py @@ -10,6 +10,7 @@ from agentci.workflows.common import ( report_for_prompt, report_json, review_markdown, + update_job_comment, ) @@ -88,17 +89,13 @@ class PullRequestWorkflow: workflow.artifact = result.model_dump_json() workflow.review_json = report_json(report) await self.deps.storage.update_workflow(workflow) - await self.deps.gitea.create_comment( - job.repo_owner, - job.repo_name, - pull_number, - agent_comment("iteration", workflow.id, result_comment(result, sha=sha)), + body = agent_comment( + "iteration", workflow.id, result_comment(result, sha=sha) ) remaining = review_markdown(report) if remaining: - await self.deps.gitea.create_comment( - job.repo_owner, job.repo_name, pull_number, remaining - ) + body = f"{body}\n\n{remaining}" + await update_job_comment(self.deps, job, body) async def fix(self, job: Job) -> None: pull_number = _pull_number(job) @@ -145,10 +142,9 @@ class PullRequestWorkflow: set_upstream=False, commit_prefix="agent fix", ) - await self.deps.gitea.create_comment( - job.repo_owner, - job.repo_name, - pull_number, + await update_job_comment( + self.deps, + job, agent_comment("fix", job.id, result_comment(result, sha=sha)), ) diff --git a/tests/test_gitea.py b/tests/test_gitea.py new file mode 100644 index 0000000..bb55e38 --- /dev/null +++ b/tests/test_gitea.py @@ -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"} diff --git a/tests/test_worker.py b/tests/test_worker.py index fc172d0..9686980 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -1,7 +1,8 @@ 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.workflows.common import JobRejected class FakeStorage: @@ -20,6 +21,47 @@ class FakeOpenCode: 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: return 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")} + + +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]