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. |
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 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]
|
||||
|
||||
Reference in New Issue
Block a user