Author SHA1 Message Date
StanPonomarev 73045258fa feat: reuse single comment 2026-07-21 00:54:46 +02:00
9 changed files with 170 additions and 56 deletions
+2 -2
View File
@@ -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
+7
View File
@@ -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,
+24 -16
View File
@@ -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.repo_owner,
job.repo_name,
job.issue_number,
f"Agent job `{job.id}` queued (`{job.kind}`).",
)
await self.storage.set_job_comment(
job.id, "accepted_comment_id", accepted_id
)
comment_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}` started (`{job.kind}`).",
)
await self.storage.set_job_comment(job.id, "started_comment_id", comment_id)
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,
f"Agent job `{job.id}` started (`{job.kind}`).",
)
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(
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})
+9 -1
View File
@@ -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"<!-- 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
)
+4 -9
View File
@@ -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(
+6 -14
View File
@@ -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(
+8 -12
View File
@@ -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)),
)
+22
View File
@@ -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
View File
@@ -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]