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
146 changes: 138 additions & 8 deletions scripts/ci/pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Comment thread
seonghobae marked this conversation as resolved.
"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",
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"}
]
Expand All @@ -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"] = []
Expand All @@ -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"}
)
Expand Down Expand Up @@ -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"}
]
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -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")],
Expand All @@ -1097,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")
Expand Down
17 changes: 14 additions & 3 deletions tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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):
Expand All @@ -631,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"]

Expand Down