[1f6a06a2] PR-review gate: verify ACs literally and require green CI before pr_pass (#428)

* [a1bde3b9] Add CI-status guard to pr_pass + update pr_reviewer prompt (#417) (#420)

* [a1bde3b9] feat(gateway): CI-status guard on pr_pass + reviewer prompt update

* [a1bde3b9] docs(pr-gate-review, worksession-git): document CI-status guard on pr_pass

Updated two architecture documentation files to reflect the new CI-status guard:

**pr-gate-review.md:**
- Documented _ci_status_guard method: blocks pr_pass on failing/pending/unscheduled/error CI with reviewer-aware pr_fail remediation
- Documented _resolve_ci_status: best-effort GitHub check-runs lookup with fail-open behavior
- Updated _pr_pass_blocked description: now returns (rejection_envelope, ci_note) tuple
- Updated _record_gate_verdict_for/verdict to note ci_status field stamping on pr_pass
- Added ci_note parameter documentation for evidence tracking when no CI is configured
- Updated Logical Tree to show new methods
- Added Config Flags note: CI guard is always armed, fails open on config gaps
- Added two regression risks: check-runs-only limitation, fail-open design

**worksession-git.md:**
- Documented GitService.get_pr_ci_status(project_slug, pr_number): CI status lookup with state classification
- Documented supporting methods: _ci_status_prereqs, _fetch_check_runs, _classify_check_runs, _classify_zero_check_runs
- Each method notes its fail-open behavior and configuration gap handling

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [e8f275d7] test(gateway): lock the 7-AC-to-test map + assert pr_reviewer prompt content (#425) (#426)

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

* [24b4237e] Fix reflow-check, CI-status classification, and noqa suppression (#440) (#443)

* [24b4237e] fix(gateway): classify unreachable/nonexistent CI-status repo as no_ci_configured, remove test noqa, reflow pr_reviewer.md

Split GitService.get_pr_ci_status's PR-head-sha lookup into a dedicated
helper so a config gap (missing project/git_url/token) or an unreachable/
nonexistent repo/PR (network error or 404) classifies as no_ci_configured
(pr_pass passes through and stamps the evidence note) while a genuine
GitHub API failure on a real, reachable repo (any other non-2xx, or an
unparseable body) stays the fail-closed error state. Replaced the
`# noqa: PLR2004` in test_git_pr_ci_status.py with a named HTTP-status
range constant, updated the config-gap tests to assert the new
classification, and added tests for the unreachable-repo and real-repo-
API-error branches. Reflowed agents/prompts/roles/pr_reviewer.md's one
hard-wrapped continuation line so it passes make reflow-check.

* [24b4237e] docs(gateway): update pr-gate-review.md for CI-status classification refactor

Updated the internal architectural map to reflect the new CI-status classification
scheme introduced in PR #440. Configuration gaps (missing project/git_url/token) and
unreachable/nonexistent repos (404 or network error) now explicitly classify as
no_ci_configured and pass through with evidence stamps. Genuine GitHub API failures
on reachable repos classify as error and stay fail-closed (retryable).

- Clarified _ci_status_guard behavior: config gaps/unreachable repos pass through
  with distinct classification; only real API failures stay fail-closed
- Updated Config Flags section to describe the new three-way classification
- Updated Regression Risks section to document the new explicit classification scheme
- Noted that _resolve_ci_status now wraps git.get_pr_ci_status and interprets its result dict

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [1f6a06a2] round-3 fixes: pr_gate back to xenon rank A; 404 means no CI, not error

Eight extracted helpers bring the module average from B(5.05) to A(4.04)
with every external contract untouched (170 gate tests byte-identical).
The CI-status guard now classifies a 404 on the check-runs or workflows
endpoints as no_ci_configured (pass-through with evidence note) —
a repo without Actions is not a transport failure — reserving the
fail-closed error state for network/5xx/auth failures, with pinning
tests for all four shapes. The e2e fake-GitHub router gains check-runs
and workflows routes so the scripted lifecycle exercises the guard's
green-CI success branch end to end.

* [1f6a06a2] merge master; align gate-diff-base tests with the tuple contract

The merged tree is the first integration of the CI-status guard with the
preferred-parent diff-base guard: _pr_pass_blocked now returns
(rejection, ci_note), so the diff-base tests unpack it instead of
asserting on a bare result. Both guards verified live in the merged
pr_gate (preferred_parent threading and _ci_status_guard present).

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-11 07:38:06 +02:00
committed by GitHub
co-authored by Backend Developer 1 Backend Documenter Renn F
parent 15a3e87a2f
commit 4d52f6ff59
10 changed files with 1270 additions and 117 deletions
+7 -1
View File
@@ -58,7 +58,13 @@ You have a second, distinct surface: the **in-path PR-review gate**. After a Cel
3. Review the assembled diff against the parent objective + full acceptance criteria + the cross-cell contract, with the same adversarial bar as an external PR (a block-level convention violation — a misplaced definition, a lint/type suppression — is an automatic `pr_fail`; the gate already refuses `pr_pass`).
4. `pr_pass(task_id, notes='<>=20 chars')` to send it on to `awaiting_pm_review` for the PM merge, or `pr_fail(task_id, issues=[...])` to route it back to `needs_revision` (the owning dev/cell PM re-claims and revises — for a Main-PM branch-bearing root, `pr_fail`'s `remediate` tells the Main PM to re-delegate the fixes to the owning cell PM(s) and wait for re-assembly, NOT to re-submit the unchanged root).
**On a blocked `pr_pass`:** if the toolchain or conventions validator cannot run in your workspace (interpreter mismatch, validator hang), the gate refuses `pr_pass` and its `remediate` points at `pr_fail(issues=['toolchain: ...'])` — your reject lever, since you have no `i_am_blocked` verb. Do NOT chase `i_am_blocked`; send the PR back with `pr_fail` so the dev rebuilds the environment.
**The per-AC evidence-walk (non-negotiable).** Do not assert "criteria met" from a skim. Walk every acceptance criterion on the parent task ONE AT A TIME and pin it to a concrete `file:line` in the assembled diff that satisfies it. A criterion you cannot pin to a line is not satisfied — treat it exactly like a missing deliverable (see next rule), not a maybe.
**The named-deliverable/silent-drop rule.** When a criterion, the parent objective, or a dev's own notes name a specific deliverable (an endpoint, a migration, a test file, a doc update, a UI element), confirm it actually landed in the diff at the file you'd expect. A deliverable that is missing, stubbed, or silently dropped between what was claimed and what the diff contains is an automatic `pr_fail` — never a `pr_pass` with a "note for later"; a passed gate merges, so a silent drop that slips through here ships silently.
**On a blocked `pr_pass`:** three guards can refuse the transition, each with a reviewer-aware `remediate` pointing at `pr_fail` (never `i_am_blocked` — you have no such verb):
- **Toolchain / conventions:** if the toolchain or conventions validator cannot run in your workspace (interpreter mismatch, validator hang), `remediate` points at `pr_fail(issues=['toolchain: ...'])` so the dev rebuilds the environment.
- **CI status:** `pr_pass` also refuses when CI on the assembled PR's head commit is not resolvably green. Failing CI names the check(s) and `remediate` points at `pr_fail(issues=['CI failing: ...'])`; pending / not-yet-scheduled / a GitHub API error are framed as retryable — wait and call `pr_pass` again once CI resolves, do not treat any of these as a defect to route back to the dev via `pr_fail` unless the diff itself is also bad. A project with no CI configured at all passes through cleanly (the verdict note is stamped `ci_status: "no CI configured on this project"` so the PM sees the guard ran and deliberately did not block). Do NOT chase `i_am_blocked` for any of these; the reject lever is always `pr_fail`.
**Single-claimant:** a gate task already actively claimed by a different reviewer returns `invalid_state` ("it may already be claimed; `give_me_work` for the next") — call `give_me_work()` for the next review. A re-claim by the same reviewer is idempotent.
+13 -5
View File
@@ -17,12 +17,14 @@ The two choreographer mixins that implement the PR-reviewer's two distinct surfa
| PRGateMixin.pr_pass | method | roboco/services/gateway/choreographer/pr_gate.py:115 | Pass the assembled PR: awaiting_pr_review → awaiting_pm_review; delegates to _gate_decision |
| PRGateMixin.pr_fail | method | roboco/services/gateway/choreographer/pr_gate.py:123 | Fail the assembled PR with concrete issues → needs_revision; rejects empty issues list, formats issues into notes, delegates to _gate_decision |
| PRGateMixin._gate_preflight | method | roboco/services/gateway/choreographer/pr_gate.py:148 | Ownership + role + spec gate (with self_review_block via actor_slug/original_developer_slug) + free-text soup guard for pr_pass/pr_fail; returns rejection Envelope or (t,agent,role_str,briefing,spec_ctx) |
| PRGateMixin._record_gate_verdict_for | method | roboco/services/gateway/choreographer/pr_gate.py:229 | Author the canonical pr_review verdict note before the transition; on pr_fail also capture the assembled PR head SHA for the unchanged-PR gate |
| PRGateMixin._record_gate_verdict_for | method | roboco/services/gateway/choreographer/pr_gate.py:229 | Author the canonical pr_review verdict note before the transition; on pr_fail also capture the assembled PR head SHA for the unchanged-PR gate; on pr_pass with ci_note, stamp the ci_status field into the verdict with evidence the CI guard ran |
| PRGateMixin._post_gate_review | method | roboco/services/gateway/choreographer/pr_gate.py:245 | Post the gate verdict to the PR itself (best-effort, after the DB transition); resolves reviewer slug |
| PRGateMixin._deliver_pr_fail_to_owner | method | roboco/services/gateway/choreographer/pr_gate.py:253 | a2a the pr_fail change-requests to the owning PM (best-effort) with a Main-PM-root steer to re-delegate not re-submit; closes the blind re-submit loop |
| PRGateMixin._gate_decision | method | roboco/services/gateway/choreographer/pr_gate.py:292 | Shared body for pr_pass/pr_fail: preflight + tracing + pr_pass blocked guards + record verdict + run_intent + None-guard for concurrent transition + post-PR + a2a on fail |
| PRGateMixin._pr_pass_blocked | method | roboco/services/gateway/choreographer/pr_gate.py:373 | Refuse pr_pass on a broken toolchain or block-level convention violation; pr_fail stays available; both guards inert when their flag is off |
| PRGateMixin._record_gate_verdict | method | roboco/services/gateway/choreographer/pr_gate.py:403 | Persist the gate verdict as the canonical pr_review structured note (passed/failed), with issues slot for pr_fail and head_sha stamp; best-effort (ContentValidationError logged not raised) |
| PRGateMixin._pr_pass_blocked | method | roboco/services/gateway/choreographer/pr_gate.py:373 | Refuse pr_pass on a broken toolchain, block-level convention violation, or non-green CI on the assembled PR's head commit; returns (rejection_envelope, ci_note). Both toolchain and conventions guards inert when their flags are off; CI guard fails open on configuration gaps |
| PRGateMixin._ci_status_guard | method | roboco/services/gateway/choreographer/pr_gate.py:520 | Refuse pr_pass unless CI on the assembled PR's head commit is green. Failing/pending/unscheduled CI blocks with reviewer-aware remediation pointing at pr_fail; configuration gaps, unreachable/nonexistent repos, or real API failures on reachable repos each pass through with distinct classifications (no_ci_configured vs error) via git.get_pr_ci_status |
| PRGateMixin._resolve_ci_status | method | roboco/services/gateway/choreographer/pr_gate.py:480 | Thin wrapper: calls git.get_pr_ci_status, interprets the returned dict (no_ci_configured/pending/failure/error/success), and composes a rejection Envelope if CI must block pr_pass |
| PRGateMixin._record_gate_verdict | method | roboco/services/gateway/choreographer/pr_gate.py:403 | Persist the gate verdict as the canonical pr_review structured note (passed/failed), with issues slot for pr_fail, head_sha stamp for pr_fail, and ci_status evidence on pr_pass; best-effort (ContentValidationError logged not raised) |
| PRGateMixin._capture_pr_head_sha | method | roboco/services/gateway/choreographer/pr_gate.py:468 | Best-effort capture of the assembled PR head SHA at pr_fail time via _project_slug_for + git.get_pr_head_sha; returns None on any failure (fail-open) |
| PRGateMixin._post_gate_review_to_pr | method | roboco/services/gateway/choreographer/pr_gate.py:502 | Post APPROVE/REQUEST_CHANGES on cell→root PRs; always COMMENT on root→master (only CEO merges master); best-effort |
| PRGateMixin._gate_role_or_rejection | method | roboco/services/gateway/choreographer/pr_gate.py:545 | Parse the role enum from role_str or return a not_authorized rejection Envelope |
@@ -85,8 +87,10 @@ pr-gate-review slice
│ ├── _gate_decision — shared body (preflight→tracing→blocked→record→run_intent→None-guard→post→a2a)
│ ├── _gate_preflight — ownership/role/spec-gate (self_review_block) + soup guard
│ ├── _gate_tracing — journal:learning + pr_reviewer_notes min chars
│ ├── _pr_pass_blocked — toolchain-broken + conventions block guards
│ ├── _record_gate_verdict_for / _record_gate_verdict — structured pr_review note (+ issues + head_sha)
│ ├── _pr_pass_blocked — toolchain-broken + conventions block + CI-status guards (returns rejection, ci_note)
│ ├── _ci_status_guard — refuse pr_pass on failing/pending/unscheduled CI; config gaps + unreachable repos pass through with evidence stamp; real API failures stay fail-closed
│ ├── _resolve_ci_status — thin wrapper calling git.get_pr_ci_status, interprets result dict, returns rejection Envelope if CI must block
│ ├── _record_gate_verdict_for / _record_gate_verdict — structured pr_review note (+ issues + head_sha + ci_status)
│ ├── _re_stamp_pr_fail_head_sha_if_advanced — re-capture head SHA post-transition and re-stamp verdict note if advanced (#189)
│ ├── _capture_pr_head_sha — best-effort PR head SHA for unchanged-PR gate
│ ├── _post_gate_review / _post_gate_review_to_pr — PR review post (COMMENT on root→master or MegaTask root-subtask)
@@ -125,6 +129,7 @@ pr-gate-review slice
- ROBOCO_TOOLCHAIN_MATCH_ENABLED (gates _toolchain_broken_guard in _pr_pass_blocked — inert when off)
- ROBOCO_CONVENTIONS_ENABLED (gates _conventions_guard in _pr_pass_blocked — inert when off)
- ROBOCO_PR_REVIEWER_NOTES_MIN_CHARS / settings.pr_reviewer_notes_min_chars (tracing gate substantive-note threshold for pr_pass/pr_fail/post_pr_review)
- CI-status guard is always armed when the toolchain can reach get_pr_ci_status via git service. Configuration gaps (missing project/git_url/token) and unreachable/nonexistent repos (404 or network error) classify as no_ci_configured and pass through with evidence stamp. Genuine API failures on reachable repos classify as error and stay fail-closed (retryable). A project with no CI configured at all also passes through cleanly (no_ci_configured). The guard never blocks pr_pass on a misconfigured project.
## Gotchas
@@ -168,6 +173,9 @@ pr-gate-review slice
| Title | File:Line | Claim | Severity |
|---|---|---|---|
| CI-status guard reads GitHub check-runs only, not legacy commit-status API | roboco/services/gateway/choreographer/pr_gate.py:520 | _ci_status_guard and get_pr_ci_status read only the check-runs API endpoint. A repo whose only CI signal is the legacy commit-status API would show zero check-runs and be classified as no_ci_configured (passes through). Noted as ponytail-comment in git.py with the upgrade path if a project ever needs it. | low |
| CI-status classification: config gaps and unreachable repos now explicitly no_ci_configured | roboco/services/git.py:_resolve_ci_head_sha | Missing project/git_url/git-token, or unreachable/nonexistent repo (404 on PR head lookup or network error), all classify as no_ci_configured → pr_pass passes through with evidence stamp. Only genuine GitHub API failures on reachable repos classify as error → pr_pass stays fail-closed (retryable). By design: configuration gaps should not block the gate, but real API failures should fail-closed to avoid false-green verdicts. A deliberately misconfigured project's CI is silently not enforced, with clear evidence in the verdict note. | low |
|---|---|---|---|
| self_review_block could fire if a reviewer is also the original developer | roboco/services/gateway/choreographer/pr_gate.py:202 | actor_slug=str(reviewer_agent_id) + original_developer_slug=markers.get_original_developer(t). The comment asserts dormancy because the marker is never set on assembled coordination tasks. If a future change sets the marker on an assembled task (or a reviewer UUID coincides with the recorded dev UUID), pr_pass/pr_fail would be refused as self-review with no remediate path. The defense is correctly wired but unguarded by a test asserting dormancy. | low |
| ~~resolve_task_project_slug cell_projects branch can raise AttributeError on malformed mapping~~ **FIXED 536bbb64 #82** | roboco/services/gateway/choreographer/pr_review.py:594 | ~~sorted(cell_map, key=lambda m: m.team.value) assumes every mapping has a non-None team with .value. _capture_pr_head_sha wraps the slug call in try/except (fail-open), but _post_gate_review_to_pr calls self._project_slug_for(t) WITHOUT a try/except — a malformed cell_map mapping would raise and abort the verdict PR post (best-effort but the exception escapes the helper, caught only by the outer try in _post_gate_review_to_pr's git.post_pr_review call, NOT the slug resolution).~~ _post_gate_review_to_pr now wraps the slug-resolve call in its own try/except (mirrors _capture_pr_head_sha) — a malformed mapping logs and returns, no longer 500s the reviewer after the committed gate transition. The underlying AttributeError possibility in resolve_task_project_slug remains but is contained. | medium |
| ~~_is_hand_formatted_verdict false-positive on summaries quoting PR-added headers~~ **FIXED 536bbb64 #188** | roboco/services/gateway/choreographer/pr_review.py:174 | ~~Substring match on '## summary'/'## issues'/'## verdict'/'## findings' in lowercased body. A reviewer summarizing a PR that itself adds a '## Summary' section (quoting it in the body) with findings=[] would be falsely refused.~~ Regex now anchored to line-start (^[ \t]*## ..., re.MULTILINE) — quoted headers (> ## Summary) and mid-prose mentions no longer trip the guard. | low |
+5
View File
@@ -64,6 +64,11 @@ This slice is the git substrate every delivery agent works on. `GitService` runs
| `GitService.update_pr_for_task` | method | git.py:2506 | Patch PR title/body; 404→typed GitError |
| `GitService.get_pr_head_sha` | method | git.py:2451 | PR head SHA for pr_fail re-submit loop guard (fail-open) |
| `GitService.get_latest_ci_conclusion` | method | git.py:1929 | Per-project CI signal (unknown never false-green) |
| `GitService.get_pr_ci_status` | method | git.py:2662 | CI status of a PR's current head commit for the in-path pr_pass gate; returns {state, failing_checks?, head_sha} or None on config gaps (fail-open) |
| `GitService._ci_status_prereqs` | method | git.py:2698 | Resolve (owner, repo, auth headers, head_sha) for CI-status lookup or None on any gap |
| `GitService._fetch_check_runs` | method | git.py:2730 | GET check-runs for head_sha; None on any API failure |
| `GitService._classify_check_runs` | method | git.py:2770 | State classification: success/failure/pending from check-run conclusions list |
| `GitService._classify_zero_check_runs` | method | git.py:2790 | State classification when zero check-runs exist: pending_not_scheduled or no_ci_configured (depends on workflow count) |
| `GitService.list_open_prs` | method | git.py:1844 | Normalized open-PR list |
| `GitService.post_pr_review` | method | git.py:2329 | Post reviewer comments via GitHub API |
| `GitService.merge_pull_request` | method | git.py:2914 | GitHub merge API + method fallback + already-merged disambiguation |
+315 -104
View File
@@ -53,37 +53,10 @@ class PRGateMixin(_Base):
pr_pass / pr_fail source-status still matches. The assembled PR's diff is
returned inline (read-only) so the reviewer inspects it before deciding.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_gate_review",
)
agent = await self.task.agent_for(reviewer_agent_id)
role_str = str(agent.role) if agent is not None else "pr_reviewer"
briefing = await self._briefing_for(reviewer_agent_id, task_id, full=True)
role = await self._gate_role_or_rejection(
t, role_str, briefing, reviewer_agent_id, task_id, "claim_gate_review"
)
if isinstance(role, Envelope):
return role
spec_ctx = spec_module.Context(
actor_id=reviewer_agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
agent_team=str(agent.team) if agent is not None and agent.team else None,
)
decision = spec_module.can_invoke_intent(role, "claim_gate_review", t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_gate_review",
)
pre = await self._claim_gate_preflight(reviewer_agent_id, task_id)
if isinstance(pre, Envelope):
return pre
t, role_str, briefing = pre
guard = await self._run_claim_guards(
agent_id=reviewer_agent_id,
task=t,
@@ -119,6 +92,52 @@ class PRGateMixin(_Base):
context_briefing=briefing,
).with_introspection(task=t, role=role_str)
async def _claim_gate_preflight(
self, reviewer_agent_id: UUID, task_id: UUID
) -> Any:
"""Task fetch + role + spec gate for ``claim_gate_review``.
Returns a rejection ``Envelope`` or the ``(t, role_str, briefing)``
tuple on pass.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_gate_review",
)
agent = await self.task.agent_for(reviewer_agent_id)
role_str = self._role_str_for_agent(agent)
briefing = await self._briefing_for(reviewer_agent_id, task_id, full=True)
role = await self._gate_role_or_rejection(
t, role_str, briefing, reviewer_agent_id, task_id, "claim_gate_review"
)
if isinstance(role, Envelope):
return role
spec_ctx = spec_module.Context(
actor_id=reviewer_agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
agent_team=str(agent.team) if agent is not None and agent.team else None,
)
decision = spec_module.can_invoke_intent(role, "claim_gate_review", t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="claim_gate_review",
)
return (t, role_str, briefing)
@staticmethod
def _role_str_for_agent(agent: Any) -> str:
"""Reviewer role string off the agent view, defaulting to pr_reviewer."""
return str(agent.role) if agent is not None else "pr_reviewer"
async def pr_pass(
self, reviewer_agent_id: UUID, task_id: UUID, notes: str
) -> Envelope:
@@ -166,53 +185,19 @@ class PRGateMixin(_Base):
Returns a rejection ``Envelope`` or the
``(t, agent, role_str, briefing, spec_ctx)`` tuple on pass.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
if t.assigned_to != reviewer_agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_gate_review(task_id) first",
context_briefing=await self._briefing_for(
reviewer_agent_id, task_id
),
).with_introspection(task=t, role="pr_reviewer"),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
t = await self._gate_ownership_or_rejection(reviewer_agent_id, task_id, verb)
if isinstance(t, Envelope):
return t
agent = await self.task.agent_for(reviewer_agent_id)
role_str = str(agent.role) if agent is not None else "pr_reviewer"
role_str = self._role_str_for_agent(agent)
briefing = await self._briefing_for(reviewer_agent_id, task_id)
role = await self._gate_role_or_rejection(
t, role_str, briefing, reviewer_agent_id, task_id, verb
)
if isinstance(role, Envelope):
return role
# The spec gate's ``self_review_block`` is the only self-review defense
# for pr_pass / pr_fail: the service-layer ``_validate_not_self_review``
# backstop covers qa/documenter but skips pr_reviewer. For the comparison
# to fire, both sides must be populated. ``GatewayAgentView`` carries no
# ``slug`` field (so ``getattr(agent, "slug", None)`` is always None in
# production), and the ``original_developer`` marker stores the dev's
# UUID — so resolve both as UUID strings and let the spec's string
# equality do the rest. The marker is never set on assembled coordination
# tasks (only on dev-leaf tasks at QA/doc claim), so the block is dormant
# by design in production — but the gate is now correctly wired to fire
# if the marker were ever set to the reviewer.
spec_ctx = spec_module.Context(
actor_id=reviewer_agent_id,
actor_slug=str(reviewer_agent_id),
agent_team=str(agent.team) if agent is not None and agent.team else None,
original_developer_slug=markers.get_original_developer(t),
notes=notes,
issues=issues,
spec_ctx = self._gate_preflight_spec_ctx(
reviewer_agent_id, agent, t, notes, issues
)
if soup := await self._guard_free_text(
checks=(("notes", notes, 8), ("issues", list(issues), 8)),
@@ -234,8 +219,73 @@ class PRGateMixin(_Base):
)
return (t, agent, role_str, briefing, spec_ctx)
async def _gate_ownership_or_rejection(
self, reviewer_agent_id: UUID, task_id: UUID, verb: str
) -> Any:
"""Fetch the task and verify it is assigned to the reviewer.
Returns the task on success, or a ``not_found`` / ``not_authorized``
rejection ``Envelope`` on failure.
"""
t = await self.task.get(task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
if t.assigned_to != reviewer_agent_id:
return await self._emit_rejection(
Envelope.not_authorized(
message="not assigned to you",
remediate="claim it via claim_gate_review(task_id) first",
context_briefing=await self._briefing_for(
reviewer_agent_id, task_id
),
).with_introspection(task=t, role="pr_reviewer"),
agent_id=reviewer_agent_id,
task_id=task_id,
verb=verb,
)
return t
@staticmethod
def _gate_preflight_spec_ctx(
reviewer_agent_id: UUID, agent: Any, t: Any, notes: str, issues: tuple[str, ...]
) -> spec_module.Context:
"""Build the pr_pass / pr_fail spec ``Context``, including the
self-review wiring.
The spec gate's ``self_review_block`` is the only self-review defense
for pr_pass / pr_fail: the service-layer ``_validate_not_self_review``
backstop covers qa/documenter but skips pr_reviewer. For the comparison
to fire, both sides must be populated. ``GatewayAgentView`` carries no
``slug`` field (so ``getattr(agent, "slug", None)`` is always None in
production), and the ``original_developer`` marker stores the dev's
UUID — so resolve both as UUID strings and let the spec's string
equality do the rest. The marker is never set on assembled coordination
tasks (only on dev-leaf tasks at QA/doc claim), so the block is dormant
by design in production — but the gate is now correctly wired to fire
if the marker were ever set to the reviewer.
"""
return spec_module.Context(
actor_id=reviewer_agent_id,
actor_slug=str(reviewer_agent_id),
agent_team=str(agent.team) if agent is not None and agent.team else None,
original_developer_slug=markers.get_original_developer(t),
notes=notes,
issues=issues,
)
async def _record_gate_verdict_for(
self, verb: str, t: Any, notes: str, *, issues: tuple[str, ...]
self,
verb: str,
t: Any,
notes: str,
*,
issues: tuple[str, ...],
ci_note: str | None = None,
) -> str | None:
"""Author the canonical pr_review verdict note before the transition.
@@ -243,6 +293,9 @@ class PRGateMixin(_Base):
can structurally refuse to re-submit the unchanged root (the 2026-06-27
infinite pr_fail re-submit loop). Best-effort: a capture failure leaves
head_sha absent and submit_root fails open rather than wedging the PM.
On pr_pass, ``ci_note`` (set by ``_pr_pass_blocked`` when the CI-status
guard passed through a project with no CI configured) is stamped into
the verdict's ``ci_status`` field as evidence the guard actually ran.
Returns the captured head_sha for pr_fail (None for pr_pass) so the caller
can re-capture after the transition commits and re-stamp if the PR head
@@ -252,7 +305,7 @@ class PRGateMixin(_Base):
head_sha = await self._capture_pr_head_sha(t)
self._record_gate_verdict(t, verb, notes, issues=issues, head_sha=head_sha)
return head_sha
self._record_gate_verdict(t, verb, notes, issues=issues)
self._record_gate_verdict(t, verb, notes, issues=issues, ci_note=ci_note)
return None
async def _re_stamp_pr_fail_head_sha_if_advanced(
@@ -354,8 +407,9 @@ class PRGateMixin(_Base):
)
if gate is not None:
return gate
ci_note: str | None = None
if verb == "pr_pass":
blocked = await self._pr_pass_blocked(
blocked, ci_note = await self._pr_pass_blocked(
reviewer_agent_id, task_id, t, role_str, briefing
)
if blocked is not None:
@@ -363,7 +417,9 @@ class PRGateMixin(_Base):
# Author the canonical pr_review verdict note BEFORE the transition so it
# is persisted by the same commit (mirrors post_pr_review) and stays in
# lock-step with the decision (pr_fail overwrites an earlier pr_pass).
pre_sha = await self._record_gate_verdict_for(verb, t, notes, issues=issues)
pre_sha = await self._record_gate_verdict_for(
verb, t, notes, issues=issues, ci_note=ci_note
)
runner = self._verb_runner()
try:
t = await runner.run_intent(verb, t, agent, spec_ctx)
@@ -433,13 +489,23 @@ class PRGateMixin(_Base):
t: Any,
role_str: str,
briefing: dict[str, Any],
) -> Envelope | None:
"""Refuse pr_pass on a broken toolchain or a block-level violation.
) -> tuple[Envelope | None, str | None]:
"""Refuse pr_pass on a broken toolchain, a block-level violation, or
non-green CI on the assembled PR's head commit.
A reviewer must not PASS an assembled PR whose suite can't run in the
workspace, or that carries unresolved architectural-convention
violations; pr_fail stays available. Returns the emitted rejection or
None to proceed. Both guards are inert when their flag is off.
workspace, that carries unresolved architectural-convention
violations, or whose CI is red/pending/unscheduled/unresolvable;
pr_fail stays available for all three. Returns ``(rejection, None)``
to block, or ``(None, ci_note)`` to proceed — ``ci_note`` is a
non-None evidence stamp only when the CI guard passed through a
project with no CI configured at all. The toolchain/conventions
guards are inert when their flag is off; the CI guard fails open on
an unresolvable gate-level slug/PR number (``None`` from
``_resolve_ci_status``) and also passes through — with an evidence
stamp — when ``get_pr_ci_status`` itself classifies a missing
project/git_url/token or an unreachable/nonexistent repo as
``no_ci_configured``.
"""
from roboco.config import settings as _settings
@@ -457,13 +523,128 @@ class PRGateMixin(_Base):
for guard in guards:
rejection = await guard()
if rejection is not None:
return await self._emit_rejection(
rejection.with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="pr_pass",
return (
await self._emit_rejection(
rejection.with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="pr_pass",
),
None,
)
return None
return await self._ci_status_guard(
reviewer_agent_id, task_id, t, role_str, briefing
)
async def _resolve_ci_status(self, task_id: UUID, t: Any) -> dict[str, Any] | None:
"""Best-effort CI-status lookup for the assembled PR's head commit.
Returns ``None`` on ANY configuration gap or lookup failure (no
resolvable slug/PR number, a raised exception, or a caller returning
something other than the documented ``dict[str, Any]`` shape) so
``_ci_status_guard`` fails open on every one of them uniformly.
"""
pr_number = getattr(t, "pr_number", None)
try:
slug = await self._project_slug_for(t)
except Exception:
logger.exception(
"ci status guard: slug resolve failed", task_id=str(task_id)
)
return None
if not slug or not pr_number:
return None
try:
status = await self.git.get_pr_ci_status(slug, int(pr_number))
except Exception:
logger.exception(
"ci status guard: get_pr_ci_status raised", task_id=str(task_id)
)
return None
return status if isinstance(status, dict) else None
async def _ci_status_guard(
self,
reviewer_agent_id: UUID,
task_id: UUID,
t: Any,
role_str: str,
briefing: dict[str, Any],
) -> tuple[Envelope | None, str | None]:
"""Refuse pr_pass unless CI on the assembled PR's head commit is green.
Failing, pending, unscheduled, or unresolvable-via-API CI states all
block with a reviewer-aware remediation pointing at ``pr_fail`` (a
reviewer has no ``i_am_blocked``) — pending/unscheduled/error are
framed as retryable (wait and call pr_pass again), never as a defect
to route back to the dev. A project with no CI configured at all
passes through cleanly, returning an evidence note so the caller can
stamp the verdict with why the guard did not block.
"""
status = await self._resolve_ci_status(task_id, t)
if status is None:
# A configuration gap or lookup failure — never mistaken for a CI
# signal, so the guard fails open rather than blocking.
return None, None
state = status.get("state")
if state == "success":
return None, None
if state == "no_ci_configured":
return None, "no CI configured on this project"
message, remediate = self._ci_status_block_message(state, status)
return (
await self._emit_rejection(
Envelope.invalid_state(
message=message,
remediate=remediate,
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=reviewer_agent_id,
task_id=task_id,
verb="pr_pass",
),
None,
)
@staticmethod
def _ci_status_block_message(
state: str | None, status: dict[str, Any]
) -> tuple[str, str]:
"""(message, remediate) for a blocking CI state — failure / pending /
pending_not_scheduled / error (the non-terminal, non-green states).
pending/unscheduled/error are framed as retryable (wait and call
pr_pass again), never as a defect to route back to the dev.
"""
if state == "failure":
names = (
", ".join(status.get("failing_checks") or []) or "one or more checks"
)
return (
f"CI is failing on the assembled PR's head commit — {names}",
f"call pr_fail(issues=['CI failing: {names}']) so the PR returns "
"to needs_revision and the dev fixes the failing check(s) — do "
"NOT pr_pass on red CI",
)
if state == "pending":
return (
"CI is still running on the assembled PR's head commit",
"wait for CI to finish and call pr_pass again once it's green "
"— do NOT pr_pass while checks are still running",
)
if state == "pending_not_scheduled":
return (
"CI has not started running on the assembled PR's head commit yet",
"wait for CI to be scheduled and call pr_pass again once it's "
"green — do NOT pr_pass before any check has run",
)
# state == "error" (or an unrecognized value) — a genuine GitHub API
# failure resolving the signal; never treat this as green.
return (
"could not determine CI status for the assembled PR (GitHub API error)",
"retry pr_pass shortly once the CI status can be resolved; "
"if it persists, pr_fail(issues=[...]) to unwedge the PR",
)
def _record_gate_verdict(
self,
@@ -473,6 +654,7 @@ class PRGateMixin(_Base):
issues: tuple[str, ...] = (),
*,
head_sha: str | None = None,
ci_note: str | None = None,
) -> None:
"""Persist the gate verdict as the canonical ``pr_review`` note.
@@ -493,25 +675,59 @@ class PRGateMixin(_Base):
re-submit the unchanged root — the 2026-06-27 infinite ``pr_fail``
re-submit loop. ``None`` (the default) leaves it absent, which the
``submit_root`` gate treats as fail-open.
On ``pr_pass``, ``ci_note`` (set only when the CI-status guard passed
through a project with no CI configured) is stamped into the slot's
``ci_status`` field — the evidence that the guard ran and deliberately
did not block, rather than silently never having checked at all.
"""
from roboco.foundation.policy.content import ContentValidationError
from roboco.services.content_notes import apply_structured_note
verdict = "passed" if verb == "pr_pass" else "failed"
summary = self._gate_verdict_summary(verb, notes, issues)
payload = self._gate_verdict_payload(
verdict, summary, issues, verb, head_sha=head_sha, ci_note=ci_note
)
try:
apply_structured_note(t, "pr_review", payload)
except ContentValidationError:
logger.warning(
"gate verdict note skipped (invalid content)",
verb=verb,
task_id=str(getattr(t, "id", "")),
)
@staticmethod
def _gate_verdict_summary(verb: str, notes: str, issues: tuple[str, ...]) -> str:
"""The verdict note's ``summary`` field.
The free-text ``issues`` render under their own ``## Issues`` section
(render_markdown). Baking them into ``summary`` too duplicated each
issue on the Task Details "PR Reviewer Notes" card (once under
## Summary, once under ## Issues). The summary is a substantive
non-issues sentence; ``notes`` (with the issues) still drives the
GitHub PR post and the a2a to the owning PM — those are raw text,
not rendered through render_markdown, so no duplication there.
"""
if verb == "pr_fail" and issues:
# The free-text issues render under their own ``## Issues`` section
# (render_markdown). Baking them into ``summary`` too duplicated each
# issue on the Task Details "PR Reviewer Notes" card (once under
# ## Summary, once under ## Issues). The summary is a substantive
# non-issues sentence; ``notes`` (with the issues) still drives the
# GitHub PR post and the a2a to the owning PM — those are raw text,
# not rendered through render_markdown, so no duplication there.
summary = (
return (
f"In-path PR-review gate requested changes - "
f"{len(issues)} issue(s) listed below."
)
else:
summary = notes
return notes
@staticmethod
def _gate_verdict_payload(
verdict: str,
summary: str,
issues: tuple[str, ...],
verb: str,
*,
head_sha: str | None,
ci_note: str | None,
) -> dict[str, Any]:
"""Assemble the structured ``pr_review`` note payload."""
payload: dict[str, Any] = {
"summary": summary,
"findings": [],
@@ -521,14 +737,9 @@ class PRGateMixin(_Base):
payload["issues"] = list(issues)
if verb == "pr_fail" and head_sha:
payload["head_sha"] = head_sha
try:
apply_structured_note(t, "pr_review", payload)
except ContentValidationError:
logger.warning(
"gate verdict note skipped (invalid content)",
verb=verb,
task_id=str(getattr(t, "id", "")),
)
if verb == "pr_pass" and ci_note:
payload["ci_status"] = ci_note
return payload
async def _capture_pr_head_sha(self, t: Any) -> str | None:
"""Best-effort capture of the assembled PR's head SHA at ``pr_fail`` time.
+259
View File
@@ -327,6 +327,17 @@ _CI_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
# Cap a conventions-validator run so a hung subprocess (tree-sitter deadlock,
# huge repo) can't hang the i_am_done/pr_pass gate forever.
_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS = 120
# --- pr_pass CI-status guard ------------------------------------------------
# GitHub check-run conclusions that count as a failing check on a PR's head
# commit. ``neutral``/``skipped``/``success`` (and ``None`` on a still-running
# run) are not failing.
_FAILING_CHECK_CONCLUSIONS = frozenset(
{"failure", "cancelled", "timed_out", "action_required"}
)
# A 404 resolving a PR's head SHA means the repo/PR is unreachable or doesn't
# exist — classified as no_ci_configured, distinct from any other non-2xx
# (a genuine API failure on a real, reachable repo) which is `error`.
_HTTP_NOT_FOUND = 404
def _select_ci_head_run(runs: list[dict[str, Any]]) -> dict[str, Any]:
@@ -2655,6 +2666,254 @@ class GitService(BaseService):
)
return None
async def get_pr_ci_status(
self, project_slug: str, pr_number: int
) -> dict[str, Any] | None:
"""CI status of a PR's current head commit, for the in-path ``pr_pass`` gate.
Reads GitHub check-runs on the PR's head SHA — the same signal GitHub
Actions surfaces on the PR page and classifies it into one
``state``: ``success`` (every check-run completed with no failing
conclusion), ``failure`` (at least one completed check-run failed
``failing_checks`` names them), ``pending`` (at least one check-run
has not completed yet), ``pending_not_scheduled`` (zero check-runs
exist for this commit but the repo has workflows configured CI
just hasn't started), ``no_ci_configured`` (zero check-runs AND the
repo has no workflows at all or the project/git_url/token is
missing, or the repo/PR is unreachable/nonexistent), or ``error`` (a
genuine GitHub API failure on a real, reachable repo).
Every unresolvable case is now classified explicitly rather than
returning ``None``: a missing project/git_url/git-token, or an
unreachable/nonexistent repo or PR (a network failure or a 404 on
the PR-head, check-runs, or workflows lookup) all classify as
``no_ci_configured`` ``pr_pass`` passes through cleanly and still
stamps the evidence note, instead of silently skipping the guard. A
genuine GitHub API failure on a real, reachable repo (any other
non-2xx, or an unparseable response) classifies as ``error`` so
``pr_pass`` stays fail-closed and retryable. This mirrors ``get_pr_head_sha`` /
``_capture_pr_head_sha`` in spirit (never mistake a configuration gap
for a CI signal) but resolves head-sha lookups via a dedicated helper
so the two failure classes above stay distinguishable the shared
``get_pr_head_sha`` (used by the unrelated pr_fail head-sha capture)
is untouched.
ponytail: reads GitHub check-runs only (the project's own CI is
GitHub Actions). A repo whose only signal is the legacy commit-status
API would show zero check-runs here; add a statuses fallback if that
ever becomes a real CI provider for a project.
"""
config = await self._ci_status_config(project_slug)
if isinstance(config, dict):
return config
owner, repo, headers = config
head_sha_or_gap = await self._resolve_ci_head_sha(
project_slug, pr_number, owner, repo, headers
)
if isinstance(head_sha_or_gap, dict):
return head_sha_or_gap
head_sha = head_sha_or_gap
check_runs = await self._fetch_check_runs(
project_slug, owner, repo, head_sha, headers
)
if isinstance(check_runs, dict):
return check_runs
if check_runs:
return self._classify_check_runs(check_runs, head_sha)
return await self._classify_zero_check_runs(
project_slug, owner, repo, head_sha, headers
)
async def _ci_status_config(
self, project_slug: str
) -> tuple[str, str, dict[str, str]] | dict[str, Any]:
"""Resolve ``(owner, repo, auth headers)`` for a CI-status lookup, or
a terminal ``no_ci_configured`` gap dict when the project, its
git_url, or a git token is missing, or the git_url doesn't parse."""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
return {"state": "no_ci_configured", "head_sha": None}
try:
owner, repo = self._parse_git_url(project.git_url)
except GitError:
return {"state": "no_ci_configured", "head_sha": None}
git_token = await self._token_for_project(project_slug)
if not git_token:
return {"state": "no_ci_configured", "head_sha": None}
headers = {
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
return owner, repo, headers
async def _resolve_ci_head_sha(
self,
project_slug: str,
pr_number: int,
owner: str,
repo: str,
headers: dict[str, str],
) -> str | dict[str, Any]:
"""Resolve the PR's head SHA for ``get_pr_ci_status`` specifically.
Returns the head SHA (``str``) on success, or a terminal gap-state
dict to return directly from the caller on failure. A network error
or a 404 (the repo or PR doesn't exist / isn't reachable) is
``no_ci_configured`` there is no way to determine a CI signal, so
the guard should pass through, not block. Any other non-2xx or an
unparseable response is a real, reachable repo whose API call itself
failed, so it is ``error`` a retryable signal the guard must not
treat as green.
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}",
headers=headers,
)
except httpx.HTTPError as e:
self.log.warning(
"get_pr_ci_status pr lookup unreachable",
project=project_slug,
error=str(e),
)
return {"state": "no_ci_configured", "head_sha": None}
if resp.status_code == _HTTP_NOT_FOUND:
return {"state": "no_ci_configured", "head_sha": None}
if not resp.is_success:
self.log.warning(
"get_pr_ci_status pr lookup non-2xx",
project=project_slug,
status=resp.status_code,
)
return {"state": "error", "head_sha": None}
try:
return str(resp.json()["head"]["sha"])
except (ValueError, KeyError, TypeError) as e:
self.log.warning(
"get_pr_ci_status pr lookup parse failed",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": None}
async def _fetch_check_runs(
self,
project_slug: str,
owner: str,
repo: str,
head_sha: str,
headers: dict[str, str],
) -> list[dict[str, Any]] | dict[str, Any]:
"""GET the check-runs for ``head_sha``.
Returns the (possibly empty) check-runs list on success, or a
terminal gap-state dict to return directly from the caller: a 404
(the repo/commit isn't reachable, e.g. no CI integration at all) is
``no_ci_configured``; any other failure (network, non-2xx,
unparseable body) is ``error``.
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/commits/{head_sha}/check-runs",
headers=headers,
params={"per_page": 100},
)
except httpx.HTTPError as e:
self.log.warning(
"get_pr_ci_status check-runs request failed",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": head_sha}
if resp.status_code == _HTTP_NOT_FOUND:
return {"state": "no_ci_configured", "head_sha": head_sha}
if not resp.is_success:
self.log.warning(
"get_pr_ci_status check-runs non-2xx",
project=project_slug,
status=resp.status_code,
)
return {"state": "error", "head_sha": head_sha}
try:
runs = resp.json().get("check_runs")
except (ValueError, AttributeError) as e:
self.log.warning(
"get_pr_ci_status check-runs parse failed",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": head_sha}
return runs if isinstance(runs, list) else []
@staticmethod
def _classify_check_runs(
check_runs: list[dict[str, Any]], head_sha: str
) -> dict[str, Any]:
"""Map a non-empty check-runs list to a failure/pending/success state."""
failing = [
str(cr.get("name") or "check")
for cr in check_runs
if cr.get("status") == "completed"
and cr.get("conclusion") in _FAILING_CHECK_CONCLUSIONS
]
if failing:
return {"state": "failure", "failing_checks": failing, "head_sha": head_sha}
if any(cr.get("status") != "completed" for cr in check_runs):
return {"state": "pending", "head_sha": head_sha}
return {"state": "success", "head_sha": head_sha}
async def _classify_zero_check_runs(
self,
project_slug: str,
owner: str,
repo: str,
head_sha: str,
headers: dict[str, str],
) -> dict[str, Any]:
"""No check-runs exist yet for ``head_sha`` — tell "not scheduled" apart
from "no CI configured" by asking whether the repo has any workflows.
A 404 here (repo unreachable/nonexistent no CI integration) is
``no_ci_configured``; any other failure is ``error``.
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/actions/workflows",
headers=headers,
params={"per_page": 1},
)
except httpx.HTTPError as e:
self.log.warning(
"get_pr_ci_status workflows request failed",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": head_sha}
if resp.status_code == _HTTP_NOT_FOUND:
return {"state": "no_ci_configured", "head_sha": head_sha}
if not resp.is_success:
self.log.warning(
"get_pr_ci_status workflows non-2xx",
project=project_slug,
status=resp.status_code,
)
return {"state": "error", "head_sha": head_sha}
try:
total = int(resp.json().get("total_count") or 0)
except (ValueError, AttributeError, TypeError) as e:
self.log.warning(
"get_pr_ci_status workflows parse failed",
project=project_slug,
error=str(e),
)
return {"state": "error", "head_sha": head_sha}
state = "pending_not_scheduled" if total > 0 else "no_ci_configured"
return {"state": state, "head_sha": head_sha}
async def update_pr_for_task(
self,
task_id: UUID,
+17
View File
@@ -227,6 +227,23 @@ def _fake_github_router(gh: _FakeGitHub) -> APIRouter:
with suppress(subprocess.CalledProcessError):
_git(gh.origin, "branch", "-D", branch)
# A minimal green check-runs signal for any commit — real-life NAS
# projects have CI, so the pr_pass CI-status guard should see a
# `success` state and exercise its pass-through branch, not the
# no_ci_configured 404 path.
@r.get("/repos/{owner}/{repo}/commits/{sha}/check-runs")
async def check_runs(owner: str, repo: str, sha: str) -> dict[str, Any]:
return {
"total_count": 1,
"check_runs": [
{"name": "ci", "status": "completed", "conclusion": "success"}
],
}
@r.get("/repos/{owner}/{repo}/actions/workflows")
async def workflows(owner: str, repo: str) -> dict[str, Any]:
return {"total_count": 1, "workflows": [{"id": 1, "name": "ci"}]}
return r
@@ -194,9 +194,11 @@ class TestPrPassBlockedThreadsParent:
cc._conventions_guard = AsyncMock(return_value=None)
reviewer_id = uuid4()
result = await c._pr_pass_blocked(reviewer_id, uuid4(), t, "pr_reviewer", {})
rejection, _ci_note = await c._pr_pass_blocked(
reviewer_id, uuid4(), t, "pr_reviewer", {}
)
assert result is None
assert rejection is None
cc._conventions_guard.assert_awaited_once_with(
reviewer_id,
t,
@@ -219,9 +221,11 @@ class TestPrPassBlockedThreadsParent:
cc._toolchain_broken_guard = AsyncMock(return_value=None)
cc._conventions_guard = AsyncMock(return_value=None)
result = await c._pr_pass_blocked(uuid4(), uuid4(), t, "pr_reviewer", {})
rejection, _ci_note = await c._pr_pass_blocked(
uuid4(), uuid4(), t, "pr_reviewer", {}
)
assert result is None
assert rejection is None
task_service.get.assert_not_called()
cc._conventions_guard.assert_awaited_once()
assert cc._conventions_guard.await_args.kwargs.get("preferred_parent") is None
@@ -69,10 +69,13 @@ def _stub_gate_path(
)
cc._gate_tracing = AsyncMock(return_value=None)
# These tests exercise the pr_fail a2a / notify path, not the head-sha
# capture (which has its own suite in test_submit_root_unchanged_pr_guard).
# Stub the capture so it does not walk the mock session into un-awaited
# coroutines; the verdict still lands via the _record_gate_verdict spy.
# capture (which has its own suite in test_submit_root_unchanged_pr_guard)
# or the pr_pass CI-status guard (its own suite in
# test_pr_pass_ci_status_guard). Stub both so they do not walk the mock
# session into un-awaited coroutines; the verdict still lands via the
# _record_gate_verdict spy.
cc._capture_pr_head_sha = AsyncMock(return_value=None)
cc._project_slug_for = AsyncMock(return_value=None)
cc._record_gate_verdict = MagicMock()
cc._post_gate_review_to_pr = AsyncMock()
runner = MagicMock()
@@ -0,0 +1,300 @@
"""pr_pass refuses to pass an assembled PR unless CI on its head commit is green.
Before this guard, ``pr_pass`` had no CI-status check at all a reviewer could
pass an assembled PR whose CI was red, still running, or not yet scheduled.
``_ci_status_guard`` (wired into ``_pr_pass_blocked`` alongside the existing
toolchain/conventions guards) reads ``GitService.get_pr_ci_status`` and blocks
on failure/pending/pending_not_scheduled/error with reviewer-aware remediation
(``pr_fail``, never ``i_am_blocked`` a reviewer has no such verb). A project
with no CI configured at all passes through cleanly, stamping the verdict note
with why the guard did not block. ``pr_fail`` is unaffected by CI state
entirely, and the separate inbound ``PRReviewerMixin`` surface
(``claim_pr_review`` / ``post_pr_review``) never consults CI status at all.
"""
from __future__ import annotations
import inspect
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.foundation.policy import lifecycle as spec_module
from roboco.services.gateway.choreographer import (
Choreographer,
ChoreographerDeps,
pr_review,
)
def _make_choreographer() -> Choreographer:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
return Choreographer(ChoreographerDeps(**base))
def _stub_gate_path(
c: Choreographer, *, reviewer_id: Any, t_before: Any, t_after: Any
) -> MagicMock:
"""Drive ``_gate_decision`` past preflight/tracing and into the real
``_pr_pass_blocked`` -> ``_ci_status_guard`` path only the ownership/
tracing plumbing is stubbed (it has its own tests); the CI guard under
test runs for real. Mirrors ``test_pr_gate_notifies_pm._stub_gate_path``.
"""
agent = MagicMock(role="pr_reviewer", slug="be-pr-reviewer")
cc: Any = c
cc._gate_preflight = AsyncMock(
return_value=(
t_before,
agent,
"pr_reviewer",
{},
spec_module.Context(actor_id=reviewer_id),
)
)
cc._gate_tracing = AsyncMock(return_value=None)
cc._project_slug_for = AsyncMock(return_value="proj-slug")
record_spy = MagicMock()
cc._record_gate_verdict = record_spy
cc._post_gate_review_to_pr = AsyncMock()
runner = MagicMock()
runner.run_intent = AsyncMock(return_value=t_after)
cc._verb_runner = MagicMock(return_value=runner)
return record_spy
def _t(*, status: str = "awaiting_pr_review", pr_number: int | None = 42) -> MagicMock:
return MagicMock(
id=uuid4(),
assigned_to=None,
pr_number=pr_number,
parent_task_id=uuid4(),
status=status,
)
# ---------------------------------------------------------------------------
# The six CI-guard branches, exercised through pr_pass
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pr_pass_blocked_on_failing_ci() -> None:
reviewer_id = uuid4()
t_before = _t()
c = _make_choreographer()
_stub_gate_path(c, reviewer_id=reviewer_id, t_before=t_before, t_after=None)
c.git.get_pr_ci_status = AsyncMock(
return_value={"state": "failure", "failing_checks": ["tests"]}
)
env = await c.pr_pass(reviewer_id, t_before.id, "Looks clean to me.")
assert env.error == "invalid_state"
assert "CI is failing" in (env.message or "")
assert "tests" in (env.message or "")
assert "pr_fail" in (env.remediate or "")
c.task.get.assert_not_called() # never reached the runner
cc: Any = c
cc._record_gate_verdict.assert_not_called()
@pytest.mark.asyncio
async def test_pr_pass_blocked_on_pending_ci() -> None:
reviewer_id = uuid4()
t_before = _t()
c = _make_choreographer()
_stub_gate_path(c, reviewer_id=reviewer_id, t_before=t_before, t_after=None)
c.git.get_pr_ci_status = AsyncMock(return_value={"state": "pending"})
env = await c.pr_pass(reviewer_id, t_before.id, "Looks clean to me.")
assert env.error == "invalid_state"
assert "still running" in (env.message or "")
assert "wait" in (env.remediate or "").lower()
@pytest.mark.asyncio
async def test_pr_pass_blocked_on_zero_checks_workflows_pending() -> None:
reviewer_id = uuid4()
t_before = _t()
c = _make_choreographer()
_stub_gate_path(c, reviewer_id=reviewer_id, t_before=t_before, t_after=None)
c.git.get_pr_ci_status = AsyncMock(return_value={"state": "pending_not_scheduled"})
env = await c.pr_pass(reviewer_id, t_before.id, "Looks clean to me.")
assert env.error == "invalid_state"
assert "has not started" in (env.message or "")
@pytest.mark.asyncio
async def test_pr_pass_blocked_on_github_api_error() -> None:
reviewer_id = uuid4()
t_before = _t()
c = _make_choreographer()
_stub_gate_path(c, reviewer_id=reviewer_id, t_before=t_before, t_after=None)
c.git.get_pr_ci_status = AsyncMock(return_value={"state": "error"})
env = await c.pr_pass(reviewer_id, t_before.id, "Looks clean to me.")
assert env.error == "invalid_state"
assert "GitHub API error" in (env.message or "")
assert "retry" in (env.remediate or "").lower()
@pytest.mark.asyncio
async def test_pr_pass_succeeds_on_all_green() -> None:
reviewer_id = uuid4()
t_before = _t()
t_after = _t(status="awaiting_pm_review")
c = _make_choreographer()
record_spy = _stub_gate_path(
c, reviewer_id=reviewer_id, t_before=t_before, t_after=t_after
)
c.git.get_pr_ci_status = AsyncMock(return_value={"state": "success"})
env = await c.pr_pass(reviewer_id, t_before.id, "Looks clean to me.")
assert env.error is None, env.as_dict()
assert env.status == "awaiting_pm_review"
record_spy.assert_called_once()
# No CI note stamped when the guard passed because CI was actually green.
assert record_spy.call_args.kwargs.get("ci_note") is None
@pytest.mark.asyncio
async def test_pr_pass_passes_through_when_no_ci_configured_and_stamps_evidence() -> (
None
):
reviewer_id = uuid4()
t_before = _t()
t_after = _t(status="awaiting_pm_review")
c = _make_choreographer()
record_spy = _stub_gate_path(
c, reviewer_id=reviewer_id, t_before=t_before, t_after=t_after
)
c.git.get_pr_ci_status = AsyncMock(return_value={"state": "no_ci_configured"})
env = await c.pr_pass(reviewer_id, t_before.id, "Looks clean to me.")
assert env.error is None, env.as_dict()
assert env.status == "awaiting_pm_review"
record_spy.assert_called_once()
assert (
record_spy.call_args.kwargs.get("ci_note") == "no CI configured on this project"
)
@pytest.mark.asyncio
async def test_pr_pass_fails_open_when_ci_status_unresolvable() -> None:
"""A configuration gap (no resolvable project/token/head sha) -> None from
get_pr_ci_status -> the guard never blocks (fail open, matches the other
pr_pass guards' posture on an unresolvable signal)."""
reviewer_id = uuid4()
t_before = _t()
t_after = _t(status="awaiting_pm_review")
c = _make_choreographer()
_stub_gate_path(c, reviewer_id=reviewer_id, t_before=t_before, t_after=t_after)
c.git.get_pr_ci_status = AsyncMock(return_value=None)
env = await c.pr_pass(reviewer_id, t_before.id, "Looks clean to me.")
assert env.error is None, env.as_dict()
assert env.status == "awaiting_pm_review"
# ---------------------------------------------------------------------------
# Regression: pr_fail is unaffected by CI state entirely
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pr_fail_succeeds_regardless_of_ci_state() -> None:
"""pr_fail must never consult get_pr_ci_status — the CI guard lives only in
the pr_pass branch of _gate_decision."""
reviewer_id = uuid4()
t_before = _t()
t_after = _t(status="needs_revision")
c = _make_choreographer()
_stub_gate_path(c, reviewer_id=reviewer_id, t_before=t_before, t_after=t_after)
# Even if configured to report red CI, pr_fail must not care — and must not
# even call it.
c.git.get_pr_ci_status = AsyncMock(return_value={"state": "failure"})
env = await c.pr_fail(reviewer_id, t_before.id, ["a concrete actionable issue"])
assert env.error is None, env.as_dict()
assert env.status == "needs_revision"
c.git.get_pr_ci_status.assert_not_awaited()
# ---------------------------------------------------------------------------
# Regression: the inbound PRReviewerMixin surface never consults CI status
# ---------------------------------------------------------------------------
def test_pr_review_mixin_has_no_ci_status_coupling() -> None:
"""claim_pr_review / post_pr_review (the external inbound review surface,
``PRReviewerMixin`` in pr_review.py) must stay completely untouched by the
new CI-status guard it is wired only into ``PRGateMixin.pr_pass``
(the in-path assembled-PR gate) via ``_pr_pass_blocked``. A source-level
check is the most robust regression here: any accidental import or call of
``get_pr_ci_status`` / ``_ci_status_guard`` into the inbound mixin fails
this immediately, regardless of how its heavier claim/decision plumbing
(self_review_block, tracing, content gates) evolves."""
source = inspect.getsource(pr_review)
assert "get_pr_ci_status" not in source
assert "_ci_status_guard" not in source
assert not hasattr(pr_review.PRReviewerMixin, "_ci_status_guard")
# ---------------------------------------------------------------------------
# Regression-lock: this task's 7 ACs mapped to the test(s) that cover each
# ---------------------------------------------------------------------------
def test_ac_coverage_map_and_pr_reviewer_prompt_states_ci_guard() -> None:
"""Explicit AC-to-test mapping for this task's 7 acceptance criteria.
AC1 (CI failure names the failing check) ->
test_pr_pass_blocked_on_failing_ci (this file, line ~90)
AC2 (pending vs error are distinct invalid_state envelopes with a
different remediate text) -> test_pr_pass_blocked_on_pending_ci
(~111), test_pr_pass_blocked_on_github_api_error (~140)
AC3 (pending_not_scheduled is the retryable not-yet-scheduled case) ->
test_pr_pass_blocked_on_zero_checks_workflows_pending (~126)
AC4 (no_ci_configured passes through + stamps the ci_status verdict
note) -> test_pr_pass_passes_through_when_no_ci_configured_and_
stamps_evidence (~175)
AC5 (all-green CI passes through with no note) ->
test_pr_pass_succeeds_on_all_green (~155)
AC6 (pr_fail and the inbound PRReviewerMixin have zero CI-status
coupling) -> test_pr_fail_succeeds_regardless_of_ci_state (~221),
test_pr_review_mixin_has_no_ci_status_coupling (~245)
AC7 (the pr_reviewer prompt states the per-AC evidence-walk + CI-status
guard section) -> asserted directly below. Previously verified only
by manual reading during self-verification, with no test-level
regression lock this closes that gap.
"""
prompt_path = (
Path(__file__).resolve().parents[3]
/ "agents"
/ "prompts"
/ "roles"
/ "pr_reviewer.md"
)
text = prompt_path.read_text(encoding="utf-8")
assert "per-AC evidence-walk" in text
assert "named-deliverable/silent-drop rule" in text
assert "CI status:" in text
assert 'ci_status: "no CI configured on this project"' in text
@@ -0,0 +1,340 @@
"""GitService.get_pr_ci_status — the CI signal behind the pr_pass gate.
Reads GitHub check-runs on a PR's head SHA (falling back to list-workflows
when zero check-runs exist yet) and classifies the result into one of:
success, failure, pending, pending_not_scheduled, no_ci_configured, error.
Every unresolvable case is classified explicitly a missing project/git_url/
token, or an unreachable/nonexistent repo or PR, is ``no_ci_configured``
(the guard passes through with an evidence stamp); a genuine GitHub API
failure on a real, reachable repo is ``error`` (the guard stays fail-closed).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from roboco.services.git import GitService
_PR = 42
_SHA = "deadbeefcafebabe0000111122223333aaaabbbb"
_HTTP_SUCCESS_RANGE = range(200, 300)
def _service() -> GitService:
session = MagicMock()
session.execute = AsyncMock()
svc = GitService(session)
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value="tok"))
return svc
def _resp(status_code: int, *, json_payload: Any = None) -> MagicMock:
resp = MagicMock()
resp.status_code = status_code
resp.is_success = status_code in _HTTP_SUCCESS_RANGE
resp.json.return_value = json_payload
return resp
def _client(*get_responses: MagicMock) -> MagicMock:
"""A fake httpx.AsyncClient whose ``.get`` serves responses in call order —
every ``async with httpx.AsyncClient(...) as client`` in the service reuses
the SAME instance (the patch target returns it unconditionally), so a list
``side_effect`` lines up with the sequential PR-head / check-runs /
workflows calls."""
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(side_effect=list(get_responses))
return client
def _patch_project() -> Any:
fake = MagicMock()
fake.get_by_slug = AsyncMock(
return_value=MagicMock(git_url="https://github.com/acme/repo.git")
)
return patch("roboco.services.git.get_project_service", return_value=fake)
def _pr_head_resp() -> MagicMock:
return _resp(200, json_payload={"head": {"sha": _SHA}})
def _check_run(name: str, *, status: str, conclusion: str | None) -> dict[str, Any]:
return {"name": name, "status": status, "conclusion": conclusion}
# ---------------------------------------------------------------------------
# Six CI-guard branches
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_all_checks_green_is_success() -> None:
checks = _resp(
200,
json_payload={
"check_runs": [
_check_run("lint", status="completed", conclusion="success"),
_check_run("tests", status="completed", conclusion="neutral"),
]
},
)
client = _client(_pr_head_resp(), checks)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "success", "head_sha": _SHA}
@pytest.mark.asyncio
async def test_failing_check_names_it() -> None:
checks = _resp(
200,
json_payload={
"check_runs": [
_check_run("lint", status="completed", conclusion="success"),
_check_run("tests", status="completed", conclusion="failure"),
]
},
)
client = _client(_pr_head_resp(), checks)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out is not None
assert out["state"] == "failure"
assert out["failing_checks"] == ["tests"]
assert out["head_sha"] == _SHA
@pytest.mark.asyncio
async def test_still_running_check_is_pending() -> None:
checks = _resp(
200,
json_payload={
"check_runs": [
_check_run("lint", status="completed", conclusion="success"),
_check_run("tests", status="in_progress", conclusion=None),
]
},
)
client = _client(_pr_head_resp(), checks)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "pending", "head_sha": _SHA}
@pytest.mark.asyncio
async def test_check_runs_api_error_is_error_state() -> None:
checks = _resp(500, json_payload={"message": "internal error"})
client = _client(_pr_head_resp(), checks)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "error", "head_sha": _SHA}
@pytest.mark.asyncio
async def test_zero_checks_with_no_workflows_is_no_ci_configured() -> None:
checks = _resp(200, json_payload={"check_runs": []})
workflows = _resp(200, json_payload={"total_count": 0})
client = _client(_pr_head_resp(), checks, workflows)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "no_ci_configured", "head_sha": _SHA}
@pytest.mark.asyncio
async def test_zero_checks_with_workflows_configured_is_pending_not_scheduled() -> None:
checks = _resp(200, json_payload={"check_runs": []})
workflows = _resp(200, json_payload={"total_count": 3})
client = _client(_pr_head_resp(), checks, workflows)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "pending_not_scheduled", "head_sha": _SHA}
# ---------------------------------------------------------------------------
# Config gaps and an unreachable/nonexistent repo pass through cleanly as
# no_ci_configured (pr_pass stamps the evidence note, never mistaken for a
# CI signal)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_no_ci_configured_on_missing_token() -> None:
svc = _service()
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
with _patch_project():
out = await svc.get_pr_ci_status("roboco", _PR)
assert out == {"state": "no_ci_configured", "head_sha": None}
@pytest.mark.asyncio
async def test_no_ci_configured_on_missing_project() -> None:
fake = MagicMock()
fake.get_by_slug = AsyncMock(return_value=None)
with patch("roboco.services.git.get_project_service", return_value=fake):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "no_ci_configured", "head_sha": None}
@pytest.mark.asyncio
async def test_no_ci_configured_when_pr_lookup_404s() -> None:
# The PR lookup itself 404s — the repo/PR doesn't exist or isn't reachable.
pr_lookup = _resp(404, json_payload={"message": "not found"})
client = _client(pr_lookup)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "no_ci_configured", "head_sha": None}
@pytest.mark.asyncio
async def test_no_ci_configured_when_pr_lookup_unreachable() -> None:
# A connection/network failure resolving the PR's head SHA — the repo is
# unreachable, not just returning an error response.
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "no_ci_configured", "head_sha": None}
# ---------------------------------------------------------------------------
# A real, reachable repo's genuinely failing GitHub API call stays
# fail-closed (error), never conflated with the no_ci_configured cases above
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_error_when_pr_lookup_api_fails_on_real_repo() -> None:
# The repo/project/token all resolve fine, but the PR-head-sha lookup
# itself returns a genuine 5xx — this must stay fail-closed (error), not
# be conflated with the unreachable/nonexistent-repo no_ci_configured case.
pr_lookup = _resp(500, json_payload={"message": "internal error"})
client = _client(pr_lookup)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "error", "head_sha": None}
@pytest.mark.asyncio
async def test_error_when_pr_lookup_body_unparseable() -> None:
pr_lookup = _resp(200, json_payload={"head": {}}) # missing "sha" key
client = _client(pr_lookup)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "error", "head_sha": None}
@pytest.mark.asyncio
async def test_workflows_api_error_after_zero_checks_is_error_state() -> None:
checks = _resp(200, json_payload={"check_runs": []})
workflows = _resp(503, json_payload={"message": "busy"})
client = _client(_pr_head_resp(), checks, workflows)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "error", "head_sha": _SHA}
# ---------------------------------------------------------------------------
# A 404 on the check-runs or workflows endpoint means the repo has no CI
# integration at all (e.g. the e2e harness's fake GitHub with no routes
# mounted for either) — no_ci_configured, never mistaken for error. 500s and
# network failures on the same two endpoints stay fail-closed (error).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_no_ci_configured_when_check_runs_404s() -> None:
checks = _resp(404, json_payload={"message": "not found"})
client = _client(_pr_head_resp(), checks)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "no_ci_configured", "head_sha": _SHA}
@pytest.mark.asyncio
async def test_error_when_check_runs_request_times_out() -> None:
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(
side_effect=[_pr_head_resp(), httpx.ReadTimeout("timed out")]
)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "error", "head_sha": _SHA}
@pytest.mark.asyncio
async def test_no_ci_configured_when_workflows_404s() -> None:
checks = _resp(200, json_payload={"check_runs": []})
workflows = _resp(404, json_payload={"message": "not found"})
client = _client(_pr_head_resp(), checks, workflows)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "no_ci_configured", "head_sha": _SHA}
@pytest.mark.asyncio
async def test_error_when_workflows_request_times_out() -> None:
checks = _resp(200, json_payload={"check_runs": []})
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(
side_effect=[_pr_head_resp(), checks, httpx.ReadTimeout("timed out")]
)
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await _service().get_pr_ci_status("roboco", _PR)
assert out == {"state": "error", "head_sha": _SHA}