Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
82b7cb1
fix(governance): select enabled auto-merge method
seonghobae Jul 13, 2026
ac2bf77
test(governance): cover auto-merge settings race
seonghobae Jul 13, 2026
b7843e0
fix(review): retry exhausted pool in central repository
seonghobae Jul 13, 2026
edcdf19
fix(review): make fast approval reusable
seonghobae Jul 13, 2026
d03f12b
fix(governance): tolerate protected stale review
seonghobae Jul 14, 2026
027a03d
fix(governance): close scheduler security races
seonghobae Jul 20, 2026
5468c49
test(governance): cover scheduler refresh guards
seonghobae Jul 20, 2026
8b122af
fix(governance): deduplicate dynamic review runs
seonghobae Jul 20, 2026
c8f5111
fix(governance): deduplicate pending review runs
seonghobae Jul 20, 2026
776cdf7
Merge branch 'main' into fix/opencode-merge-method-concurrency-20260714
opencode-agent[bot] Jul 23, 2026
43b8428
chore(ci): bootstrap PR 558 status-filter repair
seonghobae Aug 3, 2026
7743915
chore(ci): trigger reviewed PR 558 status repair
seonghobae Aug 3, 2026
c5c2dfa
chore(ci): remove inactive PR 558 bootstrap workflow
seonghobae Aug 3, 2026
ab21b5e
fix(ci): retry PR 558 active-status repair
seonghobae Aug 3, 2026
a496626
chore(ci): trigger bounded PR 558 repair
seonghobae Aug 3, 2026
64a2c1d
fix(ci): allow bounded PR 558 repair trigger
seonghobae Aug 3, 2026
2640fc7
fix(ci): trigger PR 558 active-status repair safely
seonghobae Aug 3, 2026
a821b2a
fix(ci): retrigger bounded PR 558 repair
seonghobae Aug 3, 2026
40a07b0
chore(ci): trigger PR 558 active-status repair
seonghobae Aug 3, 2026
06f5f8e
chore(ci): retrigger PR 558 active-status repair
seonghobae Aug 3, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main, develop, master]
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, auto_merge_enabled, closed]
types: [opened, synchronize, reopened, ready_for_review, closed]
pull_request_review:
types: [submitted, dismissed]
workflow_run:
Expand Down
40 changes: 38 additions & 2 deletions scripts/ci/pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1452,16 +1452,23 @@ def run_head_guarded_merge(
auto: bool,
) -> None:
"""Run a head-guarded merge using an allowed repository merge method."""
merge_flag = repository_auto_merge_flag(repo) if auto else "--squash"
args = ["gh", "pr", "merge", number, "--repo", repo]
if auto:
args.append("--auto")
args.extend(["--squash", "--match-head-commit", head])
print(
f"PR #{number}: enabling auto-merge with repository-enabled method "
f"{merge_flag.removeprefix('--')} at guarded head {head}."
)
args.extend([merge_flag, "--match-head-commit", head])
try:
run(args)
return
except RuntimeError as exc:
detail = str(exc).lower()
if not any(marker in detail for marker in SQUASH_MERGE_DISABLED_MARKERS):
if merge_flag != "--squash" or not any(
marker in detail for marker in SQUASH_MERGE_DISABLED_MARKERS
):
raise
reason = str(exc).splitlines()[-1][:400]

Expand All @@ -1477,6 +1484,35 @@ def run_head_guarded_merge(
run(merge_args)


def repository_auto_merge_flag(repo: str) -> str:
"""Return the first merge method enabled in repository settings."""
raw = run_github_read(["gh", "api", f"repos/{repo}"])
try:
settings = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"Could not select an auto-merge method for {repo}: "
"repository settings were not valid JSON."
) from exc
if not isinstance(settings, dict):
raise RuntimeError(
f"Could not select an auto-merge method for {repo}: "
"repository settings were not an object."
)

for setting, flag in (
("allow_squash_merge", "--squash"),
("allow_merge_commit", "--merge"),
("allow_rebase_merge", "--rebase"),
):
if settings.get(setting) is True:
return flag
raise RuntimeError(
f"Could not enable auto-merge for {repo}: repository settings expose "
"no enabled merge method."
)


def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None:
"""Enable auto-merge for a PR at its current head using an allowed method."""
number = str(pr["number"])
Expand Down
5 changes: 5 additions & 0 deletions tests/test_opencode_agent_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,11 @@ def test_merge_scheduler_uses_escalating_mutation_credentials():
assert '--branch-update-limit "$ORG_SWEEP_BRANCH_UPDATE_LIMIT"' in workflow
assert "pull_request_review:" in workflow
assert "types: [submitted, dismissed]" in workflow
assert (
"types: [opened, synchronize, reopened, ready_for_review, closed]"
in workflow
)
assert "auto_merge_enabled" not in workflow
assert (
"github.event_name == 'pull_request_review' && "
"format('pr-{0}', github.event.pull_request.number)" in workflow
Expand Down
93 changes: 79 additions & 14 deletions tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,10 @@ def test_actions_call_gh_with_expected_arguments(monkeypatch):

def fake_run(args, stdin=None):
calls.append(args)
if args == ["gh", "api", "repos/owner/repo"]:
return json.dumps(
{"allow_squash_merge": False, "allow_merge_commit": True}
)
if args[:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]:
return '{"workflow_runs": []}'
return ""
Expand All @@ -1580,10 +1584,12 @@ def fake_run(args, stdin=None):
sched.update_branch("owner/repo", pr, dry_run=False)
sched.dispatch_strix_evidence("owner/repo", "Strix Security Scan", pr, dry_run=False)
sched.dispatch_opencode_review("owner/repo", "OpenCode Review", pr, dry_run=False)
assert calls[0][:4] == ["gh", "pr", "merge", "1"]
assert "--squash" in calls[0]
assert calls[0][-2:] == ["--match-head-commit", head_sha]
assert calls[1] == [
assert calls[0] == ["gh", "api", "repos/owner/repo"]
assert calls[1][:4] == ["gh", "pr", "merge", "1"]
assert "--auto" in calls[1]
assert "--merge" in calls[1]
assert calls[1][-2:] == ["--match-head-commit", head_sha]
assert calls[2] == [
"gh",
"pr",
"merge",
Expand All @@ -1594,13 +1600,13 @@ def fake_run(args, stdin=None):
"--match-head-commit",
head_sha,
]
assert calls[2] == ["gh", "pr", "merge", "1", "--repo", "owner/repo", "--disable-auto"]
assert calls[3][:4] == ["gh", "api", "-X", "PUT"]
assert calls[3][-1] == f"expected_head_sha={head_sha}"
assert calls[4][:5] == ["gh", "workflow", "run", "Strix Security Scan", "--repo"]
assert calls[5][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]
assert calls[3] == ["gh", "pr", "merge", "1", "--repo", "owner/repo", "--disable-auto"]
assert calls[4][:4] == ["gh", "api", "-X", "PUT"]
assert calls[4][-1] == f"expected_head_sha={head_sha}"
assert calls[5][:5] == ["gh", "workflow", "run", "Strix Security Scan", "--repo"]
assert calls[6][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]
assert calls[7][:5] == ["gh", "workflow", "run", "OpenCode Review", "--repo"]
assert calls[7][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]
assert calls[8][:5] == ["gh", "workflow", "run", "OpenCode Review", "--repo"]
calls.clear()

required_workflow_pr = make_pr(
Expand Down Expand Up @@ -1686,9 +1692,8 @@ def test_last_push_approval_restamp_refuses_unsafe_heads(monkeypatch):
sched.restamp_pr_head_for_last_push_approval("owner/repo", stale, dry_run=False)


@pytest.mark.parametrize("auto", [False, True])
def test_head_guarded_merge_retries_merge_commit_when_squash_is_disabled(
monkeypatch, capsys, auto
monkeypatch, capsys
):
calls = []
head_sha = "a" * 40
Expand All @@ -1707,18 +1712,78 @@ def fake_run(args, stdin=None):
"owner/repo",
"7",
head_sha,
auto=auto,
auto=False,
)

assert len(calls) == 2
assert "--squash" in calls[0]
assert "--merge" in calls[1]
assert ("--auto" in calls[1]) is auto
assert "--auto" not in calls[1]
assert calls[0][-2:] == ["--match-head-commit", head_sha]
assert calls[1][-2:] == ["--match-head-commit", head_sha]
assert "Squash merges are not allowed" in capsys.readouterr().out


@pytest.mark.parametrize(
("settings", "expected_flag"),
[
({"allow_squash_merge": True, "allow_merge_commit": True}, "--squash"),
({"allow_squash_merge": False, "allow_merge_commit": True}, "--merge"),
(
{
"allow_squash_merge": False,
"allow_merge_commit": False,
"allow_rebase_merge": True,
},
"--rebase",
),
],
)
def test_head_guarded_auto_merge_uses_repository_enabled_method(
monkeypatch, capsys, settings, expected_flag
):
calls = []
head_sha = "a" * 40

monkeypatch.setattr(sched, "run_github_read", lambda _args: json.dumps(settings))
monkeypatch.setattr(sched, "run", lambda args, stdin=None: calls.append(args) or "")

sched.run_head_guarded_merge("owner/repo", "7", head_sha, auto=True)

assert calls == [
[
"gh",
"pr",
"merge",
"7",
"--repo",
"owner/repo",
"--auto",
expected_flag,
"--match-head-commit",
head_sha,
]
]
assert expected_flag.removeprefix("--") in capsys.readouterr().out


@pytest.mark.parametrize(
("payload", "message"),
[
("not-json", "not valid JSON"),
("[]", "not an object"),
("{}", "no enabled merge method"),
],
)
def test_repository_auto_merge_flag_explains_invalid_settings(
monkeypatch, payload, message
):
monkeypatch.setattr(sched, "run_github_read", lambda _args: payload)

with pytest.raises(RuntimeError, match=message):
sched.repository_auto_merge_flag("owner/repo")


def test_head_guarded_merge_does_not_mask_unrelated_failure(monkeypatch):
calls = []

Expand Down
Loading