diff --git a/src/forge_loop/runner/rescue.py b/src/forge_loop/runner/rescue.py index bb7a0f9..6bdc8b5 100644 --- a/src/forge_loop/runner/rescue.py +++ b/src/forge_loop/runner/rescue.py @@ -64,12 +64,12 @@ def rescue_uncommitted_work(outcome: WorkerOutcome, cfg: Config) -> str | None: url = _open_rescue_pr(branch, outcome, cfg, has_tests=has_tests) if url is None: return None - if has_tests and not _issue_is_risk_gated(outcome.issue, cfg): + if has_tests and not issue_is_risk_gated(outcome.issue, cfg): _enable_best_effort_automerge(url, cfg) return url -def _issue_is_risk_gated(issue: int, cfg: Config) -> bool: +def issue_is_risk_gated(issue: int, cfg: Config) -> bool: """#453 — a rescue from a risk-gated issue must stop at PR-open like any worker PR; `has_tests` is not consent (two live incidents: rescue PRs from risk:high issues auto-merged into a live-prod repo @@ -299,3 +299,7 @@ def _enable_best_effort_automerge(url: str, cfg: Config) -> None: with contextlib.suppress(Exception): _gh.enable_pr_auto_merge(url, repo=cfg.github_repo) + + +# Back-compat alias (promoted to public for the adoption gate, #108 incident). +_issue_is_risk_gated = issue_is_risk_gated diff --git a/src/forge_loop/runner/tick.py b/src/forge_loop/runner/tick.py index 2267f0d..5c51d08 100644 --- a/src/forge_loop/runner/tick.py +++ b/src/forge_loop/runner/tick.py @@ -261,6 +261,15 @@ def _enable_automerge_for_reviewed_outcomes( ) +def _issue_is_risk_gated_for_adoption(issue: int, cfg: Config) -> bool: + """Label-fresh risk-gate check for adopted PRs (they predate the + tick's dispatch metadata). Delegates to the rescue gate's logic — + one source of truth, fail-closed semantics included.""" + from forge_loop.runner.rescue import issue_is_risk_gated + + return issue_is_risk_gated(issue, cfg) + + def _enable_automerge_for_adopted_prs( cfg: Config, adoptions: list[tuple[WorkerOutcome, dict[str, Any]]], @@ -357,6 +366,19 @@ def _enable_automerge_for_adopted_prs( unresolved_human_threads=len(human_threads), ) continue + # Risk gate (live incident: getadaptiq #108 auto-merged MID-HUMAN- + # REVIEW). The sibling dispatch-path gate uses this tick's + # workers_meta, which adopted PRs predate — check the ISSUE's + # labels fresh, fail closed (same contract as the rescue gate). + if _issue_is_risk_gated_for_adoption(outcome.issue, cfg): + append_event( + cfg.events_file, + "orphan_pr_skipped", + issue=outcome.issue, + pr=outcome.pr_url, + reason="risk_gated", + ) + continue result = _gh.ensure_pr_merged(outcome.pr_url, repo=cfg.github_repo) if result.merged: outcome.status = "merged" diff --git a/tests/test_orphan_pr_adoption.py b/tests/test_orphan_pr_adoption.py index d857985..f61652e 100644 --- a/tests/test_orphan_pr_adoption.py +++ b/tests/test_orphan_pr_adoption.py @@ -334,6 +334,7 @@ def _patch_gh(monkeypatch, *, threads: list[Any] | None = None) -> list[str]: """Stub gh so adoption never touches the network; return the merge log.""" merged: list[str] = [] monkeypatch.setattr(_ghmod, "unresolved_review_threads", lambda *_a, **_k: threads or []) + monkeypatch.setattr(_ghmod, "fetch_issue", lambda issue, repo=None: {"labels": []}) monkeypatch.setattr( _ghmod, "ensure_pr_merged", @@ -878,3 +879,42 @@ def test_pre_dispatch_repairs_merges_approved_pr_no_repair_worker_across_n_ticks e["kind"] == "orphan_pr_skipped" and e.get("reason") == "already_adopted" for e in _events(cfg) ) + + + +def test_adopted_automerge_skips_risk_gated_issue(tmp_path: Path, monkeypatch) -> None: + """Live incident (getadaptiq #108, 2026-06-11): an adopted PR from a + risk:high issue auto-merged MID-HUMAN-REVIEW — the adoption path's + docstring says it mirrors `_enable_automerge_for_reviewed_outcomes`, + but the mirror dropped `risk_gated_issues`. Adopted PRs predate the + tick's dispatch metadata, so the gate must check the ISSUE's labels + fresh.""" + cfg = _cfg(tmp_path) + merged = _patch_gh(monkeypatch) + monkeypatch.setattr( + _ghmod, "fetch_issue", + lambda issue, repo=None: {"labels": [{"name": "risk:high"}]}, + ) + o = _outcome(89) + pr = {"number": 89, "url": o.pr_url, "mergeStateStatus": "CLEAN"} + + _enable_automerge_for_adopted_prs(cfg, [(o, pr)], refused_issues=set(), emit=None) + + assert merged == [] + assert o.status == "open" + reasons = {(e["issue"], e["reason"]) for e in _events(cfg) if e["kind"] == "orphan_pr_skipped"} + assert (89, "risk_gated") in reasons + + +def test_adopted_automerge_label_fetch_failure_fails_closed(tmp_path: Path, monkeypatch) -> None: + cfg = _cfg(tmp_path) + merged = _patch_gh(monkeypatch) + monkeypatch.setattr(_ghmod, "fetch_issue", lambda issue, repo=None: None) + o = _outcome(90) + pr = {"number": 90, "url": o.pr_url, "mergeStateStatus": "CLEAN"} + + _enable_automerge_for_adopted_prs(cfg, [(o, pr)], refused_issues=set(), emit=None) + + assert merged == [] + reasons = {(e["issue"], e["reason"]) for e in _events(cfg) if e["kind"] == "orphan_pr_skipped"} + assert (90, "risk_gated") in reasons