diff --git a/src/forge_loop/gh.py b/src/forge_loop/gh.py index df547bf..5c965d6 100644 --- a/src/forge_loop/gh.py +++ b/src/forge_loop/gh.py @@ -478,6 +478,28 @@ def disable_pr_auto_merge(pr: int | str, repo: str | None = None) -> bool: return r.returncode == 0 +def enable_pr_auto_merge(pr: int | str, repo: str | None = None) -> bool: + """Enable squash auto-merge for a PR. Best-effort: returns False on failure.""" + repo = _require_repo(repo) + r = subprocess.run( + [ + "gh", + "pr", + "merge", + str(pr), + "--repo", + repo, + "--squash", + "--auto", + "--delete-branch", + ], + check=False, + capture_output=True, + text=True, + ) + return r.returncode == 0 + + def post_review_comment( pr: int | str, body: str, diff --git a/src/forge_loop/runner/tick.py b/src/forge_loop/runner/tick.py index 55fa1b6..f852815 100644 --- a/src/forge_loop/runner/tick.py +++ b/src/forge_loop/runner/tick.py @@ -116,6 +116,53 @@ def _blocking_pr_repairs(cfg: Config) -> list[tuple[dict[str, Any], dict[str, An return repairs +def _enable_automerge_for_repaired_prs( + cfg: Config, + outcomes: list[WorkerOutcome], + emit: Any, +) -> None: + """After repair + critic, put fixed PRs back on the merge conveyor.""" + from forge_loop import gh as _gh + from forge_loop.runner.merge_gate import apply_issue_closed_gate + + apply_issue_closed_gate( + outcomes, + gh=_gh, + repo=cfg.github_repo, + events_file=cfg.events_file, + emit=emit, + ) + for outcome in outcomes: + if outcome.status not in {"open", "merged"} or not outcome.pr_url: + continue + threads = _gh.unresolved_review_threads(outcome.pr_url, repo=cfg.github_repo) + if threads: + append_event( + cfg.events_file, + "repair_automerge_skipped", + issue=outcome.issue, + pr=outcome.pr_url, + reason="unresolved_review_threads", + unresolved=len(threads), + ) + continue + if _gh.enable_pr_auto_merge(outcome.pr_url, repo=cfg.github_repo): + outcome.status = "merged" + append_event( + cfg.events_file, + "repair_automerge_enabled", + issue=outcome.issue, + pr=outcome.pr_url, + ) + else: + append_event( + cfg.events_file, + "repair_automerge_failed", + issue=outcome.issue, + pr=outcome.pr_url, + ) + + _TEST_FILE_GLOBS = ( "**/test/**", "**/tests/**", @@ -490,6 +537,7 @@ def _bus_emit(kind: str, payload: dict[str, Any]) -> None: ) if cfg.critic.enabled: _run_critic_for_outcomes(cfg, outcomes, _bus_emit) + _enable_automerge_for_repaired_prs(cfg, outcomes, _bus_emit) append_event( cfg.events_file, "repair_tick_done", diff --git a/tests/test_dispatch_axis_filter.py b/tests/test_dispatch_axis_filter.py index dc6a105..fbea553 100644 --- a/tests/test_dispatch_axis_filter.py +++ b/tests/test_dispatch_axis_filter.py @@ -196,3 +196,43 @@ def test_unresolved_review_thread_pr_is_selected_for_repair( assert pr["repairReasons"] == ["unresolved_review_threads"] assert ctx == "thread context" assert "repair_pr_selected" in cfg.events_file.read_text() + + +def test_repaired_pr_gets_automerge_after_threads_are_clear( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + from forge_loop import gh + from forge_loop.config import Config + from forge_loop.runner import merge_gate + from forge_loop.runner import tick as tick_mod + from forge_loop.worker import WorkerOutcome + + cfg = Config(repo=tmp_path, github_repo="acme/widgets") + gate_calls: list[str] = [] + merge_calls: list[str] = [] + monkeypatch.setattr( + merge_gate, + "apply_issue_closed_gate", + lambda outcomes, **_kwargs: gate_calls.extend(o.pr_url or "" for o in outcomes) or [], + ) + monkeypatch.setattr(gh, "unresolved_review_threads", lambda *_a, **_k: []) + monkeypatch.setattr( + gh, + "enable_pr_auto_merge", + lambda pr, **_kwargs: merge_calls.append(str(pr)) or True, + ) + outcome = WorkerOutcome( + issue=99, + title="fix review", + pr_url="https://github.com/acme/widgets/pull/10", + status="open", + duration_s=1.0, + stdout_tail="", + ) + + tick_mod._enable_automerge_for_repaired_prs(cfg, [outcome], lambda *_a, **_k: None) + + assert gate_calls == ["https://github.com/acme/widgets/pull/10"] + assert merge_calls == ["https://github.com/acme/widgets/pull/10"] + assert outcome.status == "merged" + assert "repair_automerge_enabled" in cfg.events_file.read_text()