From e8302e27ac1d1fbec921653343457dbceffbd95d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:04:22 +0900 Subject: [PATCH 1/2] fix(scheduler): reject commented reviews before runner admission A submitted COMMENTED review does not change approval or change-request state, yet the merge scheduler requested a hosted runner for it. Gate review-event admission before runs-on while preserving APPROVED, CHANGES_REQUESTED, dismissed, exact-PR concurrency, and existing permissions. Refs #712. --- .../workflows/pr-review-merge-scheduler.yml | 11 ++ CHANGELOG.md | 4 + ...-merge-scheduler-trigger-audit-20260903.md | 14 +++ ..._merge_scheduler_review_event_admission.py | 100 ++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 tests/test_merge_scheduler_review_event_admission.py diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index d32918cf45..240325941e 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -109,6 +109,17 @@ jobs: ( github.event_name != 'repository_dispatch' || github.event.client_payload.org_sweep != true + ) && + ( + github.event_name != 'pull_request_review' || + github.event.action == 'dismissed' || + ( + github.event.action == 'submitted' && + ( + github.event.review.state == 'approved' || + github.event.review.state == 'changes_requested' + ) + ) ) runs-on: ubuntu-24.04 # Bound scan-pr-queue to a wall-clock ceiling well short of GitHub's diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a1552edf4..2b07576397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - Raised `hourly-review-repair.yml`'s discovery ceiling from 50 to 200 while rotating deterministic 50-PR deep-inspection windows by hourly run number. The scheduler hydrates only the selected window and stops immediately after its single dispatch, preserving access to newer PRs without quadrupling expensive review/check/comment work. See `docs/doctoring/hourly-review-repair-single-file-consolidation.md`'s 2026-09-03 follow-up. ## [Unreleased] +- Stop `pull_request_review: submitted` events with state `commented` at the + merge scheduler's job-admission boundary, before a hosted runner is + requested. `approved`, `changes_requested`, and `dismissed` review + transitions retain their existing exact-PR scheduler path and permissions. - **Fix current-main contract drift that blocked the unscoped `agent-review-runtime-quality-ci.yml` "Verify scheduler and contextual-orchestrator review-repair contracts" step (which discovers and diff --git a/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md b/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md index b81778cfef..e978a29e50 100644 --- a/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md +++ b/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md @@ -1,5 +1,19 @@ # Doctoring record: pr-review-merge-scheduler.yml's "fires at every step" pattern is by-design, not a bug (2026-09-03) +> **2026-09-05 correction.** The broad claim below that every submitted review +> is an actionable approval-state change was incomplete. GitHub emits +> `pull_request_review: submitted` for `COMMENTED` reviews, which do not create +> an `APPROVED` or `CHANGES_REQUESTED` state. On PR #1885, CodeRabbit submitted +> `COMMENTED` reviews at 03:08:52Z, 04:27:31Z, and 05:30:37Z; the central +> scheduler admitted runner-backed runs 33941045179, 33944606701, and +> 33947394894 within seconds. The scheduler still needs the review trigger for +> `APPROVED`, `CHANGES_REQUESTED`, and `dismissed`, but `COMMENTED` is now +> rejected by the `scan-pr-queue` job-level `if` before runner acquisition. +> The executable truth-table contract is +> `tests/test_merge_scheduler_review_event_admission.py`. This correction does +> not reinterpret a bot comment as formal review evidence and does not alter +> exact-PR concurrency, review semantics, or scheduler permissions. + - **Date:** 2026-09-03 - **Subject:** the user directly observed the scheduler workflow firing repeatedly ("왜 각 모든 단계마다 Trigger 되고 있죠?") after live evidence surfaced today of severe org-wide Actions thrashing (near-zero completion diff --git a/tests/test_merge_scheduler_review_event_admission.py b/tests/test_merge_scheduler_review_event_admission.py new file mode 100644 index 0000000000..cfae21197b --- /dev/null +++ b/tests/test_merge_scheduler_review_event_admission.py @@ -0,0 +1,100 @@ +"""Executable admission contract for merge-scheduler review events.""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "pr-review-merge-scheduler.yml" + + +def scan_job_condition() -> str: + """Return the normalized pre-runner condition for ``scan-pr-queue``.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + scan_job = workflow.split("\n scan-pr-queue:\n", 1)[1] + condition = scan_job.split("\n runs-on:", 1)[0].split("\n if: >-\n", 1)[1] + return " ".join(line.strip() for line in condition.splitlines()) + + +def admits_review_event(*, action: str, state: str) -> bool: + """Evaluate the workflow's review-event condition for one trusted fixture.""" + expression = scan_job_condition().replace("&&", " and ").replace("||", " or ") + expression = re.sub(r"\btrue\b", "True", expression) + values = { + "github.event_name": "pull_request_review", + "github.event.action": action, + "github.event.review.state": state, + "github.event.client_payload.org_sweep": False, + } + + def evaluate(node: ast.AST) -> object: + """Interpret only the boolean/comparison subset used by the job guard.""" + if isinstance(node, ast.Expression): + return evaluate(node.body) + if isinstance(node, ast.BoolOp): + operands = [bool(evaluate(value)) for value in node.values] + return all(operands) if isinstance(node.op, ast.And) else any(operands) + if isinstance(node, ast.Compare) and len(node.ops) == len(node.comparators) == 1: + left = evaluate(node.left) + right = evaluate(node.comparators[0]) + if isinstance(node.ops[0], ast.Eq): + return left == right + if isinstance(node.ops[0], ast.NotEq): + return left != right + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, (ast.Attribute, ast.Name)): + key = ast.unparse(node) + if key in values: + return values[key] + raise AssertionError(f"unsupported scheduler expression node: {ast.dump(node)}") + + return bool(evaluate(ast.parse(expression, mode="eval"))) + + +@pytest.mark.parametrize( + ("action", "state", "expected"), + [ + ("submitted", "commented", False), + ("submitted", "approved", True), + ("submitted", "changes_requested", True), + ("dismissed", "commented", True), + ], +) +def test_review_event_admission_truth_table( + action: str, state: str, expected: bool +) -> None: + """Admit only review transitions that can change merge eligibility.""" + assert admits_review_event(action=action, state=state) is expected + + +def test_review_filter_preserves_exact_pr_group_and_least_privilege() -> None: + """Filtering COMMENTED reviews must not weaken scheduler trust boundaries.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + assert ( + "github.event_name == 'pull_request_review' && " + "format('pr-{0}', github.event.pull_request.number)" in workflow + ) + assert ( + "cancel-in-progress: ${{ github.event_name == 'pull_request_target' || " + "github.event_name == 'pull_request_review' || " + "github.event_name == 'repository_dispatch' }}" in workflow + ) + assert "permissions:\n contents: read" in workflow + + scan_header = workflow.split("\n scan-pr-queue:\n", 1)[1].split( + "\n env:\n", 1 + )[0] + for permission in ( + "actions: write", + "checks: read", + "contents: write", + "id-token: write", + "pull-requests: write", + ): + assert permission in scan_header From 9c6d03b1b91028f5ff3bd30f2880c7d4015c95bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:31:50 +0900 Subject: [PATCH 2/2] fix(scheduler): remove redundant review publication wait Actionable review events now enter the scheduler core without an eight-attempt runner-held polling loop. The review-event path keeps auto-merge disabled, while the existing OpenCode post-publication path retains merge authority. Co-authored-by: Codex Signed-off-by: Seongho Bae --- .../workflows/pr-review-merge-scheduler.yml | 105 ------------------ CHANGELOG.md | 6 +- ...-merge-scheduler-trigger-audit-20260903.md | 11 ++ ..._merge_scheduler_review_event_admission.py | 62 +++++++++++ tests/test_opencode_agent_contract.py | 18 +-- tests/test_opencode_workflow_shell_syntax.py | 6 +- tests/test_pr_review_merge_scheduler.py | 25 +++++ 7 files changed, 110 insertions(+), 123 deletions(-) diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index a7feaa1144..8025e748d2 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -405,112 +405,7 @@ jobs: - name: Self-test scheduler run: python3 scripts/ci/pr_review_merge_scheduler.py --self-test - - name: Wait for approved OpenCode publication run to finish - id: review_followup - if: >- - github.event_name == 'pull_request_review' - && github.event.action == 'submitted' - && github.event.review.state == 'approved' - && ( - github.event.review.user.login == 'opencode-agent' - || github.event.review.user.login == 'opencode-agent[bot]' - ) - env: - GH_TOKEN: ${{ github.token }} - REVIEW_HEAD_SHA: ${{ github.event.review.commit_id }} - REVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - echo "proceed=true" >>"$GITHUB_OUTPUT" - - if [[ ! "${REVIEW_HEAD_SHA:-}" =~ ^[0-9a-fA-F]{40}$ ]]; then - printf '::warning::Post-approval direct-merge follow-up skipped because the OpenCode App review did not carry a 40-character commit SHA. value=%s.\n' "${REVIEW_HEAD_SHA:-missing}" - echo "proceed=false" >>"$GITHUB_OUTPUT" - exit 0 - fi - - pull_error_file="$(mktemp)" - if ! pull_json="$( - gh api "repos/${GITHUB_REPOSITORY}/pulls/${REVIEW_PR_NUMBER}" \ - 2>"$pull_error_file" - )"; then - pull_reason="$(tail -n 1 "$pull_error_file" 2>/dev/null || true)" - [ -n "$pull_reason" ] || pull_reason="GitHub pull-request lookup failed without an error body" - rm -f "$pull_error_file" - printf '::warning::Post-approval direct-merge follow-up skipped because the live pull request snapshot could not be read. PR=%s review_head=%s reason=%s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$pull_reason" - echo "proceed=false" >>"$GITHUB_OUTPUT" - exit 0 - fi - rm -f "$pull_error_file" - live_state="$(jq -r '.state // "unknown"' <<<"$pull_json")" - live_head="$(jq -r '.head.sha // empty' <<<"$pull_json")" - if [ "$live_state" != "open" ] || [ "$live_head" != "$REVIEW_HEAD_SHA" ]; then - printf '::notice::Post-approval direct-merge follow-up skipped because the pull request snapshot changed. PR=%s review_head=%s live_head=%s state=%s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "${live_head:-missing}" "$live_state" - echo "proceed=false" >>"$GITHUB_OUTPUT" - exit 0 - fi - - opencode_state="missing" - opencode_reason="no opencode-review check run was visible for the approved head" - for check_attempt in 1 2 3 4 5 6 7 8; do - check_error_file="$(mktemp)" - if checks_json="$( - gh api --paginate --slurp \ - "repos/${GITHUB_REPOSITORY}/commits/${REVIEW_HEAD_SHA}/check-runs?per_page=100" \ - 2>"$check_error_file" - )"; then - opencode_state="$( - jq -r ' - [.[].check_runs[] - | select(.name == "opencode-review") - | select(.app.slug == "github-actions")] as $runs - | if ($runs | length) == 0 then "missing" - elif any($runs[]; .status != "completed") then "running" - elif any($runs[]; .conclusion != "success") then - "failed:" + ([$runs[] | (.conclusion // "missing")] | unique | join(",")) - else "success" - end - ' <<<"$checks_json" - )" - case "$opencode_state" in - success) - printf 'Approved OpenCode publication run completed successfully for PR %s at %s after check attempt %s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$check_attempt" - rm -f "$check_error_file" - break - ;; - failed:*) - opencode_reason="opencode-review completed without success (${opencode_state#failed:})" - rm -f "$check_error_file" - break - ;; - running) - opencode_reason="opencode-review is still running for the approved head" - ;; - *) - opencode_reason="no opencode-review check run was visible for the approved head" - ;; - esac - else - opencode_state="api-error" - opencode_reason="$(tail -n 1 "$check_error_file" 2>/dev/null || true)" - [ -n "$opencode_reason" ] || opencode_reason="GitHub check-runs lookup failed without an error body" - fi - rm -f "$check_error_file" - - if [ "$check_attempt" -lt 8 ]; then - check_delay="$((check_attempt * 2))" - printf 'Approved OpenCode publication run is not complete for PR %s at %s after check attempt %s: %s. Retrying in %ss.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$check_attempt" "$opencode_reason" "$check_delay" - sleep "$check_delay" - fi - done - - if [ "$opencode_state" != "success" ]; then - printf '::warning::Post-approval direct-merge follow-up skipped because the approved OpenCode publication run did not complete successfully. PR=%s head=%s state=%s reason=%s. Native events and the explicit org-sweep recovery remain authoritative.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$opencode_state" "$opencode_reason" - echo "proceed=false" >>"$GITHUB_OUTPUT" - fi - - name: Inspect PR review and merge queue - if: steps.review_followup.outputs.proceed != 'false' env: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} TARGET_REPOSITORY: ${{ steps.targeted_dispatch.outputs.repository }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e96dfc703..76184816a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,11 @@ requested. `approved`, `changes_requested`, and `dismissed` review transitions retain their existing exact-PR scheduler path and permissions; COMMENTED submissions also no longer cancel an already-running actionable - review transition through workflow-level concurrency. + review transition through workflow-level concurrency. Actionable review + events now enter the scheduler core directly instead of holding the runner + for up to 56 seconds while polling OpenCode publication. OpenCode's existing + post-publication scheduler call and GitHub's native auto-merge continuation + remain the merge owners. - Include merge-scheduler entrypoint, core, and regression-test changes in the existing runtime-quality workflow's trigger and suite selector. Scheduler workflow edits retain queue checks and also select the full review-repair diff --git a/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md b/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md index 3a5ff2c00e..b1d97d273c 100644 --- a/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md +++ b/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md @@ -1,5 +1,16 @@ # Doctoring record: pr-review-merge-scheduler.yml's "fires at every step" pattern is by-design, not a bug (2026-09-03) +> **2026-09-07 correction.** An actionable `pull_request_review` event now runs +> the scheduler core immediately. The removed pre-core step polled the +> `opencode-review` check up to eight times and could hold a runner for 56 +> seconds, even though `opencode-review-dispatch.yml` already performs the +> current-head approval check and invokes the scheduler with merge authority +> after publication. The review-event path keeps its existing admission, +> credentials, and fail-closed core checks; it does not gain merge authority. +> Once auto-merge is armed, GitHub's native required-check handling remains the +> terminal continuation. The old fallback message naming an org-wide sweep was +> removed with the obsolete wait step; no replacement workflow was added. + > **2026-09-05 correction.** The broad claim below that every submitted review > is an actionable approval-state change was incomplete. GitHub emits > `pull_request_review: submitted` for `COMMENTED` reviews, which do not create diff --git a/tests/test_merge_scheduler_review_event_admission.py b/tests/test_merge_scheduler_review_event_admission.py index dd0d0e8b6c..7f21461ca4 100644 --- a/tests/test_merge_scheduler_review_event_admission.py +++ b/tests/test_merge_scheduler_review_event_admission.py @@ -11,6 +11,18 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github" / "workflows" / "pr-review-merge-scheduler.yml" +OPENCODE_WORKFLOW = ROOT / ".github" / "workflows" / "opencode-review-dispatch.yml" + + +def workflow_step(workflow: str, name: str) -> str: + """Return exactly one named workflow step.""" + marker = f" - name: {name}\n" + start = workflow.index(marker) + try: + end = workflow.index("\n - name:", start + len(marker)) + except ValueError: + end = len(workflow) + return workflow[start:end] def scan_job_condition() -> str: @@ -35,17 +47,27 @@ def cancellation_condition() -> str: return normalized.removeprefix("${{ ").removesuffix(" }}") +def auto_merge_condition() -> str: + """Return the executable scheduler auto-merge input expression.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + raw = workflow.split(" ENABLE_AUTO_MERGE: ${{ ", 1)[1].split(" }}", 1)[0] + return " ".join(raw.split()) + + def evaluate_review_expression( *, expression: str, action: str, state: str, event_name: str = "pull_request_review" ) -> bool: """Evaluate a workflow review-event expression for one trusted fixture.""" expression = expression.replace("&&", " and ").replace("||", " or ") expression = re.sub(r"\btrue\b", "True", expression) + expression = re.sub(r"\bfalse\b", "False", expression) values = { "github.event_name": event_name, "github.event.action": action, "github.event.review.state": state, "github.event.client_payload.org_sweep": False, + "github.event.client_payload.enable_auto_merge": False, + "inputs.enable_auto_merge": False, } def evaluate(node: ast.AST) -> object: @@ -158,3 +180,43 @@ def test_review_filter_preserves_non_review_cancellation_semantics( ) is expected ) + + +def test_actionable_review_event_reaches_core_without_runner_held_publication_wait() -> None: + """An admitted review transition must invoke the core without a sleep gate.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + assert "Wait for approved OpenCode publication run to finish" not in workflow + assert "steps.review_followup.outputs.proceed" not in workflow + assert 'check_delay="$((check_attempt * 2))"' not in workflow + + self_test_index = workflow.index(" - name: Self-test scheduler") + inspect_index = workflow.index(" - name: Inspect PR review and merge queue") + between = workflow[self_test_index:inspect_index] + assert "sleep " not in between + assert "gh api" not in between + assert evaluate_review_expression( + expression=auto_merge_condition(), + action="submitted", + state="approved", + ) is False + + +def test_opencode_follow_up_retains_merge_authority_after_wait_removal() -> None: + """OpenCode's existing follow-up remains the post-publication merge owner.""" + scheduler = WORKFLOW.read_text(encoding="utf-8") + opencode = OPENCODE_WORKFLOW.read_text(encoding="utf-8") + assert "Wait for approved OpenCode publication run to finish" not in scheduler + + follow_up = workflow_step(opencode, "Run merge scheduler after approval") + for contract in ( + "python3 scripts/ci/opencode_existing_approval_gate.py", + "--require-opencode-app", + "python3 scripts/ci/pr_review_merge_scheduler.py", + "--review-dispatch-limit 0", + "--no-trigger-reviews", + "--enable-auto-merge", + "--merge-mode direct_or_auto", + "--no-update-branches", + ): + assert contract in follow_up + assert "gh workflow run pr-review-merge-scheduler.yml" not in follow_up diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 4784da541b..f1ceacec9c 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -2394,20 +2394,10 @@ def test_merge_scheduler_uses_escalating_mutation_credentials(): "github.event_name == 'pull_request_review' && " "format('pr-{0}', github.event.pull_request.number)" in workflow ) - assert "Wait for approved OpenCode publication run to finish" in workflow - assert "github.event.review.user.login == 'opencode-agent'" in workflow - assert "github.event.review.user.login == 'opencode-agent[bot]'" in workflow - assert "REVIEW_HEAD_SHA: ${{ github.event.review.commit_id }}" in workflow - assert "repos/${GITHUB_REPOSITORY}/pulls/${REVIEW_PR_NUMBER}" in workflow - assert "live pull request snapshot could not be read" in workflow - assert ( - "repos/${GITHUB_REPOSITORY}/commits/${REVIEW_HEAD_SHA}/check-runs?per_page=100" - in workflow - ) - assert 'select(.name == "opencode-review")' in workflow - assert 'check_delay="$((check_attempt * 2))"' in workflow - assert "steps.review_followup.outputs.proceed != 'false'" in workflow - assert "Native events and the explicit org-sweep recovery remain authoritative." in workflow + assert "Wait for approved OpenCode publication run to finish" not in workflow + assert "steps.review_followup.outputs.proceed" not in workflow + assert 'check_delay="$((check_attempt * 2))"' not in workflow + assert "Native events and the explicit org-sweep recovery remain authoritative." not in workflow concurrency_block = workflow.split("\nconcurrency:\n", 1)[1].split( "\n# Scorecard", 1 )[0] diff --git a/tests/test_opencode_workflow_shell_syntax.py b/tests/test_opencode_workflow_shell_syntax.py index b0a672b1a2..af18bf4f8c 100644 --- a/tests/test_opencode_workflow_shell_syntax.py +++ b/tests/test_opencode_workflow_shell_syntax.py @@ -95,8 +95,8 @@ def test_opencode_review_comment_helpers_are_shared_and_valid_bash(): assert result.returncode == 0, result.stderr -def test_merge_scheduler_review_followup_run_block_is_valid_bash(): - """The App-review follow-up keeps its dynamic wait logic valid Bash.""" +def test_merge_scheduler_inspect_run_block_is_valid_bash(): + """The direct review-event scheduler path remains valid Bash.""" if sys.platform == "win32": return bash = shutil.which("bash") @@ -108,7 +108,7 @@ def test_merge_scheduler_review_followup_run_block_is_valid_bash(): ).read_text(encoding="utf-8") script = _extract_run_block( workflow_text, - "Wait for approved OpenCode publication run to finish", + "Inspect PR review and merge queue", ) result = subprocess.run( [bash, "-n"], diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 1e5848aac3..e5e01dfe1a 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -8794,6 +8794,31 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): assert "no OpenCode approval" in missing_approval_auto.reason +def test_review_event_enters_inspection_without_merge_authority(monkeypatch): + """An approved live head is inspected but cannot mutate on the review event.""" + mutations = [] + monkeypatch.setattr( + sched, + "merge_pr", + lambda *_args, **_kwargs: mutations.append("merge"), + ) + monkeypatch.setattr( + sched, + "enable_auto_merge", + lambda *_args, **_kwargs: mutations.append("auto-merge"), + ) + approved = make_pr( + reviewDecision="APPROVED", + reviews=merge_approved_reviews(commit="head"), + ) + + decision = inspect(approved, enable_auto_merge_flag=False) + + assert decision.action == "wait" + assert decision.reason == "current head is approved; auto-merge disabled by scheduler inputs" + assert mutations == [] + + def test_inspect_pr_waits_when_same_head_dispatch_is_already_running(monkeypatch): monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda repo, workflow: None) monkeypatch.setattr(