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
2 changes: 1 addition & 1 deletion docs/org-required-workflow-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The central `.github/workflows/pr-review-merge-scheduler.yml` is now part of the
- PR-event scope: when GitHub invokes the workflow for a PR, the scheduler passes `--pr-number` and inspects only that PR instead of scanning or mutating the whole repository queue
- Token posture: the workflow passes `GH_TOKEN: ${{ github.token }}` so stale-thread resolution, branch update, auto-merge, and direct merge mutations are attributed to the target repository's `github-actions[bot]`
- Flow posture: default branches named `main` or `master` are treated as GitHub Flow; default branches named `develop` are treated as Git Flow unless a repository explicitly sets `PROJECT_FLOW`
- Automation boundary: `update-branch` handles `BEHIND` PRs only after current-head OpenCode approval; `DIRTY` or `CONFLICTING` PRs still require author or maintainer conflict resolution guidance
- Automation boundary: `update-branch` handles `BEHIND` PRs after current-head OpenCode approval, and also handles PRs where auto-merge is already enabled but compare evidence shows the base branch is ahead; `DIRTY` or `CONFLICTING` PRs still require author or maintainer conflict resolution guidance
- Retry posture: before retrying OpenCode, the scheduler force-cancels older active OpenCode runs for the same PR number and a previous head SHA. It does not automatically cancel Strix runs because security evidence should not be silently discarded by force-push churn.

Do not centralize the scheduler by running a `.github` scheduled job against other repositories with the `.github` repository token. That would either fail permission checks or use the wrong mutation actor. The central path is a required workflow executed in each target repository context.
Expand Down
20 changes: 15 additions & 5 deletions scripts/ci/pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1077,21 +1077,31 @@ def decide(action: str, reason: str) -> Decision:
return decide("block", "current-head OpenCode review requested changes")

current_head_approved = has_current_head_approval(pr)
auto_merge_enabled = bool(pr.get("autoMergeRequest"))
behind_by = branch_outdated_by_base(pr, merge_state)
if behind_by and current_head_approved:
if behind_by and (current_head_approved or auto_merge_enabled):
if not update_branches:
return decide("wait", "current-head OpenCode review approved; branch update disabled")
if current_head_approved:
return decide("wait", "current-head OpenCode review approved; branch update disabled")
return decide("wait", "auto-merge already enabled; branch update disabled")
if not can_update_pr_head(repo, pr):
return decide("wait", non_mutable_head_reason(repo, pr))
update_branch(repo, pr, dry_run=dry_run)
suffix = "; existing auto-merge request remains queued" if pr.get("autoMergeRequest") else ""
if merge_state == "BEHIND":
suffix = "; existing auto-merge request remains queued" if auto_merge_enabled else ""
if current_head_approved and merge_state == "BEHIND":
freshness_reason = "current-head OpenCode review approved"
else:
elif current_head_approved:
freshness_reason = (
"current-head OpenCode review approved; "
f"base branch is {behind_by} commit(s) ahead even though GitHub mergeability is {merge_state}"
)
elif merge_state == "BEHIND":
freshness_reason = "auto-merge already enabled"
else:
freshness_reason = (
"auto-merge already enabled; "
f"base branch is {behind_by} commit(s) ahead even though GitHub mergeability is {merge_state}"
)
return decide(
"update_branch",
f"{freshness_reason}; branch update requested with workflow GH_TOKEN "
Expand Down
35 changes: 35 additions & 0 deletions tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,41 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch):
assert "existing auto-merge request remains queued" in blocked_failed_behind_decision.reason
assert called == [("owner/repo", 1, True)]
assert disabled == []
called.clear()
blocked_failed_behind_auto_without_opencode_approval = make_pr(
mergeStateStatus="BLOCKED",
restMergeableState="BLOCKED",
compareBehindBy=2,
autoMergeRequest={"enabledAt": "now"},
statusCheckRollup={
"contexts": {
"nodes": [{"__typename": "CheckRun", "name": "strix", "conclusion": "FAILURE"}],
}
},
)
blocked_without_opencode_decision = inspect(blocked_failed_behind_auto_without_opencode_approval)
assert blocked_without_opencode_decision.action == "update_branch"
assert "auto-merge already enabled" in blocked_without_opencode_decision.reason
assert "base branch is 2 commit(s) ahead" in blocked_without_opencode_decision.reason
assert "existing auto-merge request remains queued" in blocked_without_opencode_decision.reason
assert called == [("owner/repo", 1, True)]
called.clear()
disabled.clear()
assert (
inspect(blocked_failed_behind_auto_without_opencode_approval, update_branches=False).reason
== "auto-merge already enabled; branch update disabled"
)
assert called == []
assert disabled == []
behind_auto_without_opencode_approval = make_pr(
mergeStateStatus="BEHIND",
autoMergeRequest={"enabledAt": "now"},
)
behind_without_opencode_decision = inspect(behind_auto_without_opencode_approval)
assert behind_without_opencode_decision.action == "update_branch"
assert behind_without_opencode_decision.reason.startswith("auto-merge already enabled; branch update requested")
assert "existing auto-merge request remains queued" in behind_without_opencode_decision.reason
assert called == [("owner/repo", 1, True)]


def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch):
Expand Down
Loading