From 8b58c108bd7bf892a1425c29dde9f50943731b02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 29 Jun 2026 22:23:27 +0900 Subject: [PATCH] Handle update branch for queued auto-merge PRs --- docs/org-required-workflow-rollout.md | 2 +- scripts/ci/pr_review_merge_scheduler.py | 20 ++++++++++---- tests/test_pr_review_merge_scheduler.py | 35 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/org-required-workflow-rollout.md b/docs/org-required-workflow-rollout.md index 4c067d20aa..80a0a3764b 100644 --- a/docs/org-required-workflow-rollout.md +++ b/docs/org-required-workflow-rollout.md @@ -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. diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 1ea0213a67..3e54a1ddf2 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -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 " diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 4fd1f85bb2..e1e1984961 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -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):