Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/forge_loop/runner/rescue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
22 changes: 22 additions & 0 deletions src/forge_loop/runner/tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]],
Expand Down Expand Up @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions tests/test_orphan_pr_adoption.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Loading