From fe9fe13d2512f5a841abd1cccedaa7a327b6b660 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 26 Jun 2026 00:54:39 +0900 Subject: [PATCH 1/3] ci: disable unsafe auto merge states --- scripts/ci/pr_review_merge_scheduler.py | 142 +++++++++++++++++++++++- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 43c3113b5..30aaf3f0f 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -197,9 +197,10 @@ def decision_guidance(decision: Decision) -> dict[str, Any] | None: } if decision.action == "disable_auto_merge": return { - "type": "fresh_head_review_required", - "summary": "Auto-merge was disabled because the PR needs fresh same-head review evidence before it can be merged.", + "type": "unsafe_auto_merge_disabled", + "summary": "Auto-merge was disabled because the current PR state is not safe to merge automatically.", "next_required_evidence": [ + "the unsafe condition described in reason is repaired", "OpenCode approval submitted after the current head commit was created", "required GitHub Checks success on the current head", "same-head Strix evidence", @@ -496,6 +497,18 @@ def disable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: run(["gh", "pr", "merge", number, "--repo", repo, "--disable-auto"]) +def disable_auto_merge_decision( + repo: str, + pr: dict[str, Any], + *, + dry_run: bool, + reason: str, +) -> Decision: + """Disable auto-merge and return a WAIT decision with the concrete unsafe reason.""" + disable_auto_merge(repo, pr, dry_run=dry_run) + return Decision(pr["number"], "disable_auto_merge", f"auto-merge disabled; {reason}") + + def update_branch(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: """Ask GitHub to update a PR branch, guarded by the observed head SHA.""" number = str(pr["number"]) @@ -609,13 +622,34 @@ def inspect_pr( merge_state = (pr.get("mergeStateStatus") or "").upper() if merge_state in {"DIRTY", "CONFLICTING"}: + if pr.get("autoMergeRequest"): + return disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=f"{merge_conflict_guidance(pr, merge_state)}; repair the conflict before re-enabling auto-merge", + ) return Decision(number, "block", merge_conflict_guidance(pr, merge_state)) unresolved = unresolved_thread_count(pr) if unresolved: + if pr.get("autoMergeRequest"): + return disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=f"{unresolved} unresolved review thread(s); resolve the active thread(s) before re-enabling auto-merge", + ) return Decision(number, "block", f"{unresolved} unresolved review thread(s)") if has_current_head_changes_requested(pr): + if pr.get("autoMergeRequest"): + return disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason="current-head OpenCode review requested changes; address the review before re-enabling auto-merge", + ) return Decision(number, "block", "current-head OpenCode review requested changes") current_head_approved = has_current_head_approval(pr) @@ -630,16 +664,27 @@ def inspect_pr( if current_head_approved: failed_checks = failed_status_checks(pr) if failed_checks: + if pr.get("autoMergeRequest"): + return disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=f"failed check(s): {', '.join(failed_checks[:5])}; fix or rerun checks before re-enabling auto-merge", + ) return Decision(number, "block", f"failed check(s): {', '.join(failed_checks[:5])}") if merge_state == "BEHIND" and current_head_approved: if not update_branches: return Decision(number, "wait", "current-head OpenCode review approved; branch update disabled") + had_auto_merge = bool(pr.get("autoMergeRequest")) + if had_auto_merge: + disable_auto_merge(repo, pr, dry_run=dry_run) update_branch(repo, pr, dry_run=dry_run) + prefix = "auto-merge disabled before branch update; " if had_auto_merge else "" return Decision( number, "update_branch", - "current-head OpenCode review approved; branch update requested with workflow GH_TOKEN (github-actions[bot] in GitHub Actions)", + f"{prefix}current-head OpenCode review approved; branch update requested with workflow GH_TOKEN (github-actions[bot] in GitHub Actions)", ) if current_head_approved: @@ -687,6 +732,14 @@ def inspect_pr( "current head has completed Strix evidence; same-head OpenCode dispatched", ) + if pr.get("autoMergeRequest"): + return disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason="current head has no OpenCode approval; wait for fresh same-head approval before re-enabling auto-merge", + ) + return Decision(number, "block", "current head has no OpenCode approval") @@ -769,12 +822,14 @@ def write_actions_summary( def parse_conflict_reason(reason: str) -> tuple[str, str, str] | None: """Extract merge state, base branch, and head branch from conflict guidance.""" prefix = "merge conflict: " - if not reason.startswith(prefix): + conflict_start = reason.find(prefix) + if conflict_start < 0: return None - state = reason[len(prefix) :].split(";", 1)[0].strip() or "UNKNOWN" + conflict_reason = reason[conflict_start:] + state = conflict_reason[len(prefix) :].split(";", 1)[0].strip() or "UNKNOWN" base_ref = "base" head_ref = "head" - for segment in reason.split(";"): + for segment in conflict_reason.split(";"): segment = segment.strip() if not segment.startswith("base="): continue @@ -938,6 +993,7 @@ def self_test() -> None: base_branch="main", ) assert decision.action == "auto_merge" + sample["autoMergeRequest"] = {"enabledAt": "2026-01-01T00:02:00Z"} sample["statusCheckRollup"]["contexts"]["nodes"] = [ {"__typename": "CheckRun", "name": "strix", "status": "COMPLETED", "conclusion": "FAILURE"} ] @@ -952,6 +1008,20 @@ def self_test() -> None: security_workflow="Strix Security Scan", base_branch="main", ) + assert decision.action == "disable_auto_merge" + assert "failed check(s): strix" in decision.reason + sample["autoMergeRequest"] = None + decision = inspect_pr( + "owner/repo", + sample, + dry_run=True, + trigger_reviews=True, + enable_auto_merge_flag=True, + update_branches=True, + workflow="OpenCode Review", + security_workflow="Strix Security Scan", + base_branch="main", + ) assert decision.action == "block" assert "strix" in decision.reason sample["statusCheckRollup"]["contexts"]["nodes"] = [] @@ -974,6 +1044,29 @@ def self_test() -> None: } ) assert not has_current_head_changes_requested(sample) + sample["reviews"]["nodes"] = [ + { + "state": "CHANGES_REQUESTED", + "author": {"login": "opencode-agent"}, + "commit": {"oid": "abc"}, + } + ] + sample["autoMergeRequest"] = {"enabledAt": "2026-01-01T00:02:00Z"} + assert has_current_head_changes_requested(sample) + decision = inspect_pr( + "owner/repo", + sample, + dry_run=True, + trigger_reviews=True, + enable_auto_merge_flag=True, + update_branches=True, + workflow="OpenCode Review", + security_workflow="Strix Security Scan", + base_branch="main", + ) + assert decision.action == "disable_auto_merge" + assert "current-head OpenCode review requested changes" in decision.reason + sample["autoMergeRequest"] = None sample["statusCheckRollup"]["contexts"]["nodes"].append( {"__typename": "CheckRun", "name": "opencode-review", "status": "IN_PROGRESS"} ) @@ -1033,6 +1126,21 @@ def self_test() -> None: base_branch="main", ) assert decision.action == "update_branch" + sample["autoMergeRequest"] = {"enabledAt": "2026-01-01T00:02:00Z"} + decision = inspect_pr( + "owner/repo", + sample, + dry_run=True, + trigger_reviews=True, + enable_auto_merge_flag=True, + update_branches=True, + workflow="OpenCode Review", + security_workflow="Strix Security Scan", + base_branch="main", + ) + assert decision.action == "update_branch" + assert "auto-merge disabled before branch update" in decision.reason + sample["autoMergeRequest"] = None sample["statusCheckRollup"]["contexts"]["nodes"] = [ {"__typename": "CheckRun", "name": "strix", "status": "COMPLETED", "conclusion": "FAILURE"} ] @@ -1051,6 +1159,24 @@ def self_test() -> None: assert decision.reason == "failed check(s): strix" sample["statusCheckRollup"]["contexts"]["nodes"] = [] sample["mergeStateStatus"] = "DIRTY" + sample["autoMergeRequest"] = {"enabledAt": "2026-01-01T00:02:00Z"} + decision = inspect_pr( + "owner/repo", + sample, + dry_run=True, + trigger_reviews=True, + enable_auto_merge_flag=True, + update_branches=True, + workflow="OpenCode Review", + security_workflow="Strix Security Scan", + base_branch="main", + ) + assert decision.action == "disable_auto_merge" + assert "merge conflict: DIRTY" in decision.reason + conflict_guidance = decision_guidance(decision) + assert conflict_guidance + assert conflict_guidance["type"] == "merge_conflict_repair" + sample["autoMergeRequest"] = None decision = inspect_pr( "owner/repo", sample, @@ -1078,6 +1204,7 @@ def self_test() -> None: assert contract_decision(Decision(1, "update_branch", "ok")) == "UPDATE_BRANCH" assert contract_decision(Decision(1, "wait", "ok")) == "WAIT" assert contract_decision(Decision(1, "action_error", "ok")) == "WAIT" + assert contract_decision(Decision(1, "disable_auto_merge", "ok")) == "WAIT" assert contract_decision(Decision(1, "auto_merge", "ok")) == "NO_ACTION" assert contract_decision(Decision(1, "skip", "ok")) == "NO_ACTION" assert ( @@ -1089,6 +1216,9 @@ def self_test() -> None: assert update_guidance assert update_guidance["actor"] == "github-actions[bot]" assert update_guidance["head_guard"] == "expected_head_sha" + disable_guidance = decision_guidance(Decision(1, "disable_auto_merge", "ok")) + assert disable_guidance + assert disable_guidance["type"] == "unsafe_auto_merge_disabled" assert decision_guidance(Decision(1, "wait", "ok")) is None payload = decision_payload( [Decision(1, "update_branch", "ok")], From 6de3c01e5abbc45f5052f9324a4b5491bb29f929 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 26 Jun 2026 01:07:53 +0900 Subject: [PATCH 2/3] test: cover unsafe auto merge guidance --- tests/test_pr_review_merge_scheduler.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index edfa6c30a..d4274069e 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -447,7 +447,7 @@ def test_print_summary_writes_github_step_summary(monkeypatch, tmp_path, capsys) assert payload["decisions"][1]["guidance"]["token"] == "workflow GITHUB_TOKEN" assert payload["decisions"][1]["guidance"]["required_permission"] == "pull-requests: write" assert payload["decisions"][1]["guidance"]["head_guard"] == "expected_head_sha" - assert payload["decisions"][2]["guidance"]["type"] == "fresh_head_review_required" + assert payload["decisions"][2]["guidance"]["type"] == "unsafe_auto_merge_disabled" summary = summary_path.read_text(encoding="utf-8") assert "## PR review merge scheduler" in summary assert "| #7 | block | merge conflict: DIRTY; base=main, head=feature\\|x; run" in summary @@ -519,6 +519,14 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch): assert conflicting.action == "block" assert "merge conflict: CONFLICTING" in conflicting.reason assert inspect(make_pr(reviewThreads={"nodes": [{"isResolved": False}]})).reason == "1 unresolved review thread(s)" + unresolved_auto = inspect( + make_pr( + reviewThreads={"nodes": [{"isResolved": False}]}, + autoMergeRequest={"enabledAt": "now"}, + ) + ) + assert unresolved_auto.action == "disable_auto_merge" + assert "unresolved review thread" in unresolved_auto.reason assert inspect(make_pr(reviews={"nodes": [opencode_review("CHANGES_REQUESTED", "head")]})).reason == ( "current-head OpenCode review requested changes" ) @@ -619,6 +627,9 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): assert stale_wait.action == "wait" assert "review dispatch disabled" in stale_wait.reason assert inspect(make_pr(), trigger_reviews=False).reason == "current head has no OpenCode approval" + missing_approval_auto = inspect(make_pr(autoMergeRequest={"enabledAt": "now"}), trigger_reviews=False) + assert missing_approval_auto.action == "disable_auto_merge" + assert "no OpenCode approval" in missing_approval_auto.reason def test_print_summary_self_test_parse_args_and_main(monkeypatch, capsys): From e6e59956843b9984e257e8d8210dc10a7f340e14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 26 Jun 2026 01:27:29 +0900 Subject: [PATCH 3/3] ci: bump scheduler summary schema --- scripts/ci/pr_review_merge_scheduler.py | 4 ++-- tests/test_pr_review_merge_scheduler.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 30aaf3f0f..4200dc03b 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -119,7 +119,7 @@ def decision_payload( ) -> dict[str, Any]: """Return the machine-readable scheduler decision contract.""" return { - "schema_version": "pr-review-merge-scheduler/v1", + "schema_version": "pr-review-merge-scheduler/v2", "base_branch": base_branch, "dry_run": dry_run, "inspected": len(decisions), @@ -1227,7 +1227,7 @@ def self_test() -> None: base_branch="main", project_flow="github-flow", ) - assert payload["schema_version"] == "pr-review-merge-scheduler/v1" + assert payload["schema_version"] == "pr-review-merge-scheduler/v2" assert payload["decisions"][0]["contract_decision"] == "UPDATE_BRANCH" assert payload["decisions"][0]["guidance"]["actor"] == "github-actions[bot]" print("self-test passed") diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index d4274069e..55cc2a5ea 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -426,7 +426,7 @@ def test_print_summary_writes_github_step_summary(monkeypatch, tmp_path, capsys) output = capsys.readouterr().out assert "PR #7: block: merge conflict: DIRTY" in output payload = json.loads(output.splitlines()[-1]) - assert payload["schema_version"] == "pr-review-merge-scheduler/v1" + assert payload["schema_version"] == "pr-review-merge-scheduler/v2" assert payload["base_branch"] == "main" assert payload["counts"] == {"block": 1, "disable_auto_merge": 1, "update_branch": 1} assert payload["dry_run"] is True @@ -642,7 +642,7 @@ def test_print_summary_self_test_parse_args_and_main(monkeypatch, capsys): output = capsys.readouterr().out assert "PR #1: wait: ready" in output payload = json.loads(output.strip().splitlines()[-1]) - assert payload["schema_version"] == "pr-review-merge-scheduler/v1" + assert payload["schema_version"] == "pr-review-merge-scheduler/v2" assert payload["counts"] == {"wait": 2} assert [decision["contract_decision"] for decision in payload["decisions"]] == ["WAIT", "WAIT"]