fix(pr-gate): land gate verdict on product-scoped PRs + persist it to notes

Two in-path PR-review-gate bugs surfaced reviewing the guard-core-app recovery
roots (PR #107 / root fead4372):

1. No verdict comment reached the PR. _project_slug_for returned None whenever
   project_id was None — but a Main-PM coordination root (the only task a
   root->master PR ever sits on) carries just a product_id (the cell->repo map),
   so _post_gate_review_to_pr resolved a None slug and silently no-op'd. It now
   falls through to the product's first distinct project (mirrors
   GitService._project_for_task), so the gate verdict actually lands on the PR.

2. The task's PR-reviewer notes contradicted the transition. pr_pass / pr_fail
   only threaded their notes through the tracing-gate shim and posted to GitHub;
   nothing wrote notes_structured.pr_review. A root passed once and later failed
   kept showing verdict=passed while the real transition was pr_fail. The gate
   now authors the canonical pr_review note on every decision (pr_pass -> passed,
   pr_fail -> failed), best-effort so a malformed note never rolls back the gate.

Adds unit tests for the product-slug fallback and the verdict persistence.
This commit is contained in:
Renn F
2026-06-25 10:54:31 +02:00
parent a51c3d312a
commit 2cce7d6a9f
4 changed files with 214 additions and 3 deletions
@@ -240,6 +240,12 @@ class PRGateMixin(_Base):
)
if blocked is not None:
return blocked
# Author the canonical pr_review verdict note BEFORE the transition so
# it is persisted by the same commit (mirrors post_pr_review). This is
# what keeps notes_structured.pr_review in lock-step with the decision —
# a later pr_fail overwrites an earlier pr_pass verdict instead of
# leaving a stale "passed" on a task that was just sent back.
self._record_gate_verdict(t, verb, notes)
runner = self._verb_runner()
try:
t = await runner.run_intent(verb, t, agent, spec_ctx)
@@ -296,6 +302,34 @@ class PRGateMixin(_Base):
)
return None
def _record_gate_verdict(self, t: Any, verb: str, notes: str) -> None:
"""Persist the gate verdict as the canonical ``pr_review`` note.
The tracing gate only threads ``notes`` through a throwaway shim, so
nothing wrote the task's structured PR-reviewer slot — a task passed
once and later failed kept showing the stale ``verdict: passed``. This
authors the slot on every decision (``pr_pass`` → passed, ``pr_fail`` →
failed) so it can never contradict the transition. Best-effort: content
validation (e.g. a too-short summary) must never roll back the gate, so a
malformed payload is logged and skipped rather than raised.
"""
from roboco.foundation.policy.content import ContentValidationError
from roboco.services.content_notes import apply_structured_note
verdict = "passed" if verb == "pr_pass" else "failed"
try:
apply_structured_note(
t,
"pr_review",
{"summary": notes, "findings": [], "verdict": verdict},
)
except ContentValidationError:
logger.warning(
"gate verdict note skipped (invalid content)",
verb=verb,
task_id=str(getattr(t, "id", "")),
)
async def _post_gate_review_to_pr(
self, t: Any, verb: str, reviewer_slug: str, notes: str
) -> None:
@@ -403,10 +403,34 @@ class PRReviewerMixin(_Base):
)
async def _project_slug_for(self, t: Any) -> str | None:
"""Resolve the project slug for a task (for read-only PR API calls)."""
"""Resolve the project slug for a task (read-only PR API calls + the
in-path gate's PR comment).
A normal task carries ``project_id``. A Main-PM coordination root — the
only task a root→master PR ever sits on — often carries just a
``product_id`` (the cell→repo map) and no project of its own; its
``feature/main_pm/{root}`` branch + PR live in the product's repo. Fall
through to the product's first distinct project (a monorepo product maps
every cell to one repo) so the gate verdict reaches the PR instead of
silently no-op'ing. Purely additive: a task WITH ``project_id`` resolves
exactly as before.
"""
from uuid import UUID
from roboco.services.project import get_project_service
if t.project_id is None:
project_service = get_project_service(self.task.session)
if t.project_id is not None:
project = await project_service.get(t.project_id)
return project.slug if project is not None else None
product_id = getattr(t, "product_id", None)
if product_id is None:
return None
project = await get_project_service(self.task.session).get(t.project_id)
from roboco.services.product import get_product_service
product_service = get_product_service(self.task.session)
project_ids = await product_service.distinct_project_ids(UUID(str(product_id)))
if not project_ids:
return None
project = await project_service.get(project_ids[0])
return project.slug if project is not None else None