mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(content): structured PR-review findings + generated GitHub comment
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user