feat(content): structured PR-review findings + generated GitHub comment

This commit is contained in:
Renn F
2026-06-21 03:47:17 +02:00
parent b97465a631
commit 11c1d9eee7
7 changed files with 171 additions and 33 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ The PR is from an outside contributor: its code is **untrusted**. Until a human
|---|---|---| |---|---|---|
| `give_me_work()` | Returns an external-PR review task or `idle`. | None. | | `give_me_work()` | Returns an external-PR review task or `idle`. | None. |
| `claim_pr_review(task_id)` | Claims the review task and starts it. `pending → claimed → in_progress`. Returns the PR diff inline. | Task is an `external_pr` review task in `pending`. | | `claim_pr_review(task_id)` | Claims the review task and starts it. `pending → claimed → in_progress`. Returns the PR diff inline. | Task is an `external_pr` review task in `pending`. |
| `post_pr_review(task_id, ...)` | Posts ONE complete change-request to the PR and finishes the review task. `in_progress → completed`. | Task claimed by you; findings cover every relevant criterion. | | `post_pr_review(task_id, body, findings=[...])` | Posts ONE complete change-request and finishes the review. `in_progress → completed`. `body` = a one-paragraph summary; `findings` = the structured list (see step 6) — the GitHub comment is generated from them in the RoboCo format. | Task claimed by you; findings cover every relevant criterion. |
| `note(text, scope?)` | Journal entry. Record your reasoning. | None. | | `note(text, scope?)` | Journal entry. Record your reasoning. | None. |
| `evidence(task_id)` | Re-fetch the PR diff if you need more detail. | None. | | `evidence(task_id)` | Re-fetch the PR diff if you need more detail. | None. |
| `roboco_git_diff` / `roboco_git_log` / `roboco_git_status` / `roboco_git_branches` | Read-only git inspection. | None. | | `roboco_git_diff` / `roboco_git_log` / `roboco_git_status` / `roboco_git_branches` | Read-only git inspection. | None. |
@@ -35,7 +35,7 @@ The PR is from an outside contributor: its code is **untrusted**. Until a human
3. Review the diff **read-only**. Do NOT run the contributor's code unless the PR is human-confirmed. 3. Review the diff **read-only**. Do NOT run the contributor's code unless the PR is human-confirmed.
4. For each acceptance criterion and each correctness/security/quality concern, find the specific evidence (file/line) and form a concrete, actionable finding. 4. For each acceptance criterion and each correctness/security/quality concern, find the specific evidence (file/line) and form a concrete, actionable finding.
5. `note(scope='learning', ...)` capturing what the review surfaced. 5. `note(scope='learning', ...)` capturing what the review surfaced.
6. `post_pr_review(task_id, ...)` → one complete change-request, per-criterion findings, each referencing file + line + expected vs actual. 6. `post_pr_review(task_id, body="<one-paragraph summary>", findings=[...])` — supply **structured** findings, one object per issue: `{"file": "path", "line": 42, "severity": "blocker|major|minor|nit", "expected": "...", "actual": "..."}`. The GitHub comment is generated in the RoboCo format (summary + findings table + verdict); do not hand-format the body.
## Anti-patterns ## Anti-patterns
+1 -1
View File
@@ -59,7 +59,7 @@ async def post_pr_review(
choreographer: _ChoreographerDep, choreographer: _ChoreographerDep,
) -> dict: ) -> dict:
env = await choreographer.post_pr_review( env = await choreographer.post_pr_review(
x_agent_id, body.task_id, body.body, body.event x_agent_id, body.task_id, body.body, body.event, body.findings
) )
return envelope_to_response(env, request) return envelope_to_response(env, request)
+9
View File
@@ -1,5 +1,6 @@
"""Request schemas for /api/v1/flow/* intent verbs.""" """Request schemas for /api/v1/flow/* intent verbs."""
from typing import Any
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
@@ -133,6 +134,14 @@ class PostPrReviewRequest(BaseModel):
task_id: UUID task_id: UUID
body: str = Field(..., min_length=1) body: str = Field(..., min_length=1)
event: str = "REQUEST_CHANGES" event: str = "REQUEST_CHANGES"
findings: list[dict[str, Any]] = Field(
default_factory=list,
description=(
"Structured per-criterion findings — each {file, line?, severity "
"(blocker|major|minor|nit), expected, actual}. When provided, the "
"GitHub comment is generated from them in the RoboCo format."
),
)
class ClaimGateReviewRequest(BaseModel): class ClaimGateReviewRequest(BaseModel):
+16 -4
View File
@@ -365,16 +365,28 @@ def claim_pr_review(task_id: str) -> dict[str, Any]:
def post_pr_review( def post_pr_review(
task_id: str, body: str, event: str = "REQUEST_CHANGES" task_id: str,
body: str,
event: str = "REQUEST_CHANGES",
findings: list[dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""PR reviewer: post ONE complete change-request to the PR and finish the task. """PR reviewer: post ONE complete change-request to the PR and finish the task.
body: the full review (per-criterion findings). event: REQUEST_CHANGES body: a one-paragraph summary. findings: the per-criterion list — each
(default), APPROVE, or COMMENT. Requires a journal:learning entry first. {file, line?, severity (blocker|major|minor|nit), expected, actual}. When
findings are given, the GitHub comment is GENERATED in the RoboCo format
(summary + a findings table + verdict) — do not hand-format it in body.
event: REQUEST_CHANGES (default), APPROVE, or COMMENT. Requires a
journal:learning entry first.
""" """
return _post( return _post(
_role_path("post_pr_review"), _role_path("post_pr_review"),
{"task_id": task_id, "body": body, "event": event}, {
"task_id": task_id,
"body": body,
"event": event,
"findings": findings or [],
},
) )
@@ -21,6 +21,8 @@ import structlog
from roboco.foundation.policy import lifecycle as spec_module from roboco.foundation.policy import lifecycle as spec_module
from roboco.foundation.policy import tracing as _tr from roboco.foundation.policy import tracing as _tr
from roboco.foundation.policy.content import ContentValidationError, validate_content
from roboco.services.content_notes import apply_structured_note
from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.envelope import Envelope
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -115,12 +117,83 @@ class PRReviewerMixin(_Base):
context_briefing=briefing, context_briefing=briefing,
).with_introspection(task=t, role=role_str) ).with_introspection(task=t, role=role_str)
@staticmethod
def _build_pr_review_content(
body: str, findings: list[dict[str, Any]], event: str
) -> Any:
"""Validate structured findings into a PrReviewContent, or an Envelope.
The reviewer supplies a summary (``body``) + structured ``findings``; the
canonical GitHub comment is generated from them. ``event`` maps to the
verdict (APPROVE → approved, else changes_requested).
"""
verdict = "approved" if event == "APPROVE" else "changes_requested"
try:
return validate_content(
"pr_review",
{"summary": body, "findings": findings, "verdict": verdict},
)
except ContentValidationError as exc:
return Envelope.invalid_state(
message=f"malformed PR-review findings: {exc.field}{exc.reason}",
remediate=(
"each finding needs file + expected + actual (line + severity "
"optional); re-call post_pr_review with structured findings"
),
)
def _resolve_post_body(
self, t: Any, body: str, findings: list[dict[str, Any]] | None, event: str
) -> Any:
"""The GitHub comment body: the canonical render when findings are given
(and stored structured), else the free-text body. Envelope on malformed
findings."""
if not findings:
return body
structured = self._build_pr_review_content(body, findings, event)
if isinstance(structured, Envelope):
return structured
apply_structured_note(t, "pr_review", structured)
return structured.render_markdown()
async def _post_review_side_effects(
self,
t: Any,
slug: str | None,
pr_number: int | None,
post_body: str,
event: str,
task_id: UUID,
) -> None:
"""Post the review to GitHub + surface it to the CEO (both best-effort)."""
if slug and pr_number:
try:
await self.git.post_pr_review(slug, pr_number, post_body, event=event)
except Exception:
logger.exception(
"post_pr_review GitHub post failed", task_id=str(task_id)
)
if pr_number:
try:
from roboco.services.notification import NotificationService
await NotificationService().send_external_pr_reviewed_notification(
task_id=str(task_id),
pr_number=pr_number,
pr_url=str(getattr(t, "pr_url", "") or ""),
)
except Exception:
logger.exception(
"post_pr_review CEO notify failed", task_id=str(task_id)
)
async def post_pr_review( async def post_pr_review(
self, self,
reviewer_agent_id: UUID, reviewer_agent_id: UUID,
task_id: UUID, task_id: UUID,
body: str, body: str,
event: str = "REQUEST_CHANGES", event: str = "REQUEST_CHANGES",
findings: list[dict[str, Any]] | None = None,
) -> Envelope: ) -> Envelope:
"""Post ONE change-request to the PR and finish the review task. """Post ONE change-request to the PR and finish the review task.
@@ -144,6 +217,14 @@ class PRReviewerMixin(_Base):
agent, role_str, briefing, spec_ctx = pre agent, role_str, briefing, spec_ctx = pre
slug = await self._project_slug_for(t) slug = await self._project_slug_for(t)
pr_number = t.pr_number pr_number = t.pr_number
post_body = self._resolve_post_body(t, body, findings, event)
if isinstance(post_body, Envelope):
return await self._emit_rejection(
post_body.with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="post_pr_review",
)
runner = self._verb_runner() runner = self._verb_runner()
try: try:
t = await runner.run_intent("post_pr_review", t, agent, spec_ctx) t = await runner.run_intent("post_pr_review", t, agent, spec_ctx)
@@ -151,32 +232,11 @@ class PRReviewerMixin(_Base):
return await self._runner_failure( return await self._runner_failure(
exc, t, role_str, briefing, reviewer_agent_id, task_id, "post_pr_review" exc, t, role_str, briefing, reviewer_agent_id, task_id, "post_pr_review"
) )
# GitHub side-effect AFTER the DB transition (a2a.send pattern). Best- # Side-effects AFTER the DB transition (a2a.send pattern), both best-
# effort: a posting failure is logged, not rolled back — the review task # effort: post the canonical review to GitHub + surface it to the CEO.
# is complete; a missed post can be re-driven manually. await self._post_review_side_effects(
if slug and pr_number: t, slug, pr_number, post_body, event, task_id
try: )
await self.git.post_pr_review(slug, pr_number, body, event=event)
except Exception:
logger.exception(
"post_pr_review GitHub post failed", task_id=str(task_id)
)
# Surface the review to the CEO as an actionable decision (supersede /
# dismiss). The reviewer is read-only with no notify verb, so the server
# emits it. Best-effort — a notify failure must not fail the review.
if pr_number:
try:
from roboco.services.notification import NotificationService
await NotificationService().send_external_pr_reviewed_notification(
task_id=str(task_id),
pr_number=pr_number,
pr_url=str(getattr(t, "pr_url", "") or ""),
)
except Exception:
logger.exception(
"post_pr_review CEO notify failed", task_id=str(task_id)
)
return Envelope.ok( return Envelope.ok(
status=str(t.status), status=str(t.status),
task_id=str(task_id), task_id=str(task_id),
+4
View File
@@ -3649,7 +3649,11 @@ class TaskService(BaseService):
``qa_notes`` / ``dev_notes``. Structured per-line findings arrive via the ``qa_notes`` / ``dev_notes``. Structured per-line findings arrive via the
reviewer verb; until then the free-text summary + issues are captured. reviewer verb; until then the free-text summary + issues are captured.
Best-effort: a too-trivial review body must never block the transition. Best-effort: a too-trivial review body must never block the transition.
Skips when a structured PrReviewContent is already stored (the
post_pr_review verb with findings does that itself).
""" """
if (task.notes_structured or {}).get("pr_review"):
return
body = (summary or "").strip() body = (summary or "").strip()
if issues: if issues:
bullets = "\n".join(f"- {i}" for i in issues if i and i.strip()) bullets = "\n".join(f"- {i}" for i in issues if i and i.strip())
@@ -0,0 +1,53 @@
"""post_pr_review structured-findings helper.
The reviewer supplies a summary + structured findings; the verb generates the
canonical GitHub comment from them and rejects malformed findings.
"""
from __future__ import annotations
from roboco.foundation.policy.content import PrReviewContent
from roboco.services.gateway.choreographer.pr_review import PRReviewerMixin
from roboco.services.gateway.envelope import Envelope
_build = PRReviewerMixin._build_pr_review_content
def test_valid_findings_build_pr_review_content() -> None:
content = _build(
"The 422 path is unguarded.",
[
{
"file": "roboco/services/git.py",
"line": 42,
"severity": "blocker",
"expected": "retry as COMMENT",
"actual": "raises",
}
],
"REQUEST_CHANGES",
)
assert isinstance(content, PrReviewContent)
assert content.verdict.value == "changes_requested"
md = content.render_markdown()
assert "## Findings" in md
assert "`roboco/services/git.py`" in md
assert "blocker" in md
def test_approve_event_maps_to_approved_verdict() -> None:
content = _build("All criteria met; clean diff.", [], "APPROVE")
assert isinstance(content, PrReviewContent)
assert content.verdict.value == "approved"
def test_malformed_findings_return_envelope() -> None:
# A finding missing the required `actual` field is rejected with remediation.
result = _build(
"Something is off here.",
[{"file": "a.py", "severity": "minor", "expected": "x"}],
"REQUEST_CHANGES",
)
assert isinstance(result, Envelope)
assert result.error is not None
assert "finding" in (result.remediate or "").lower()