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
22 changes: 22 additions & 0 deletions src/forge_loop/gh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions src/forge_loop/runner/tick.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions tests/test_dispatch_axis_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading