mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [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>
232 lines
8.4 KiB
Python
232 lines
8.4 KiB
Python
"""In-path PR-review gate: the assembled diff must use the REAL parent branch.
|
|
|
|
``_build_gate_review_evidence`` (claim_gate_review) and ``_pr_pass_blocked``
|
|
(pr_pass's conventions guard) used to call ``git.diff`` / the conventions
|
|
check with no base, which derives the parent via the same-team string
|
|
surgery ``parent_branch_for`` — wrong for every cross-team cell→root hop
|
|
(the cell task's own team segment can't derive the ``main_pm`` root's
|
|
branch). Both now resolve ``preferred_parent`` via
|
|
``merge_chain.resolve_parent_branch`` (reads the parent TASK's own
|
|
``branch_name``) and thread it through, falling back exactly like the
|
|
pre-fix derivation for a root / branchless-parent / parentless task.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.config import settings
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
|
|
|
|
def _make_choreographer(*, task_service: AsyncMock, git: AsyncMock) -> Choreographer:
|
|
return Choreographer(
|
|
ChoreographerDeps(
|
|
task=task_service,
|
|
work_session=AsyncMock(),
|
|
git=git,
|
|
a2a=AsyncMock(),
|
|
journal=AsyncMock(),
|
|
audit=AsyncMock(),
|
|
evidence_repo=AsyncMock(),
|
|
)
|
|
)
|
|
|
|
|
|
def _gate_task(*, branch_name: str, parent_task_id: Any) -> Any:
|
|
return MagicMock(
|
|
branch_name=branch_name,
|
|
parent_task_id=parent_task_id,
|
|
pr_number=139,
|
|
pr_url="https://example/pr/139",
|
|
acceptance_criteria=[],
|
|
)
|
|
|
|
|
|
class TestGateDiffParent:
|
|
"""``_gate_diff_parent`` mirrors ``resolve_parent_branch``'s three cases."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cross_team_child_uses_parent_task_branch(self) -> None:
|
|
parent_id = uuid4()
|
|
t = _gate_task(
|
|
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
|
|
parent_task_id=parent_id,
|
|
)
|
|
task_service = AsyncMock()
|
|
task_service.get.return_value = MagicMock(
|
|
branch_name="feature/main_pm/f7d0a61a--e56e6543"
|
|
)
|
|
c = _make_choreographer(task_service=task_service, git=AsyncMock())
|
|
|
|
parent = await c._gate_diff_parent(t)
|
|
assert parent == "feature/main_pm/f7d0a61a--e56e6543"
|
|
task_service.get.assert_awaited_once_with(parent_id)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_root_subtask_with_branchless_umbrella_uses_project_default(
|
|
self,
|
|
) -> None:
|
|
parent_id = uuid4()
|
|
t = _gate_task(
|
|
branch_name="feature/main_pm/f7d0a61a--e56e6543", parent_task_id=parent_id
|
|
)
|
|
task_service = AsyncMock()
|
|
task_service.get.return_value = MagicMock(branch_name=None)
|
|
task_service.project_default_branch_for_task = AsyncMock(return_value="master")
|
|
c = _make_choreographer(task_service=task_service, git=AsyncMock())
|
|
|
|
parent = await c._gate_diff_parent(t)
|
|
assert parent == "master"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parentless_root_falls_back_to_string_derivation(self) -> None:
|
|
t = _gate_task(branch_name="feature/main_pm/f7d0a61a", parent_task_id=None)
|
|
task_service = AsyncMock()
|
|
c = _make_choreographer(task_service=task_service, git=AsyncMock())
|
|
|
|
parent = await c._gate_diff_parent(t)
|
|
assert parent == "master"
|
|
task_service.get.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_branchless_task_returns_none(self) -> None:
|
|
t = _gate_task(branch_name="", parent_task_id=uuid4())
|
|
task_service = AsyncMock()
|
|
c = _make_choreographer(task_service=task_service, git=AsyncMock())
|
|
|
|
assert await c._gate_diff_parent(t) is None
|
|
task_service.get.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fails_open_on_parent_lookup_error(self) -> None:
|
|
t = _gate_task(
|
|
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
|
|
parent_task_id=uuid4(),
|
|
)
|
|
task_service = AsyncMock()
|
|
task_service.get.side_effect = RuntimeError("db connection reset")
|
|
c = _make_choreographer(task_service=task_service, git=AsyncMock())
|
|
|
|
assert await c._gate_diff_parent(t) is None
|
|
|
|
|
|
class TestBuildGateReviewEvidence:
|
|
@pytest.mark.asyncio
|
|
async def test_diff_called_with_resolved_cross_team_parent(self) -> None:
|
|
parent_id = uuid4()
|
|
t = _gate_task(
|
|
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
|
|
parent_task_id=parent_id,
|
|
)
|
|
task_service = AsyncMock()
|
|
task_service.get.return_value = MagicMock(
|
|
branch_name="feature/main_pm/f7d0a61a--e56e6543"
|
|
)
|
|
git = AsyncMock()
|
|
git.diff.return_value = "diff body"
|
|
c = _make_choreographer(task_service=task_service, git=git)
|
|
|
|
evidence = await c._build_gate_review_evidence(t)
|
|
|
|
git.diff.assert_awaited_once_with(
|
|
branch_name=t.branch_name,
|
|
preferred_parent="feature/main_pm/f7d0a61a--e56e6543",
|
|
)
|
|
assert evidence["pr_diff"] == "diff body"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diff_skipped_for_branchless_task(self) -> None:
|
|
t = _gate_task(branch_name="", parent_task_id=None)
|
|
git = AsyncMock()
|
|
c = _make_choreographer(task_service=AsyncMock(), git=git)
|
|
|
|
evidence = await c._build_gate_review_evidence(t)
|
|
|
|
git.diff.assert_not_awaited()
|
|
assert evidence["pr_diff"] == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diff_falls_back_when_parent_lookup_fails(self) -> None:
|
|
t = _gate_task(
|
|
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
|
|
parent_task_id=uuid4(),
|
|
)
|
|
task_service = AsyncMock()
|
|
task_service.get.side_effect = RuntimeError("db connection reset")
|
|
git = AsyncMock()
|
|
git.diff.return_value = "diff body"
|
|
c = _make_choreographer(task_service=task_service, git=git)
|
|
|
|
evidence = await c._build_gate_review_evidence(t)
|
|
|
|
git.diff.assert_awaited_once_with(
|
|
branch_name=t.branch_name, preferred_parent=None
|
|
)
|
|
assert evidence["pr_diff"] == "diff body"
|
|
|
|
|
|
class TestPrPassBlockedThreadsParent:
|
|
"""``_pr_pass_blocked`` resolves the parent ONCE and hands it to the
|
|
conventions guard, so a reviewer's block-level finding is never raised
|
|
against inherited base-branch content on a cross-team assembled PR."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conventions_guard_receives_resolved_parent(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(settings, "conventions_enabled", True)
|
|
parent_id = uuid4()
|
|
t = _gate_task(
|
|
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
|
|
parent_task_id=parent_id,
|
|
)
|
|
task_service = AsyncMock()
|
|
task_service.get.return_value = MagicMock(
|
|
branch_name="feature/main_pm/f7d0a61a--e56e6543"
|
|
)
|
|
c = _make_choreographer(task_service=task_service, git=AsyncMock())
|
|
cc: Any = c
|
|
cc._toolchain_broken_guard = AsyncMock(return_value=None)
|
|
cc._conventions_guard = AsyncMock(return_value=None)
|
|
reviewer_id = uuid4()
|
|
|
|
rejection, _ci_note = await c._pr_pass_blocked(
|
|
reviewer_id, uuid4(), t, "pr_reviewer", {}
|
|
)
|
|
|
|
assert rejection is None
|
|
cc._conventions_guard.assert_awaited_once_with(
|
|
reviewer_id,
|
|
t,
|
|
{},
|
|
preferred_parent="feature/main_pm/f7d0a61a--e56e6543",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parent_lookup_skipped_when_conventions_off(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(settings, "conventions_enabled", False)
|
|
t = _gate_task(
|
|
branch_name="feature/frontend/f7d0a61a--e56e6543--e2b50b06",
|
|
parent_task_id=uuid4(),
|
|
)
|
|
task_service = AsyncMock()
|
|
c = _make_choreographer(task_service=task_service, git=AsyncMock())
|
|
cc: Any = c
|
|
cc._toolchain_broken_guard = AsyncMock(return_value=None)
|
|
cc._conventions_guard = AsyncMock(return_value=None)
|
|
|
|
rejection, _ci_note = await c._pr_pass_blocked(
|
|
uuid4(), uuid4(), t, "pr_reviewer", {}
|
|
)
|
|
|
|
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
|