From 9cffa76a40f23d69e22de647fd4231bf48d06087 Mon Sep 17 00:00:00 2001 From: kmajdoub Date: Thu, 11 Jun 2026 15:41:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(adoption):=20adopted-PR=20automerge=20respe?= =?UTF-8?q?cts=20the=20risk=20gate=20=E2=80=94=20fail=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live incident (getadaptiq #108, 2026-06-11): an adopted PR from a risk:high issue auto-merged MID-HUMAN-REVIEW — while the operator was running its acceptance evals, which then FAILED 2/6 deterministically. Main carried a broken routing change for ~40 minutes until the review repair landed. _enable_automerge_for_adopted_prs documents itself as mirroring _enable_automerge_for_reviewed_outcomes, but the mirror dropped risk_gated_issues. Adopted PRs predate the tick's dispatch metadata, so the gate now checks the ISSUE's labels fresh via the rescue gate's helper (promoted public; one source of truth, fail-closed on fetch failure), emitting orphan_pr_skipped reason=risk_gated. This closes the LAST of the three merge conveyors: dispatch-path gate (pre-existing), rescue-path gate (#454), adoption-path gate (this). TDD: gated-adoption never merges, fetch-failure fails closed; all existing adoption/rescue tests preserved. 44/44. Co-Authored-By: Claude Fable 5 --- src/forge_loop/runner/rescue.py | 8 +++++-- src/forge_loop/runner/tick.py | 22 ++++++++++++++++++ tests/test_orphan_pr_adoption.py | 40 ++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) 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