diff --git a/.github/workflows/scheduler-independent-approval-quality-ci.yml b/.github/workflows/scheduler-independent-approval-quality-ci.yml new file mode 100644 index 000000000..d52d7d063 --- /dev/null +++ b/.github/workflows/scheduler-independent-approval-quality-ci.yml @@ -0,0 +1,65 @@ +name: Scheduler Independent Approval Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/scheduler-independent-approval-quality-ci.yml" + - "scripts/ci/pr_review_merge_scheduler.py" + - "scripts/ci/_pr_review_merge_scheduler_core.py" + - "tests/test_scheduler_independent_approval_gate.py" + - "tests/test_pr_review_merge_scheduler.py" + - "docs/doctoring/scheduler-independent-current-head-approval.md" + - "CHANGELOG.md" + +permissions: + contents: read + +concurrency: + group: scheduler-independent-approval-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + exact-head-independent-approval: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install exact hash-verified test runner dependencies + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_NO_INPUT: "1" + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + cat >"${RUNNER_TEMP}/scheduler-approval-quality-requirements.txt" <<'EOF' + iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + EOF + python -m pip install \ + --only-binary=:all: \ + --require-hashes \ + -r "${RUNNER_TEMP}/scheduler-approval-quality-requirements.txt" + + - name: Prove independent current-head approval contract + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + python -m pytest -q + python -m compileall -q \ + scripts/ci/pr_review_merge_scheduler.py \ + scripts/ci/_pr_review_merge_scheduler_core.py \ + tests/test_scheduler_independent_approval_gate.py + test -z "$(git status --porcelain=v1 --untracked-files=all)" diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..4e0ddbb7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,7 @@ Semantic Versioning where the repository publishes a release. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. - Bound both trusted-uv quality jobs to `github.event.pull_request.head.sha` and added a permanent two-checkout regression contract so exact-head compatibility, coverage, docstring, and compilation claims cannot silently measure GitHub's generated pull-request merge revision. - Made Strix treat only a single LiteLLM provider-error line containing NVIDIA NIM context and model-catalog 404 evidence as cross-model fallback evidence, rejecting cross-line signal assembly and provider-like target source literals; moved the public default to Nemotron 3 Super 120B and added a second NVIDIA hosted candidate before GitHub Models without neutralizing reported vulnerabilities. + +### Security + +- Made the central merge scheduler fail closed unless GitHub reports the pull request approved and a formal non-author, non-OpenCode approval is bound to the exact current head; authoritative PR evidence now carries author identity, stale or anonymous approval cannot authorize merge, and the permanent quality gate tracks both the approval-policy facade and scheduler core. diff --git a/docs/doctoring/scheduler-independent-current-head-approval.md b/docs/doctoring/scheduler-independent-current-head-approval.md new file mode 100644 index 000000000..38927238c --- /dev/null +++ b/docs/doctoring/scheduler-independent-current-head-approval.md @@ -0,0 +1,74 @@ +# Scheduler independent exact-head approval gate + +## Decision record + +The organization merge scheduler must fail closed unless GitHub's current pull-request review policy is satisfied **and** an independent reviewer has submitted a formal `APPROVED` review bound to the exact live pull-request head. Exact-head OpenCode approval remains necessary where configured, but it is not sufficient merge authority. + +This control repairs the governance defect tracked in #771: an automation credential that can merge or enable auto-merge must not infer separation of duties from an advisory model review, a predecessor-head approval, the pull-request author's own identity, a status context, or absent review metadata. + +## Threat and failure model + +A repository or organization ruleset can permit selected users, roles, teams, or GitHub Apps to bypass rules. Consequently, relying on GitHub to reject an unsafe scheduler mutation is insufficient when the scheduler credential could ever be granted bypass capability. The scheduler therefore applies an application-level gate before either direct merge or native auto-merge entrypoints. + +The fail-closed decision requires all of the following review evidence on the current live pull request: + +1. `reviewDecision` is exactly `APPROVED`; +2. the authoritative pull-request evidence includes a non-empty author login; +3. an independent review has state `APPROVED`; +4. that formal review is bound to the exact current head SHA under the scheduler's existing review/head-evidence rules; +5. the reviewer identity is non-empty, differs from the pull-request author, and is not the OpenCode automated reviewer; and +6. every pre-existing scheduler gate for current-head OpenCode evidence, Strix/security evidence, unresolved threads, checks, mergeability, head freshness, branch update safety, and expected-head merge semantics remains in force. + +`REVIEW_REQUIRED`, missing review state, stale/dismissed/comment-only reviews, unknown author identity, author self-review, OpenCode-only review, predecessor-head approval, status-only evidence, and synthetic merge evidence never satisfy this gate. + +## Implementation boundary + +`scripts/ci/pr_review_merge_scheduler.py` is the auditable approval-policy facade. The mature scheduler engine remains in `scripts/ci/_pr_review_merge_scheduler_core.py`; the facade adds the smallest merge-authorization boundary without duplicating or weakening the established check, cleanup, branch-update, review-dispatch, conflict, and expected-head machinery. + +The authoritative GraphQL pull-request envelope is extended with `author { login }`. The REST fallback records the same author identity from GitHub's pull-request user field. If that identity is absent, independence cannot be established and the result is `WAIT`. + +When the independent-approval gate is unsatisfied, the facade invokes the existing scheduler engine with merge entrypoints mechanically disabled. This preserves productive non-merge maintenance while preventing `merge_pr` or auto-merge enablement. If native auto-merge is already configured on an otherwise clean PR that no longer satisfies the gate, the scheduler disables it rather than allowing stale approval state to remain armed. + +The split is permanent source structure, not a one-shot repair workflow or branch writer. The focused quality workflow tracks and compiles both the policy facade and the core module so edits to either surface regenerate exact-head evidence. + +## Test-first evidence + +The permanent regression suite covers the following cases: + +- GitHub `REVIEW_REQUIRED` blocks despite exact-head OpenCode and independent approvals; +- GitHub `APPROVED` without an exact-head independent approval blocks; +- predecessor-head independent approval blocks; +- pull-request-author self-approval blocks; +- missing pull-request author identity blocks; +- missing reviewer identity blocks; +- non-`APPROVED` independent review blocks; +- exact-head OpenCode plus exact-head non-author independent approval plus GitHub `APPROVED` preserves the normal merge path; and +- the permanent quality workflow tracks the scheduler core as well as the facade. + +The focused exact-head workflow also verifies literal pull-request-head checkout with persisted credentials disabled, hash-verified test dependencies, Python compilation, and a clean worktree. Broader repository, security, supply-chain, automated-review, independent-review, and branch-protection evidence remains independently required before readiness or merge. + +## Relationship to GitHub rules + +GitHub documents that protected branches and rulesets can require approving reviews and passing status checks. GitHub also documents that pull-request authors cannot approve their own pull requests; that stale approvals can be dismissed after code changes; and that rulesets can require approval from someone other than the most recent pusher. Rulesets may additionally define bypass actors. The scheduler's application-level gate intentionally complements these server-side controls rather than replacing or weakening them. + +A GitHub `APPROVED` aggregate decision is therefore treated as necessary repository-policy evidence, while the exact-head independent-review check supplies an explicit automation-level separation-of-duties invariant. Neither condition substitutes for required checks, security gates, conversation resolution, or the repository's actual branch/ruleset evaluation. + +## Operations and rollback + +Before every scheduler mutation, refetch the live pull-request head/base and the relevant target state. If the head or base changes, discard predecessor evidence and re-evaluate. Merge remains expected-head guarded. + +If this repair causes an operational regression, rollback means reverting the reviewed scheduler change and restoring the last protected-main implementation while keeping merges disabled until an equivalent independent-approval control is available. Rollback must never be implemented by lowering required-review counts, granting routine bypass, synthesizing review state, accepting stale approvals, or re-enabling direct merge without an equivalent fail-closed authorization check. + +## Non-claims + +This control does not prove that an approving reviewer is organizationally independent merely because GitHub identities differ. Repository permission, team membership, last-pusher rules, CODEOWNERS requirements, and organization policy remain authoritative. It also does not make an automated review a substitute for a human approval when repository policy requires a counted human reviewer. + +## References + +GitHub. (n.d.). *About protected branches*. GitHub Docs. Retrieved August 8, 2026, from https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches + +GitHub. (n.d.). *Approving a pull request with required reviews*. GitHub Docs. Retrieved August 8, 2026, from https://docs.github.com/en/pull-requests/how-tos/review-pull-requests/approving-a-pull-request-with-required-reviews + +GitHub. (n.d.). *Available rules for rulesets*. GitHub Docs. Retrieved August 8, 2026, from https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets + +GitHub. (n.d.). *About rulesets*. GitHub Docs. Retrieved August 8, 2026, from https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets diff --git a/scripts/ci/_pr_review_merge_scheduler_core.py b/scripts/ci/_pr_review_merge_scheduler_core.py new file mode 100644 index 000000000..75e18c860 --- /dev/null +++ b/scripts/ci/_pr_review_merge_scheduler_core.py @@ -0,0 +1,3803 @@ +#!/usr/bin/env python3 +"""Inspect PR review state and drive centralized OpenCode merge automation.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import json +import os +import re +import shlex +import subprocess +import sys +import time +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any +from urllib.parse import quote + + +PULL_REQUEST_FIELDS_FRAGMENT = """\ +fragment SchedulerPullRequestFields on PullRequest { + number + title + isDraft + mergeable + mergeStateStatus + reviewDecision + baseRefName + baseRefOid + headRefName + headRefOid + isCrossRepository + maintainerCanModify + headRepository { nameWithOwner } + autoMergeRequest { enabledAt } + commits(last: 1) { + nodes { + commit { + oid + authoredDate + committedDate + messageHeadline + } + } + } + reviewThreads(first: 100) { + nodes { id isResolved isOutdated } + } + files(first: 20) { + nodes { path } + } + reviews(last: 100) { + nodes { + databaseId + state + body + submittedAt + author { login } + commit { oid } + } + } + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + status + conclusion + startedAt + detailsUrl + checkSuite { + workflowRun { + workflow { name } + } + } + } + ... on StatusContext { + context + state + } + } + } + } +} +""" + +OPEN_PRS_QUERY = """\ +query($owner: String!, $name: String!, $pageSize: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequests(first: $pageSize, after: $cursor, states: OPEN, orderBy: {field: CREATED_AT, direction: ASC}) { + pageInfo { hasNextPage endCursor } + nodes { + ...SchedulerPullRequestFields + } + } + } +} +""" + PULL_REQUEST_FIELDS_FRAGMENT + +PR_BY_NUMBER_QUERY = """\ +query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + ...SchedulerPullRequestFields + } + } +} +""" + PULL_REQUEST_FIELDS_FRAGMENT + +OPEN_PRS_PAGE_SIZE = 25 +# Must exceed the 45-minute OpenCode job cap plus typical runner-queue wait. +# QUEUED counts as running and the age clock starts at check creation, so this +# remains deliberately larger than the job cap while recovering genuine zombie +# checks in the same operating window instead of leaving them for seven hours. +DEFAULT_STALE_OPENCODE_MINUTES = 90 +DEFAULT_UPDATE_BRANCH_HEAD_POLL_ATTEMPTS = 6 +DEFAULT_UPDATE_BRANCH_HEAD_POLL_SECONDS = 5.0 +OPENCODE_WORKFLOW_NAMES = { + "OpenCode Review", + "Required OpenCode Review", + "OpenCode Review Dispatch", +} +RUNNING_CHECK_STATES = {"PENDING", "EXPECTED", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED"} +FAILED_CHECK_CONCLUSIONS = {"FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE"} +ACTION_REQUIRED_CONCLUSIONS = {"ACTION_REQUIRED"} +GIT_REF_RE = re.compile(r"^(?!-)[A-Za-z0-9._/-]+$") +GIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +GITHUB_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +REVIEW_BODY_HEAD_SHA_RE = re.compile(r"Head SHA:\s*`([0-9a-fA-F]{40})`") +ACTIONS_JOB_DETAILS_URL_RE = re.compile(r"/actions/runs/\d+/job/(\d+)(?:[/?#]|$)") +DIRECT_MERGE_AUTO_FALLBACK_MARKERS = ( + "base branch policy prohibits the merge", + "is not mergeable", + "merge requirements", + "required status check", +) +SQUASH_MERGE_DISABLED_MARKERS = ( + "squash merge is not allowed", + "squash merges are not allowed", +) +REST_MERGEABLE_STATE_MAP = { + "behind": "BEHIND", + "blocked": "BLOCKED", + "clean": "CLEAN", + "dirty": "DIRTY", + "draft": "DRAFT", + "has_hooks": "HAS_HOOKS", + "unknown": "UNKNOWN", + "unstable": "UNSTABLE", +} +REST_MERGEABLE_STATES = set(REST_MERGEABLE_STATE_MAP.values()) +REST_MERGEABLE_STATE_WORKERS = 10 +DETERMINISTIC_APPROVAL_MARKERS = ( + "deterministic current-head evidence", + "deterministic fallback approval", + "did not emit a usable current-head control block", +) +LAST_PUSH_APPROVAL_RESTAMP_MESSAGE = "chore: refresh head for last-push approval" + + +@dataclass +class Decision: + """Scheduler decision for a single pull request.""" + + pr: int + action: str + reason: str + notes: tuple[str, ...] = () + + +RESOLVE_REVIEW_THREAD_MUTATION = """\ +mutation($threadId: ID!) { + resolveReviewThread(input: {threadId: $threadId}) { + thread { id isResolved } + } +} +""" + + +SENSITIVE_DATA_SCRUB_PATTERNS = ( + (re.compile(r'(?i)(bearer\s+)[^\s"\'\\]+'), r'\1***'), + (re.compile(r'(?i)(token\s+)[^\s"\'\\]+'), r'\1***'), + (re.compile(r'(?i)\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+)\b'), '***'), + (re.compile(r'\b(sk-[A-Za-z0-9_-]+)'), '***'), + (re.compile(r'\b(xox[baprs]-[A-Za-z0-9-]+)'), '***'), + (re.compile(r'\b(AKIA[0-9A-Z]{16})'), '***'), + (re.compile(r'(?i)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|password|passwd|secret)\s*[:=]\s*)["\']?[^"\'\s]+["\']?'), r'\1***'), + (re.compile(r'(?i)((?:authorization|proxy-authorization)\s*:\s*(?:bearer|basic)\s+)[A-Za-z0-9._~+\/=-]+'), r'\1***'), +) + + +def scrub_sensitive_data(text: str | None) -> str | None: + """Mask sensitive tokens in text to prevent secret leakage.""" + if not text: + return text + for pattern, repl in SENSITIVE_DATA_SCRUB_PATTERNS: + text = pattern.sub(repl, text) + return text + + +def mutation_token_source() -> str: + """Return the configured scheduler mutation credential source.""" + return (os.environ.get("SCHEDULER_MUTATION_TOKEN_SOURCE") or "github-token").strip() or "github-token" + + +def mutation_token_label() -> str: + """Return a non-secret label for the scheduler mutation credential.""" + source = mutation_token_source() + labels = { + "PR_REVIEW_MERGE_TOKEN": "PR_REVIEW_MERGE_TOKEN", + "OPENCODE_APPROVE_TOKEN": "OPENCODE_APPROVE_TOKEN", + "opencode-app": "OpenCode app token", + "github-token": "workflow GITHUB_TOKEN", + } + return labels.get(source, "workflow GH_TOKEN") + + +def mutation_actor_label() -> str: + """Return the expected GitHub actor class for scheduler mutations.""" + source = mutation_token_source() + if source == "github-token": + return "github-actions[bot]" + if source == "opencode-app": + return "OpenCode GitHub App" + return "configured workflow credential" + + +def contract_decision(decision: Decision) -> str: + """Map scheduler actions into the bounded PR decision contract.""" + if decision.action in {"update_branch", "restamp_head"}: + return "UPDATE_BRANCH" + if decision.action in {"wait", "security_dispatch", "review_dispatch", "disable_auto_merge", "action_error"}: + return "WAIT" + if decision.action in {"skip", "auto_merge", "merge"}: + return "NO_ACTION" + if decision.action == "block" and "current-head OpenCode review requested changes" in decision.reason: + return "REQUEST_CHANGES" + return "WAIT" + + +def decision_payload( + decisions: list[Decision], + *, + counts: dict[str, int], + dry_run: bool, + base_branch: str, + project_flow: str, +) -> dict[str, Any]: + """Return the machine-readable scheduler decision contract.""" + return { + "schema_version": "pr-review-merge-scheduler/v2", + "base_branch": base_branch, + "dry_run": dry_run, + "inspected": len(decisions), + "counts": counts, + "project_flow": project_flow, + "decisions": [decision_contract_entry(decision) for decision in decisions], + } + + +def decision_contract_entry(decision: Decision) -> dict[str, Any]: + """Return one machine-readable decision contract entry.""" + entry: dict[str, Any] = { + "pr": decision.pr, + "action": decision.action, + "contract_decision": contract_decision(decision), + "reason": decision.reason, + } + guidance = decision_guidance(decision) + if guidance: + entry["guidance"] = guidance + if decision.notes: + entry["notes"] = list(decision.notes) + return entry + + +def decision_guidance(decision: Decision) -> dict[str, Any] | None: + """Return actionable repair or automation guidance for known scheduler states.""" + parsed_conflict = parse_conflict_reason(decision.reason) + if parsed_conflict: + state, base_ref, head_ref = parsed_conflict + base_remote = f"origin/{base_ref}" + quoted_base_ref = shlex.quote(base_ref) + quoted_base_remote = shlex.quote(base_remote) + guidance: dict[str, Any] = { + "type": "merge_conflict_repair", + "merge_state": state, + "base_ref": base_ref, + "head_ref": head_ref, + "summary": "Repair the PR branch against the latest base branch, then push the same branch so review and required checks rerun on the new head.", + "automation_limit": "GitHub update-branch cannot choose merge-conflict resolutions; the scheduler must wait until the PR branch is repaired.", + "steps": [ + "Check out the PR branch.", + "Fetch the latest base branch.", + "Choose merge or rebase; do not treat the conflict as an OpenCode finding.", + "Resolve conflict markers in the PR branch and stage the resolved files.", + "Run the focused checks for the changed area.", + "Push the PR branch; use --force-with-lease only if the branch was rebased.", + ], + "commands": [ + f"gh pr checkout {decision.pr}", + f"git fetch origin {quoted_base_ref}", + f"git merge --no-ff {quoted_base_remote}", + f"# or: git rebase {quoted_base_remote}", + "git status --short", + "git add ", + "# merge path: git commit", + "# rebase path: git rebase --continue", + "git push", + "# rebase path only: git push --force-with-lease", + ], + } + changed_files = parse_conflict_changed_files(decision.reason) + if changed_files: + guidance["changed_files_to_inspect"] = changed_files + return guidance + action_required = parse_workflow_action_required_reason(decision.reason) + if action_required: + return { + "type": "workflow_action_required", + "checks": action_required, + "summary": "A GitHub Actions run is waiting for workflow approval or a repository policy unblock; this is not a source-code failure by itself.", + "automation_limit": "The scheduler cannot safely reinterpret an ACTION_REQUIRED run as passed or failed, and should not publish a code-review finding from it.", + "next_required_evidence": [ + "GitHub Actions run approval or repository policy unblock", + "current-head check rerun after the unblock", + "OpenCode approval on the exact current head", + "same-head Strix evidence", + "zero active unresolved review threads", + ], + } + external_update = parse_external_head_update_reason(decision.reason) + if external_update: + return { + "type": "external_head_update_required", + "head_repository": external_update, + "summary": "The PR can be reviewed centrally, but this head branch is not writable by the scheduler credential.", + "automation_limit": "The scheduler should not skip the PR; it waits for the author to update the branch or for maintainers to enable a writable head path.", + "next_required_evidence": [ + "PR author updates the head branch against the base branch, or maintainer edit permission is enabled", + "new head SHA after the branch update", + "OpenCode approval on that exact new head", + "same-head Strix evidence", + "required GitHub Checks success", + "zero active unresolved review threads", + ], + } + external_merge = parse_external_head_merge_reason(decision.reason) + if external_merge: + return { + "type": "external_head_merge_excluded", + "head_repository": external_merge, + "summary": "The PR can be reviewed centrally, but this external head is excluded from scheduler direct merge and auto-merge.", + "automation_limit": "The scheduler deliberately leaves fork or external-head merges to maintainers even when approval evidence is clean.", + "next_required_evidence": [ + "same-head OpenCode approval", + "same-head Strix evidence", + "required GitHub Checks success", + "zero active unresolved review threads", + "maintainer manual merge decision", + ], + } + if parse_last_push_approval_restamp_reason(decision.reason): + return { + "type": "last_push_approval_restamp", + "actor": mutation_actor_label(), + "token": mutation_token_label(), + "required_permission": "contents: write", + "head_guard": "live PR head check plus force=false Git ref update", + "summary": "GitHub Actions creates a same-tree child commit so require_last_push_approval can be satisfied by a later non-pusher approval.", + "automation_limit": "The refreshed head is not merge evidence by itself; all current-head checks, Strix evidence, OpenCode review, and review-thread gates must rerun after the new commit.", + "next_required_evidence": [ + "new same-tree head SHA after the restamp mutation", + "OpenCode approval on that exact new head", + "same-head Strix evidence", + "required GitHub Checks success", + "zero active unresolved review threads", + "approving review from an actor who did not push the refreshed head", + ], + } + if decision.action == "update_branch": + return { + "type": "github_actions_update_branch", + "actor": mutation_actor_label(), + "token": mutation_token_label(), + "required_permission": "pull-requests: write", + "head_guard": "expected_head_sha", + "summary": "GitHub Actions requests the PR branch update mechanically; the updated head must be reviewed again before merge.", + "next_required_evidence": [ + "new head SHA after the update_branch mutation", + "OpenCode approval on that exact new head", + "same-head Strix evidence", + "required GitHub Checks success", + "zero active unresolved review threads", + ], + } + if decision.action == "merge": + return { + "type": "github_actions_direct_merge", + "actor": mutation_actor_label(), + "token": mutation_token_label(), + "required_permission": "contents: write", + "head_guard": "gh pr merge --match-head-commit", + "summary": "GitHub Actions performed an immediate guarded merge because repo policy does not use native auto-merge for this queue.", + "next_required_evidence": [ + "merge commit recorded by GitHub", + "merged head SHA matches the inspected current head", + "no active unresolved review threads before merge", + "same-head OpenCode approval before merge", + "required GitHub Checks success before merge", + ], + } + if decision.action == "disable_auto_merge": + return { + "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", + "zero active unresolved review threads", + ], + } + return None + + +def run(args: Sequence[str], *, stdin: str | None = None) -> str: + """Run a command and return stdout, raising a scrubbed summary on failure.""" + return run_with_env(args, stdin=stdin) + + +def run_with_env(args: Sequence[str], *, stdin: str | None = None, env: dict[str, str] | None = None) -> str: + """Run a command with an optional environment override and scrub failures.""" + if isinstance(args, str) or not all(isinstance(arg, str) for arg in args): + raise TypeError("run() requires a sequence of argv strings; shell command strings are not allowed") + argv = list(args) + try: + process = subprocess.run( + argv, + input=stdin, + capture_output=True, + text=True, + shell=False, + check=True, + env=env, + ) + except subprocess.CalledProcessError as exc: + scrubbed_args = scrub_sensitive_data(' '.join(argv)) + scrubbed_stderr = scrub_sensitive_data(exc.stderr or "") + raise RuntimeError( + f"Command failed ({exc.returncode}): {scrubbed_args}\n{scrubbed_stderr}" + ) from exc + return process.stdout + + +def scheduler_read_env() -> dict[str, str] | None: + """Return an env override for GitHub read calls when configured.""" + read_token = os.environ.get("SCHEDULER_READ_TOKEN") + if not read_token or read_token == os.environ.get("GH_TOKEN"): + return None + env = os.environ.copy() + env["GH_TOKEN"] = read_token + return env + + +def run_github_read(args: Sequence[str], *, stdin: str | None = None) -> str: + """Run a GitHub read command with the configured read token when available.""" + env = scheduler_read_env() + if env is None: + return run(args, stdin=stdin) + return run_with_env(args, stdin=stdin, env=env) + + +def scheduler_actions_env() -> dict[str, str] | None: + """Return an env override for GitHub Actions control calls when configured.""" + actions_token = os.environ.get("SCHEDULER_ACTIONS_TOKEN") + if not actions_token or actions_token == os.environ.get("GH_TOKEN"): + return None + env = os.environ.copy() + env["GH_TOKEN"] = actions_token + return env + + +def run_github_actions(args: Sequence[str], *, stdin: str | None = None) -> str: + """Run a GitHub Actions control command with the workflow token when configured.""" + env = scheduler_actions_env() + if env is None: + return run(args, stdin=stdin) + return run_with_env(args, stdin=stdin, env=env) + + +def scheduler_dispatch_env() -> dict[str, str] | None: + """Return an env override for central repository dispatch when configured. + + The OpenCode app installation has no Actions permission, so the mutation token + cannot create a repository dispatch. When the scheduler executes inside the + central repository receiving the event, the runner's own github.token is a + sufficient credential; the workflow passes it through SCHEDULER_DISPATCH_TOKEN. + """ + dispatch_token = os.environ.get("SCHEDULER_DISPATCH_TOKEN") + if not dispatch_token or dispatch_token == os.environ.get("GH_TOKEN"): + return None + env = os.environ.copy() + env["GH_TOKEN"] = dispatch_token + return env + + +def run_github_dispatch(args: Sequence[str], *, stdin: str | None = None) -> str: + """Run a repository dispatch command with the dispatch token when configured.""" + env = scheduler_dispatch_env() + if env is None: + return run_github_actions(args, stdin=stdin) + return run_with_env(args, stdin=stdin, env=env) + + +def split_repo(repo: str) -> tuple[str, str]: + """Split an owner/name repository string into owner and repository name.""" + try: + owner, name = repo.split("/", 1) + except ValueError as exc: + raise ValueError(f"repo must be owner/name, got {repo!r}") from exc + if not owner or not name: + raise ValueError(f"repo must be owner/name, got {repo!r}") + return owner, name + + +def validate_git_ref(ref: str) -> str: + """Return a conservative Git ref name for gh workflow dispatch fields.""" + if ( + not isinstance(ref, str) + or not ref + or not GIT_REF_RE.fullmatch(ref) + or ref == "HEAD" + or ref.startswith("/") + or ref.endswith(("/", ".")) + or "@{" in ref + or ".." in ref + or "//" in ref + ): + raise ValueError(f"invalid git ref: {ref!r}") + if any(part == "." or part.startswith(".") for part in ref.split("/")): + raise ValueError(f"invalid git ref: {ref!r}") + return ref + + +def validate_git_sha(sha: str) -> str: + """Return a 40-character hex SHA for head-guarded GitHub operations.""" + if not isinstance(sha, str) or not GIT_SHA_RE.fullmatch(sha): + raise ValueError(f"invalid git sha: {sha!r}") + return sha + + +def validate_github_repository(repo: str) -> str: + """Return a GitHub owner/repository name safe to pass to gh.""" + if not isinstance(repo, str) or not GITHUB_REPOSITORY_RE.fullmatch(repo): + raise ValueError(f"invalid GitHub repository: {repo!r}") + return repo + + +def validated_pr_dispatch_fields(pr: dict[str, Any]) -> tuple[str, str, str]: + """Return validated base ref, base SHA, and head SHA for workflow dispatch.""" + return ( + validate_git_ref(pr["baseRefName"]), + validate_git_sha(pr["baseRefOid"]), + validate_git_sha(pr["headRefOid"]), + ) + + +def repository_dispatch_target(repo: str) -> str: + """Return the default-branch repository that receives review dispatch events. + + Organization required workflows are sourced from ContextualWisdomLab/.github, + while most target repositories deliberately do not keep repo-local workflow + copies. GitHub evaluates ``repository_dispatch`` only from the receiver's + default branch, so callers cannot select a privileged workflow ref. + """ + target_repo = validate_github_repository(repo) + dispatch_repo = (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip() + if not dispatch_repo: + return target_repo + return validate_github_repository(dispatch_repo) + + +def env_flag_enabled(name: str) -> bool: + """Return whether an environment flag is explicitly truthy.""" + return (os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"} + + +def repository_dispatch_wait_reason(repo: str, workflow: str) -> str | None: + """Explain why cross-repository required repository dispatch should wait.""" + target_repo = validate_github_repository(repo) + dispatch_repo = (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip() + if not dispatch_repo: + return None + dispatch_repo = validate_github_repository(dispatch_repo) + if dispatch_repo == target_repo or env_flag_enabled("SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH"): + return None + execution_repo = (os.environ.get("GITHUB_REPOSITORY") or "").strip() + if os.environ.get("SCHEDULER_DISPATCH_TOKEN") and execution_repo == dispatch_repo: + # The dispatch targets the repository this scheduler run executes in and the + # workflow provided a dispatch-capable runner token for it, so no + # cross-repository credential is needed. + return None + return ( + f"{workflow} dispatch waits for central required workflow materialization; " + f"required workflow source is {dispatch_repo}, but this scheduler run has no " + "cross-repository repository-dispatch credential. Wait for the organization required " + "workflow to materialize, or rerun the same-head target-repository job after GitHub " + "exposes it in the PR check rollup." + ) + + +TRANSIENT_GITHUB_API_ERRORS = ( + "HTTP 500", + "HTTP 502", + "HTTP 503", + "HTTP 504", + "connection reset", + "connection refused", + "connection timed out", + "context deadline exceeded", + "gateway timeout", + "i/o timeout", + "server error", + "service unavailable", + "stream error", + "temporary failure", + "timeout", + "unexpected end of JSON input", + "unexpected EOF", + "received from peer", +) + + +def is_transient_github_api_error(exc: Exception) -> bool: + """Return whether a GitHub API failure is worth retrying in the same run.""" + if isinstance(exc, json.JSONDecodeError): + return True + message = str(exc) + folded = message.lower() + return any(marker in message or marker.lower() in folded for marker in TRANSIENT_GITHUB_API_ERRORS) + + +def gh_graphql(query: str, **fields: str | int) -> dict[str, Any]: + """Run a GitHub GraphQL query through gh and decode the JSON response.""" + cmd = ["gh", "api", "graphql", "-F", "query=@-"] + for key, value in fields.items(): + flag = "-F" if isinstance(value, int) else "-f" + cmd.extend([flag, f"{key}={value}"]) + max_attempts = 4 + for attempt in range(1, max_attempts + 1): # pragma: no branch - last failed attempt always raises + try: + return json.loads(run_github_read(cmd, stdin=query)) + except (RuntimeError, json.JSONDecodeError) as exc: + if attempt >= max_attempts or not is_transient_github_api_error(exc): + raise + delay = min(2 ** (attempt - 1), 8) + print( + f"Transient GitHub GraphQL error on attempt {attempt}/{max_attempts}; retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + + +def github_resource_inaccessible(exc: RuntimeError) -> bool: + """Return whether GitHub denied an API read for the current integration token.""" + + return "Resource not accessible by integration" in str(exc) + + +def gh_api_json(path: str) -> Any: + """Run a GitHub REST API request through gh and decode the JSON response.""" + + return json.loads(run_github_read(["gh", "api", path])) + + +def rest_review_node(review: dict[str, Any]) -> dict[str, Any]: + """Convert a REST review payload into the GraphQL shape used by the scheduler.""" + + commit_id = review.get("commit_id") + return { + "databaseId": review.get("id"), + "state": review.get("state"), + "body": review.get("body"), + "submittedAt": review.get("submitted_at"), + "author": {"login": ((review.get("user") or {}).get("login"))}, + "commit": {"oid": commit_id} if commit_id else None, + } + + +def rest_check_node(check: dict[str, Any]) -> dict[str, Any]: + """Convert a REST check-run payload into the GraphQL status rollup shape.""" + + return { + "__typename": "CheckRun", + "name": check.get("name"), + "status": (check.get("status") or "").upper(), + "conclusion": (check.get("conclusion") or "").upper() if check.get("conclusion") else None, + "startedAt": check.get("started_at"), + "detailsUrl": check.get("details_url"), + "checkSuite": {"workflowRun": {"workflow": {}}}, + } + + +def rest_pr_node(repo: str, pr: dict[str, Any]) -> dict[str, Any]: + """Convert a REST pull request payload into the GraphQL shape used by the scheduler.""" + + number = int(pr["number"]) + head = pr.get("head") or {} + base = pr.get("base") or {} + head_repo = head.get("repo") or {} + reviews = gh_api_json(f"repos/{repo}/pulls/{number}/reviews?per_page=100") + checks = gh_api_json(f"repos/{repo}/commits/{head.get('sha')}/check-runs?per_page=100") + files = gh_api_json(f"repos/{repo}/pulls/{number}/files?per_page=20") + rest_merge_state = REST_MERGEABLE_STATE_MAP.get( + str(pr.get("mergeable_state") or "").lower(), + str(pr.get("mergeable_state") or "").upper(), + ) + return { + "number": number, + "title": pr.get("title"), + "isDraft": bool(pr.get("draft")), + "mergeable": pr.get("mergeable"), + "mergeStateStatus": rest_merge_state, + "reviewDecision": "REVIEW_REQUIRED", + "baseRefName": base.get("ref"), + "baseRefOid": base.get("sha"), + "headRefName": head.get("ref"), + "headRefOid": head.get("sha"), + "isCrossRepository": (head_repo.get("full_name") or repo).lower() != repo.lower(), + "maintainerCanModify": bool(pr.get("maintainer_can_modify")), + "headRepository": {"nameWithOwner": head_repo.get("full_name") or repo}, + "autoMergeRequest": pr.get("auto_merge"), + "reviewThreads": {"nodes": []}, + "files": {"nodes": [{"path": file.get("filename")} for file in files if file.get("filename")]}, + "reviews": {"nodes": [rest_review_node(review) for review in reviews]}, + "statusCheckRollup": { + "contexts": { + "nodes": [ + rest_check_node(check) + for check in (checks.get("check_runs") or []) + ] + } + }, + "restMergeableState": rest_merge_state, + } + + +def fetch_open_prs_rest(repo: str, max_prs: int, base_branch: str | None = None) -> list[dict[str, Any]]: + """Fetch open pull requests through REST when GraphQL is unavailable.""" + + prs: list[dict[str, Any]] = [] + page = 1 + while len(prs) < max_prs: + page_size = min(100, max_prs - len(prs)) + path = ( + f"repos/{repo}/pulls?state=open&sort=created&direction=asc" + f"&per_page={page_size}&page={page}" + ) + if base_branch: + path += f"&base={quote(base_branch, safe='')}" + payload = gh_api_json(path) + if not payload: + break + if len(payload) <= 1: + prs.extend(rest_pr_node(repo, pr) for pr in payload) # pragma: no cover + else: + max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(payload)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + # Keep original API sort order + prs.extend(list(executor.map(lambda pr: rest_pr_node(repo, pr), payload))) + if len(payload) < page_size: + break + page += 1 + return prs[:max_prs] + + +def fetch_pr_rest(repo: str, number: int) -> list[dict[str, Any]]: + """Fetch one pull request through REST when GraphQL is unavailable.""" + + pr = gh_api_json(f"repos/{repo}/pulls/{number}") + return [rest_pr_node(repo, pr)] if pr else [] + + +def fetch_open_prs(repo: str, max_prs: int) -> list[dict[str, Any]]: + """Fetch open pull requests from GitHub, paginating up to max_prs.""" + owner, name = split_repo(repo) + prs: list[dict[str, Any]] = [] + cursor: str | None = None + + while len(prs) < max_prs: + page_size = min(OPEN_PRS_PAGE_SIZE, max_prs - len(prs)) + fields: dict[str, str | int] = { + "owner": owner, + "name": name, + "pageSize": page_size, + } + if cursor: + fields["cursor"] = cursor + try: + payload = gh_graphql(OPEN_PRS_QUERY, **fields) + except RuntimeError as exc: + if github_resource_inaccessible(exc) or is_transient_github_api_error(exc): + return fetch_open_prs_rest(repo, max_prs) + raise + pr_page = payload["data"]["repository"]["pullRequests"] + prs.extend(pr_page.get("nodes") or []) + if not pr_page["pageInfo"]["hasNextPage"]: + break + cursor = pr_page["pageInfo"]["endCursor"] + + enrich_rest_mergeable_states(repo, prs) + return prs + + +def fetch_pr(repo: str, number: int) -> list[dict[str, Any]]: + """Fetch one pull request by number using the same evidence shape as the queue scan.""" + owner, name = split_repo(repo) + try: + payload = gh_graphql(PR_BY_NUMBER_QUERY, owner=owner, name=name, number=number) + except RuntimeError as exc: + if github_resource_inaccessible(exc) or is_transient_github_api_error(exc): + return fetch_pr_rest(repo, number) + raise + pr = payload["data"]["repository"].get("pullRequest") + prs = [pr] if pr else [] + enrich_rest_mergeable_states(repo, prs) + return prs + + +def fetch_rest_mergeable_state(repo: str, number: int) -> str: + """Fetch and normalize GitHub REST mergeable_state for one pull request.""" + raw_state = run( + [ + "gh", + "api", + f"repos/{repo}/pulls/{number}", + "--jq", + ".mergeable_state // \"\"", + ] + ).strip() + return REST_MERGEABLE_STATE_MAP.get(raw_state.lower(), raw_state.upper()) + + +def compare_ref_for_pr_head(repo: str, pr: dict[str, Any]) -> str: + """Return the compare-API head ref for a PR branch.""" + head_ref = pr.get("headRefName") or "HEAD" + head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") + if not head_repo or head_repo == repo: + return head_ref + head_owner, _ = split_repo(head_repo) + return f"{head_owner}:{head_ref}" + + +def fetch_compare_branch_freshness(repo: str, pr: dict[str, Any]) -> dict[str, Any]: + """Fetch compare evidence showing whether the PR head lacks base commits.""" + base = quote(pr.get("baseRefName") or "base", safe="") + head = quote(compare_ref_for_pr_head(repo, pr), safe=":") + return json.loads( + run( + [ + "gh", + "api", + f"repos/{repo}/compare/{base}...{head}", + ] + ) + ) + + +def enrich_rest_mergeable_states(repo: str, prs: list[dict[str, Any]]) -> None: + """Attach REST mergeability evidence to GraphQL pull request payloads.""" + + def enrich(pr: dict[str, Any]) -> None: + """Attach REST mergeability evidence to one pull request payload.""" + try: + pr["restMergeableState"] = fetch_rest_mergeable_state(repo, int(pr["number"])) + except RuntimeError as exc: + pr["restMergeableStateError"] = bounded_error_summary(str(exc)) + try: + compare = fetch_compare_branch_freshness(repo, pr) + pr["compareStatus"] = compare.get("status") + pr["compareBehindBy"] = compare.get("behind_by") + except RuntimeError as exc: + pr["compareBranchFreshnessError"] = bounded_error_summary(str(exc)) + + if not prs: + return + + if len(prs) <= 1: + for pr in prs: + enrich(pr) + return + + max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(prs)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + for _ in executor.map(enrich, prs): + pass + + +def effective_merge_state(pr: dict[str, Any]) -> str: + """Return the safest merge state from GraphQL plus REST mergeability evidence.""" + graph_state = (pr.get("mergeStateStatus") or "").upper() + rest_state = (pr.get("restMergeableState") or "").upper() + if rest_state in REST_MERGEABLE_STATES: + return rest_state + if graph_state in {"BEHIND", "DIRTY", "CONFLICTING", "UNKNOWN"}: + return graph_state + return rest_state or graph_state + + +def compare_behind_by(pr: dict[str, Any]) -> int: + """Return the compare API's behind_by count as a safe integer.""" + behind_by = pr.get("compareBehindBy") + if isinstance(behind_by, int): + return max(0, behind_by) + if isinstance(behind_by, str) and behind_by.isdigit(): + return int(behind_by) + return 0 + + +def branch_outdated_by_base(pr: dict[str, Any], merge_state: str) -> int: + """Return known count of base commits missing from the PR head.""" + compare_status = (pr.get("compareStatus") or "").lower() + if merge_state == "BEHIND" or compare_status == "behind": + return max(1, compare_behind_by(pr)) + return compare_behind_by(pr) + + +def context_nodes(pr: dict[str, Any]) -> list[dict[str, Any]]: + """Return status rollup context nodes for a pull request payload.""" + rollup = pr.get("statusCheckRollup") or {} + contexts = rollup.get("contexts") or {} + return contexts.get("nodes") or [] + + +def is_opencode_context(node: dict[str, Any]) -> bool: + """Return whether a check or status context belongs to OpenCode Review.""" + if node.get("__typename") == "CheckRun": + if (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip(): + # Central reviews run through repository_dispatch and publish a commit + # status. Organization required-workflow CheckRuns are deliberately + # non-authoritative placeholders and must not suppress that dispatch. + return False + workflow = ( + ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") + or {} + ) + return node.get("name") == "opencode-review" or workflow.get("name") in OPENCODE_WORKFLOW_NAMES + return node.get("context") == "opencode-review" + + +def is_strix_context(node: dict[str, Any]) -> bool: + """Return whether a check or status context belongs to Strix evidence.""" + if node.get("__typename") == "CheckRun": + workflow = ( + ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") + or {} + ) + workflow_name = workflow.get("name") + return workflow_name in {"Strix Security Scan", "Strix"} or ( + node.get("name") == "strix" and workflow_name is None + ) + return (node.get("context") or "") in {"strix", "Strix Security Scan"} + + +def actions_job_id_from_details_url(value: str | None) -> str | None: + """Return a GitHub Actions job id from a check-run details URL.""" + if not value: + return None + match = ACTIONS_JOB_DETAILS_URL_RE.search(value) + return match.group(1) if match else None + + +def matching_actions_job_id(pr: dict[str, Any], predicate: Any) -> str | None: + """Return the latest matching check-run job id, if GitHub exposed one.""" + for node in reversed(context_nodes(pr)): + if node.get("__typename") != "CheckRun" or not predicate(node): + continue + job_id = actions_job_id_from_details_url(node.get("detailsUrl")) + if job_id: + return job_id + return None + + +def parse_github_datetime(value: str | None) -> datetime | None: + """Parse a GitHub API timestamp into an aware UTC datetime.""" + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def review_matches_current_head(review: dict[str, Any], pr: dict[str, Any]) -> bool: + """Return whether a review is valid evidence for the current head commit.""" + head = pr.get("headRefOid") + commit = (review.get("commit") or {}).get("oid") + if not head: + return False + body_head = review_body_head_sha(review) + if commit == head: + return body_head is None or body_head.lower() == head.lower() + if not commit and body_head is not None: + return body_head.lower() == head.lower() + return False + + +def review_body_head_sha(review: dict[str, Any]) -> str | None: + """Return the last explicit Head SHA from an OpenCode review body.""" + body = review.get("body") or "" + matches = REVIEW_BODY_HEAD_SHA_RE.findall(body) + return matches[-1] if matches else None + + +def running_check_state(node: dict[str, Any]) -> str: + """Return running, complete, or absent for a check/status context.""" + status = (node.get("status") or node.get("state") or "").upper() + if not status: + return "absent" + return "running" if status in RUNNING_CHECK_STATES else "complete" + + +def opencode_progress_state( + pr: dict[str, Any], + *, + stale_after_minutes: int, + now: datetime | None = None, +) -> str: + """Return absent, running, stale, or complete for current OpenCode review status.""" + now = now or datetime.now(timezone.utc) + saw_complete = False + for node in context_nodes(pr): + if not is_opencode_context(node): + continue + state = running_check_state(node) + if state == "absent": + continue + if state != "running": + saw_complete = True + continue + started_at = parse_github_datetime(node.get("startedAt")) + if started_at and stale_after_minutes >= 0: + age_seconds = (now - started_at).total_seconds() + if age_seconds >= stale_after_minutes * 60: + return "stale" + return "running" + return "complete" if saw_complete else "absent" + + +def opencode_in_progress(pr: dict[str, Any], *, stale_after_minutes: int | None = None) -> bool: + """Return whether any OpenCode review status for the PR is still actively running.""" + stale_after = DEFAULT_STALE_OPENCODE_MINUTES if stale_after_minutes is None else stale_after_minutes + return opencode_progress_state(pr, stale_after_minutes=stale_after) == "running" + + +def strix_evidence_state(pr: dict[str, Any]) -> str: + """Return missing, running, or complete for current-head Strix evidence.""" + found = False + for node in context_nodes(pr): + if not is_strix_context(node): + continue + found = True + status = (node.get("status") or node.get("state") or "").upper() + if status in RUNNING_CHECK_STATES: + return "running" + if node.get("__typename") == "CheckRun" and status != "COMPLETED": + return "running" + return "complete" if found else "missing" + + +def unresolved_thread_count(pr: dict[str, Any]) -> int: + """Count active, non-outdated unresolved review threads on a PR.""" + threads = ((pr.get("reviewThreads") or {}).get("nodes") or []) + return sum(1 for thread in threads if not thread.get("isResolved") and not thread.get("isOutdated")) + + +def outdated_thread_ids(pr: dict[str, Any]) -> list[str]: + """Return unresolved review-thread IDs GitHub already marks outdated.""" + threads = ((pr.get("reviewThreads") or {}).get("nodes") or []) + return [ + thread["id"] + for thread in threads + if thread.get("id") and not thread.get("isResolved") and thread.get("isOutdated") + ] + + +def resolve_review_thread(thread_id: str) -> None: + """Resolve one GitHub review thread by GraphQL node ID.""" + gh_graphql(RESOLVE_REVIEW_THREAD_MUTATION, threadId=thread_id) + + +def resolve_outdated_review_threads(pr: dict[str, Any], *, dry_run: bool) -> int: + """Resolve obsolete diff conversations before active-thread merge checks.""" + thread_ids = outdated_thread_ids(pr) + if not thread_ids: + return 0 + if dry_run: + return len(thread_ids) + require_github_actions_mutation_actor("resolve-outdated-review-thread") + if len(thread_ids) <= 1: + for thread_id in thread_ids: # pragma: no cover + resolve_review_thread(thread_id) # pragma: no cover + else: + max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(thread_ids)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + list(executor.map(resolve_review_thread, thread_ids)) + return len(thread_ids) + + +def with_outdated_thread_cleanup_note(decision: Decision, count: int, *, dry_run: bool) -> Decision: + """Annotate a decision with the outdated-thread cleanup side effect.""" + if count <= 0: + return decision + verb = "Would resolve" if dry_run else "Resolved" + note = ( + f"{verb} {count} outdated review thread(s) before active unresolved-thread checks; " + "outdated diff comments are not current-head review blockers." + ) + return Decision(decision.pr, decision.action, decision.reason, (*decision.notes, note)) + + +def review_author_login(review: dict[str, Any]) -> str: + """Return a normalized review author login.""" + return ((review.get("author") or {}).get("login") or "").lower() + + +def is_opencode_review(review: dict[str, Any]) -> bool: + """Return whether a review was authored by the OpenCode agent.""" + return review_author_login(review) in {"opencode-agent", "opencode-agent[bot]"} + + +def is_legacy_actions_opencode_review(review: dict[str, Any]) -> bool: + """Return whether a legacy Actions-authored review contains OpenCode evidence.""" + login = review_author_login(review) + return login in {"github-actions", "github-actions[bot]"} and "opencode" in ( + review.get("body") or "" + ).lower() + + +def is_automated_opencode_review(review: dict[str, Any]) -> bool: + """Return whether a review is OpenCode automation evidence, including legacy writes.""" + return is_opencode_review(review) or is_legacy_actions_opencode_review(review) + + +def is_deterministic_fallback_approval(review: dict[str, Any]) -> bool: + """Return whether an old fail-open approval body is not review evidence.""" + if (review.get("state") or "").upper() != "APPROVED": + return False + body = (review.get("body") or "").lower() + return any(marker in body for marker in DETERMINISTIC_APPROVAL_MARKERS) + + +def has_current_head_deterministic_fallback_approval(pr: dict[str, Any]) -> bool: + """Return whether OpenCode's latest current-head review is fallback-only.""" + for review in reversed((pr.get("reviews") or {}).get("nodes") or []): + if not is_opencode_review(review): + continue + if not review_matches_current_head(review, pr): + continue + return is_deterministic_fallback_approval(review) + return False + + +def current_head_review_state(pr: dict[str, Any], state: str) -> bool: + """Return whether OpenCode's latest current-head review has the target state.""" + target_state = state.upper() + for review in reversed((pr.get("reviews") or {}).get("nodes") or []): + if not is_opencode_review(review): + continue + if not review_matches_current_head(review, pr): + continue + if target_state == "APPROVED" and is_deterministic_fallback_approval(review): + return False + return (review.get("state") or "").upper() == target_state + return False + + +def has_current_head_approval(pr: dict[str, Any]) -> bool: + """Return whether OpenCode approved the exact current head commit.""" + return current_head_review_state(pr, "APPROVED") + + +def has_current_head_changes_requested(pr: dict[str, Any]) -> bool: + """Return whether OpenCode requested changes on the exact current head.""" + return current_head_review_state(pr, "CHANGES_REQUESTED") + + +def stale_opencode_change_request_ids(pr: dict[str, Any]) -> list[int]: + """Return dismissible automated change requests tied to previous heads.""" + review_ids: list[int] = [] + for review in (pr.get("reviews") or {}).get("nodes") or []: + if (review.get("state") or "").upper() != "CHANGES_REQUESTED": + continue + if review_matches_current_head(review, pr): + continue + if not is_automated_opencode_review(review): + continue + review_id = review.get("databaseId") + if isinstance(review_id, int) and review_id > 0: + review_ids.append(review_id) + return review_ids + + +def stale_opencode_approval_ids(pr: dict[str, Any]) -> list[int]: + """Return active automated approvals whose evidence is not for the live head. + + GitHub evaluates the latest review from each author. Older review objects may + remain ``APPROVED`` after a later same-author review supersedes them, and the + dismissal API treats those historical objects as no-ops. Inspect only the + latest OpenCode review per automation identity so cleanup targets effective + policy state rather than immutable review history. + """ + latest_by_author: dict[str, dict[str, Any]] = {} + for review in (pr.get("reviews") or {}).get("nodes") or []: + if not is_automated_opencode_review(review): + continue + latest_by_author[review_author_login(review)] = review + + review_ids: list[int] = [] + for review in latest_by_author.values(): + if (review.get("state") or "").upper() != "APPROVED": + continue + if review_matches_current_head(review, pr): + continue + review_id = review.get("databaseId") + if isinstance(review_id, int) and review_id > 0: + review_ids.append(review_id) + return review_ids + + +def dismiss_pull_request_review( + repo: str, + number: str, + review_id: int, + *, + message: str, +) -> bool: + """Dismiss one review and verify GitHub actually changed its state.""" + try: + run( + [ + "gh", + "api", + "-X", + "PUT", + f"repos/{repo}/pulls/{number}/reviews/{review_id}/dismissals", + "-f", + f"message={message}", + ] + ) + live_state = run_github_read( + [ + "gh", + "api", + f"repos/{repo}/pulls/{number}/reviews/{review_id}", + "--jq", + ".state", + ] + ).strip().upper() + except RuntimeError as exc: + print( + "::warning::Stale OpenCode review dismissal failed for " + f"PR #{number} review {review_id}: {scrub_sensitive_data(str(exc))}" + ) + return False + if live_state == "DISMISSED": + return True + print( + "::warning::GitHub accepted stale OpenCode review dismissal for " + f"PR #{number} review {review_id}, but the verified review state is " + f"{live_state or ''}; the review remains non-authoritative unless its explicit " + "Head SHA matches the live PR head." + ) + return False + + +def dismiss_stale_opencode_approvals( + repo: str, + pr: dict[str, Any], + *, + dry_run: bool, +) -> tuple[int, int]: + """Dismiss latest automated approvals that do not match the exact live head.""" + review_ids = stale_opencode_approval_ids(pr) + if not review_ids: + return 0, 0 + if dry_run: + return len(review_ids), 0 + + require_github_actions_mutation_actor("dismiss-stale-opencode-approval") + repo = validate_github_repository(repo) + number = str(int(pr["number"])) + expected_head = validate_git_sha(pr["headRefOid"]) + live_head = run_github_read( + ["gh", "api", f"repos/{repo}/pulls/{number}", "--jq", ".head.sha"] + ).strip() + if live_head != expected_head: + raise RuntimeError( + "PR head changed before stale approval dismissal; " + f"expected {expected_head}, observed {live_head or ''}" + ) + + dismissed = 0 + for review_id in review_ids: + message = ( + "Superseded automated OpenCode approval whose explicit review evidence does not match " + f"exact current head {expected_head}; a fresh current-head review is required." + ) + if dismiss_pull_request_review(repo, number, review_id, message=message): + dismissed += 1 + return dismissed, len(review_ids) - dismissed + + +def stale_approval_cleanup_note(dismissed: int, retained: int, *, dry_run: bool) -> str | None: + """Render exact stale-approval cleanup evidence for scheduler logs.""" + notes: list[str] = [] + if dismissed: + verb = "would dismiss" if dry_run else "dismissed" + notes.append(f"{verb} {dismissed} latest previous-head automated OpenCode approval(s)") + if retained: + notes.append( + f"GitHub retained {retained} stale automated approval(s) after dismissal attempts; " + "their head evidence remains non-authoritative" + ) + return "; ".join(notes) if notes else None + + +def dismiss_stale_opencode_change_requests(repo: str, pr: dict[str, Any], *, dry_run: bool) -> int: + """Dismiss previous-head automated gates only after exact-head approval.""" + if not has_current_head_approval(pr): + return 0 + review_ids = stale_opencode_change_request_ids(pr) + if not review_ids: + return 0 + if dry_run: + return len(review_ids) + + require_github_actions_mutation_actor("dismiss-stale-opencode-review") + repo = validate_github_repository(repo) + number = str(int(pr["number"])) + expected_head = validate_git_sha(pr["headRefOid"]) + live_head = run_github_read( + ["gh", "api", f"repos/{repo}/pulls/{number}", "--jq", ".head.sha"] + ).strip() + if live_head != expected_head: + raise RuntimeError( + "PR head changed before stale review dismissal; " + f"expected {expected_head}, observed {live_head or ''}" + ) + + for review_id in review_ids: + message = ( + "Superseded automated OpenCode change request from a previous head; " + f"exact current head {expected_head} has a later OpenCode approval." + ) + run( + [ + "gh", + "api", + "-X", + "PUT", + f"repos/{repo}/pulls/{number}/reviews/{review_id}/dismissals", + "-f", + f"message={message}", + ] + ) + return len(review_ids) + + +def failed_status_checks(pr: dict[str, Any]) -> list[str]: + """Return failing check or status context names from the PR rollup.""" + failed: list[str] = [] + latest_check_runs: dict[ + tuple[str, str], + tuple[datetime | None, int, dict[str, Any]], + ] = {} + status_contexts: list[dict[str, Any]] = [] + for index, node in enumerate(context_nodes(pr)): + if node.get("__typename") != "CheckRun": + status_contexts.append(node) + continue + workflow = ( + (((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {}).get("name") + or "" + ) + key = (workflow, node.get("name") or "check-run") + started_at = parse_github_datetime(node.get("startedAt")) + previous = latest_check_runs.get(key) + if previous is None: + latest_check_runs[key] = (started_at, index, node) + continue + previous_started_at, previous_index, _ = previous + if started_at is None and previous_started_at is not None: + continue + if previous_started_at is None and started_at is not None: + latest_check_runs[key] = (started_at, index, node) + continue + if (started_at or datetime.min.replace(tzinfo=timezone.utc), index) >= ( + previous_started_at or datetime.min.replace(tzinfo=timezone.utc), + previous_index, + ): + latest_check_runs[key] = (started_at, index, node) + + successful_status_contexts = { + node.get("context") + for node in status_contexts + if (node.get("state") or "").upper() == "SUCCESS" + } + for _, _, node in sorted(latest_check_runs.values(), key=lambda item: item[1]): + conclusion = (node.get("conclusion") or "").upper() + if conclusion in FAILED_CHECK_CONCLUSIONS: + if is_strix_context(node) and "strix" in successful_status_contexts: + continue + if is_opencode_context(node) and "opencode-review" in successful_status_contexts: + continue + failed.append(node.get("name") or "check-run") + for node in status_contexts: + state = (node.get("state") or "").upper() + if state in {"FAILURE", "ERROR"}: + failed.append(node.get("context") or "status-context") + return failed + + +def action_required_checks(pr: dict[str, Any]) -> list[str]: + """Return check-run names that need explicit GitHub Actions approval or unblocking.""" + required: list[str] = [] + for node in context_nodes(pr): + if node.get("__typename") != "CheckRun": + continue + conclusion = (node.get("conclusion") or "").upper() + if conclusion in ACTION_REQUIRED_CONCLUSIONS: + required.append(node.get("name") or "check-run") + return required + + +def workflow_action_required_reason(checks: list[str]) -> str: + """Return a scheduler reason for ACTION_REQUIRED check runs.""" + visible = checks[:5] + suffix = f", +{len(checks) - len(visible)} more" if len(checks) > len(visible) else "" + return ( + f"workflow action required: {', '.join(visible)}{suffix}; " + "approve or unblock the GitHub Actions run before treating checks as failed or passed" + ) + + +def run_head_guarded_merge( + repo: str, + number: str, + head: str, + *, + auto: bool, +) -> None: + """Run a head-guarded merge using an allowed repository merge method.""" + args = ["gh", "pr", "merge", number, "--repo", repo] + if auto: + args.append("--auto") + args.extend(["--squash", "--match-head-commit", head]) + try: + run(args) + return + except RuntimeError as exc: + detail = str(exc).lower() + if not any(marker in detail for marker in SQUASH_MERGE_DISABLED_MARKERS): + raise + reason = str(exc).splitlines()[-1][:400] + + mode = "auto-merge" if auto else "direct merge" + print( + f"PR #{number}: squash is disabled; retrying {mode} with a merge commit " + f"at guarded head {head}. GitHub reason: {reason}" + ) + merge_args = ["gh", "pr", "merge", number, "--repo", repo] + if auto: + merge_args.append("--auto") + merge_args.extend(["--merge", "--match-head-commit", head]) + run(merge_args) + + +def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: + """Enable auto-merge for a PR at its current head using an allowed method.""" + number = str(pr["number"]) + if dry_run: + return + require_github_actions_mutation_actor("enable-auto-merge") + head = validate_git_sha(pr["headRefOid"]) + run_head_guarded_merge(repo, number, head, auto=True) + + +def merge_pr(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: + """Merge a current-head-approved PR immediately with a head guard.""" + number = str(pr["number"]) + if dry_run: + return + require_github_actions_mutation_actor("direct-merge") + head = validate_git_sha(pr["headRefOid"]) + run_head_guarded_merge(repo, number, head, auto=False) + + +def direct_merge_can_fallback_to_auto_merge(error: Exception) -> bool: + """Return whether a direct merge failure should queue auto-merge instead.""" + text = str(error).lower() + return any(marker in text for marker in DIRECT_MERGE_AUTO_FALLBACK_MARKERS) + + +def direct_merge_block_detail(error: Exception) -> str: + """Return the concrete GitHub merge refusal detail for scheduler logs.""" + lines = [line.strip() for line in str(error).splitlines() if line.strip()] + detail_lines = [ + line + for line in lines + if line.startswith(("X ", "gh:", "{")) + or "Repository rule violations found" in line + or "required" in line.lower() + or "prohibits the merge" in line.lower() + ] + if not detail_lines: + detail_lines = lines[-2:] + detail = " ".join(detail_lines) + return detail[:600] if detail else "GitHub did not return a merge refusal detail" + + +def disable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: + """Disable auto-merge when the current head no longer has fresh review evidence.""" + number = str(pr["number"]) + if dry_run: + return + require_github_actions_mutation_actor("disable-auto-merge") + 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"]) + if dry_run: + return + require_github_actions_mutation_actor("update-branch") + head = validate_git_sha(pr["headRefOid"]) + run( + [ + "gh", + "api", + "-X", + "PUT", + f"repos/{repo}/pulls/{number}/update-branch", + "-f", + f"expected_head_sha={head}", + ] + ) + + +def latest_commit_headline(pr: dict[str, Any]) -> str: + """Return the latest PR commit headline from the GraphQL payload.""" + commits = pr.get("commits") or {} + nodes = commits.get("nodes") or [] + if not nodes: + return "" + commit = nodes[-1].get("commit") or {} + return str(commit.get("messageHeadline") or "") + + +def head_already_restamped_for_last_push_approval(pr: dict[str, Any]) -> bool: + """Return whether the latest PR commit is the scheduler restamp commit.""" + return latest_commit_headline(pr) == LAST_PUSH_APPROVAL_RESTAMP_MESSAGE + + +def should_restamp_for_last_push_approval( + repo: str, + pr: dict[str, Any], + merge_state: str, + *, + current_head_approved: bool, + auto_merge_enabled: bool, +) -> bool: + """Return whether a BLOCKED approved PR likely needs a last-push approval restamp.""" + if merge_state != "BLOCKED": + return False + if not current_head_approved or not auto_merge_enabled: + return False + if not same_repository_head(repo, pr): + return False + if str(pr.get("reviewDecision") or "").upper() != "APPROVED": + return False + if strix_evidence_state(pr) != "complete": + return False + return branch_outdated_by_base(pr, merge_state) == 0 + + +def last_push_approval_block_reason() -> str: + """Return the explicit scheduler reason for suspected last-push approval blocking.""" + return ( + "current head is approved and auto-merge is queued, but GitHub mergeability is BLOCKED " + "while reviewDecision is APPROVED; likely require_last_push_approval cannot be satisfied " + "by the actor who pushed the current head" + ) + + +def restamp_pr_head_for_last_push_approval(repo: str, pr: dict[str, Any], *, dry_run: bool) -> str | None: + """Create a same-tree child commit and move the PR head with a force=false ref update.""" + if dry_run: + return None + require_github_actions_mutation_actor("last-push-approval-head-refresh") + repo = validate_github_repository(repo) + if not same_repository_head(repo, pr): + raise RuntimeError("last-push approval head refresh only supports same-repository PR heads") + + number = str(int(pr["number"])) + head = validate_git_sha(pr["headRefOid"]) + head_ref = validate_git_ref(pr["headRefName"]) + live_head = run(["gh", "api", f"repos/{repo}/pulls/{number}", "--jq", ".head.sha"]).strip() + if live_head != head: + raise RuntimeError( + "PR head changed before last-push approval head refresh; " + f"expected {head}, observed {live_head or ''}" + ) + + current_commit = json.loads(run(["gh", "api", f"repos/{repo}/git/commits/{head}"])) + tree = current_commit.get("tree") or {} + tree_sha = validate_git_sha(str(tree.get("sha") or "")) + created_commit = json.loads( + run( + ["gh", "api", "-X", "POST", f"repos/{repo}/git/commits", "--input", "-"], + stdin=json.dumps( + { + "message": LAST_PUSH_APPROVAL_RESTAMP_MESSAGE, + "tree": tree_sha, + "parents": [head], + } + ), + ) + ) + new_head = validate_git_sha(str(created_commit.get("sha") or "")) + run( + ["gh", "api", "-X", "PATCH", f"repos/{repo}/git/refs/heads/{head_ref}", "--input", "-"], + stdin=json.dumps({"sha": new_head, "force": False}), + ) + return new_head + + +def short_sha(value: str | None) -> str: + """Return a compact SHA for human-readable scheduler notes.""" + if not value: + return "" + return value[:12] + + +def wait_for_updated_branch_head( + repo: str, + pr: dict[str, Any], + *, + attempts: int = DEFAULT_UPDATE_BRANCH_HEAD_POLL_ATTEMPTS, + delay_seconds: float = DEFAULT_UPDATE_BRANCH_HEAD_POLL_SECONDS, +) -> dict[str, Any] | None: + """Poll GitHub after update-branch until the PR head or freshness evidence changes.""" + original_head = str(pr.get("headRefOid") or "") + attempts = max(1, attempts) + for attempt in range(attempts): + if attempt and delay_seconds > 0: + time.sleep(delay_seconds) + fresh_prs = fetch_pr(repo, int(pr["number"])) + if not fresh_prs: + continue + fresh_pr = fresh_prs[0] + fresh_head = str(fresh_pr.get("headRefOid") or "") + if fresh_head and fresh_head != original_head: + return fresh_pr + fresh_merge_state = effective_merge_state(fresh_pr) + if branch_outdated_by_base(fresh_pr, fresh_merge_state) <= 0: + return fresh_pr + return None + + +def post_update_branch_followup( + repo: str, + pr: dict[str, Any], + *, + dry_run: bool, + trigger_reviews: bool, + review_dispatch_allowed: bool, + workflow: str, + security_workflow: str, + stale_opencode_minutes: int, +) -> str | None: + """After update-branch, observe the new head and dispatch current-head evidence.""" + if dry_run: + return None + + original_head = str(pr.get("headRefOid") or "") + updated_pr = wait_for_updated_branch_head(repo, pr) + if updated_pr is None: + return ( + "update-branch was accepted, but the scheduler did not observe a refreshed PR head within " + "the poll window; the next scheduler run must re-read the PR before review or merge" + ) + + updated_head = str(updated_pr.get("headRefOid") or "") + if not updated_head or updated_head == original_head: + return ( + f"update-branch completed without a new head SHA (still {short_sha(original_head)}); " + "wait for GitHub to refresh branch-freshness and required-check evidence" + ) + + dismissed_approvals, retained_approvals = dismiss_stale_opencode_approvals( + repo, + updated_pr, + dry_run=dry_run, + ) + cleanup_note = stale_approval_cleanup_note( + dismissed_approvals, + retained_approvals, + dry_run=dry_run, + ) + head_note = f"updated head {short_sha(updated_head)} observed after update-branch" + if cleanup_note: + head_note = f"{head_note}; {cleanup_note}" + if not trigger_reviews: + return f"{head_note}; review dispatch is disabled for this scheduler run" + if not review_dispatch_allowed: + return f"{head_note}; review dispatch limit reached, so no same-head evidence workflow was dispatched" + + strix_state = strix_evidence_state(updated_pr) + if strix_state == "missing": + wait_reason = repository_dispatch_wait_reason(repo, security_workflow) + if wait_reason: + return f"{head_note}; {wait_reason}" + dispatch_strix_evidence(repo, security_workflow, updated_pr, dry_run=dry_run) + return ( + f"{head_note}; same-head Strix evidence dispatched because workflow-token branch updates " + "must not rely on a PR synchronize event to rerun evidence" + ) + if strix_state == "running": + return f"{head_note}; same-head Strix evidence is already running" + + opencode_state = opencode_progress_state(updated_pr, stale_after_minutes=stale_opencode_minutes) + if opencode_state == "running": + return f"{head_note}; same-head OpenCode review is already running" + + wait_reason = repository_dispatch_wait_reason(repo, workflow) + if wait_reason: + return f"{head_note}; {wait_reason}" + dispatch_result = dispatch_opencode_review(repo, workflow, updated_pr, dry_run=dry_run) + if dispatch_result == "already_running": + return f"{head_note}; same-head OpenCode workflow run is already active" + return f"{head_note}; same-head Strix evidence is complete, so OpenCode review was dispatched" + + +def same_repository_head(repo: str, pr: dict[str, Any]) -> bool: + """Return whether the PR head branch belongs to the repository being scanned.""" + head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") + return head_repo == repo + + +def can_update_pr_head(repo: str, pr: dict[str, Any]) -> bool: + """Return whether the scheduler may try to mutate the PR head branch.""" + if same_repository_head(repo, pr): + return True + return bool(pr.get("maintainerCanModify")) + + +def external_head_merge_reason(repo: str, pr: dict[str, Any]) -> str: + """Explain why the scheduler will not merge or auto-merge an external PR head.""" + head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") or "" + return ( + f"current-head OpenCode review approved, but head repo {head_repo} is external; " + "fork or external PR heads are excluded from scheduler direct merge and auto-merge. " + "A maintainer must merge manually after required checks, same-head OpenCode approval, " + "same-head Strix evidence, and unresolved-thread checks stay clean" + ) + + +def non_mutable_head_reason(repo: str, pr: dict[str, Any]) -> str: + """Explain why a PR can be reviewed but not mechanically updated.""" + head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") or "" + if same_repository_head(repo, pr): + return "current-head OpenCode review approved, but same-repository head update permission is unavailable" + return ( + f"current-head OpenCode review approved, but head repo {head_repo} is external and not writable by " + "the scheduler credential; ask the PR author to update the branch against the base branch, or enable " + "a maintainer-writable head path before rerunning" + ) + + +def require_github_actions_mutation_actor(action: str) -> None: + """Refuse mutating PR branches from a maintainer-local gh credential.""" + if os.environ.get("GITHUB_ACTIONS") != "true": + raise RuntimeError( + f"{action} refused outside GitHub Actions; dispatch PR Review Merge Scheduler " + "so the workflow mutation credential performs the guarded GitHub mutation" + ) + if not os.environ.get("GH_TOKEN"): + raise RuntimeError( + f"{action} refused without GH_TOKEN; configure the scheduler job to pass " + "PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, an OpenCode app token, or github.token through GH_TOKEN" + ) + + +def require_github_actions_control_actor(action: str) -> None: + """Refuse Actions rerun or dispatch calls without a workflow control token.""" + if os.environ.get("GITHUB_ACTIONS") != "true": + raise RuntimeError( + f"{action} refused outside GitHub Actions; dispatch PR Review Merge Scheduler " + "so the workflow actions credential performs the guarded GitHub Actions control call" + ) + if not os.environ.get("SCHEDULER_ACTIONS_TOKEN") and not os.environ.get("GH_TOKEN"): + raise RuntimeError( + f"{action} refused without SCHEDULER_ACTIONS_TOKEN or GH_TOKEN; configure the scheduler " + "job to pass github.token through SCHEDULER_ACTIONS_TOKEN for workflow rerun and dispatch calls" + ) + + +def rerun_actions_job(repo: str, job_id: str, *, dry_run: bool, action: str) -> None: + """Ask GitHub Actions to rerun an existing required-workflow job.""" + if dry_run: + return + require_github_actions_control_actor(action) + run_github_actions(["gh", "api", "-X", "POST", f"repos/{repo}/actions/jobs/{job_id}/rerun"]) + + +def active_workflow_runs(repo: str, statuses: Sequence[str] = ("queued", "in_progress")) -> list[dict[str, Any]]: + """Return active workflow runs for a repository.""" + runs: list[dict[str, Any]] = [] + for status in statuses: + payload = json.loads( + run_github_actions( + [ + "gh", + "api", + "--method", + "GET", + f"repos/{repo}/actions/runs", + "-f", + f"status={status}", + "-F", + "per_page=100", + ] + ) + ) + runs.extend(payload.get("workflow_runs") or []) + return runs + + +def workflow_run_mentions_pr(run_data: dict[str, Any], pr_number: int) -> bool: + """Return whether a workflow run is attached to the pull request number.""" + return any(pr.get("number") == pr_number for pr in run_data.get("pull_requests") or []) + + +def stale_pr_run_ids( + repo: str, + pr: dict[str, Any], + *, + workflow: str | None = None, + statuses: Sequence[str] = ("queued", "in_progress"), +) -> list[str]: + """Return active run ids for older heads of the same pull request.""" + head = str(pr.get("headRefOid") or "").lower() + number = int(pr["number"]) + stale: list[str] = [] + for run_data in active_workflow_runs(repo, statuses): + if workflow is not None and run_data.get("name") != workflow: + continue + if str(run_data.get("head_sha") or "").lower() == head: + continue + if not workflow_run_mentions_pr(run_data, number): + continue + run_id = run_data.get("id") + if run_id: + stale.append(str(run_id)) + return stale + + +def stale_opencode_run_ids(repo: str, workflow: str, pr: dict[str, Any]) -> list[str]: + """Return active OpenCode run ids for older heads of the same pull request.""" + _, stale = active_opencode_run_ids(repo, workflow, pr) + return stale + + +def active_review_run_refs( + repo: str, + workflow: str, + pr: dict[str, Any], + *, + run_title: str, + workflow_aliases: frozenset[str], + statuses: Sequence[str] = ("queued", "in_progress"), +) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: + """Return repository-qualified current and stale review workflow runs.""" + target_repo = validate_github_repository(repo) + dispatch_repo = repository_dispatch_target(target_repo) + centralized_dispatch = bool( + (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip() + ) + head = str(pr.get("headRefOid") or "").lower() + number = int(pr["number"]) + dispatch_title_prefixes = tuple( + f"{title} {target_repo}#{number}@" + for title in sorted({run_title, *workflow_aliases}, key=len, reverse=True) + ) + current: list[tuple[str, str]] = [] + stale: list[tuple[str, str]] = [] + + # Only the repository_dispatch receiver hosts the privileged review run. + # When organization required workflows are materialized in a target + # repository, their pull_request_target jobs are evidence placeholders and + # must not suppress the central authenticated reviewer. + for run_repo in (dispatch_repo,): + for run_data in active_workflow_runs(run_repo, statuses): + run_name = str(run_data.get("name") or "") + if run_name != workflow and run_name not in workflow_aliases: + continue + run_id = run_data.get("id") + if not run_id: + continue + run_ref = (run_repo, str(run_id)) + display_title = str(run_data.get("display_title") or "") + dispatch_title_prefix = next( + ( + prefix + for prefix in dispatch_title_prefixes + if display_title.startswith(prefix) + ), + None, + ) + if run_data.get("event") == "repository_dispatch" and dispatch_title_prefix: + dispatched_head = display_title.removeprefix(dispatch_title_prefix).lower() + if not GIT_SHA_RE.fullmatch(dispatched_head): + continue + (current if dispatched_head == head else stale).append(run_ref) + continue + if centralized_dispatch: + continue + run_head = str(run_data.get("head_sha") or "").lower() + pull_requests = run_data.get("pull_requests") or [] + if run_head == head: + if pull_requests and not workflow_run_mentions_pr(run_data, number): + continue + current.append(run_ref) + continue + if workflow_run_mentions_pr(run_data, number): + stale.append(run_ref) + return current, stale + + +def active_opencode_run_refs( + repo: str, + workflow: str, + pr: dict[str, Any], + statuses: Sequence[str] = ("queued", "in_progress"), +) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: + """Return repository-qualified current and stale OpenCode run references. + + A central ``repository_dispatch`` run executes at the receiver's default + branch SHA, not the target pull request SHA. Its protected workflow run-name + therefore carries the live-validated target repository, PR number, and head + SHA. Inspect both the target and central repositories so a scheduler pass can + suppress the same-head retry and cancel an older-head central run safely. + """ + return active_review_run_refs( + repo, + workflow, + pr, + run_title="Required OpenCode Review", + workflow_aliases=frozenset(OPENCODE_WORKFLOW_NAMES), + statuses=statuses, + ) + + +def active_opencode_run_ids( + repo: str, + workflow: str, + pr: dict[str, Any], + statuses: Sequence[str] = ("queued", "in_progress"), +) -> tuple[list[str], list[str]]: + """Return current-head and stale OpenCode run ids for one pull request. + + A repository-dispatch run can have an empty ``pull_requests`` array even + though its validated inputs target a PR. Treat a matching OpenCode workflow + name plus the exact current head SHA as sufficient current-head ownership; + otherwise require an explicit PR association before classifying a run as + stale. This prevents repeated scheduler passes from dispatching a new run + that cancels the already queued or running same-head review. + """ + current, stale = active_opencode_run_refs(repo, workflow, pr, statuses) + return [run_id for _, run_id in current], [run_id for _, run_id in stale] + + +def force_cancel_workflow_runs(repo: str, run_ids: Sequence[str]) -> dict[str, str]: + """Force-cancel workflow runs without blocking current-head decisions.""" + if not run_ids: + return {} + + def cancel_one(run_id: str) -> tuple[str, str | None]: + """Return one run id and its bounded GitHub cancellation error, if any.""" + try: + run_github_actions( + [ + "gh", + "api", + "-X", + "POST", + f"repos/{repo}/actions/runs/{run_id}/force-cancel", + ] + ) + except RuntimeError as exc: + return run_id, str(exc).replace("\n", "; ")[:600] + return run_id, None + + if len(run_ids) == 1: + results = [cancel_one(str(run_ids[0]))] + else: + max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(run_ids)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + results = list(executor.map(cancel_one, (str(run_id) for run_id in run_ids))) + + failures = {run_id: reason for run_id, reason in results if reason is not None} + for run_id, reason in failures.items(): + print( + "::warning::Could not force-cancel superseded workflow run " + f"{run_id}: {reason}. Continuing current-head processing; " + "the old-head run remains non-authoritative." + ) + return failures + + +def force_cancel_workflow_run_refs(run_refs: Sequence[tuple[str, str]]) -> None: + """Force-cancel repository-qualified runs while retaining bounded batches.""" + runs_by_repo: dict[str, list[str]] = {} + for run_repo, run_id in run_refs: + runs_by_repo.setdefault(run_repo, []).append(run_id) + for run_repo, run_ids in runs_by_repo.items(): + force_cancel_workflow_runs(run_repo, run_ids) + + +def cancel_stale_pr_runs(repo: str, pr: dict[str, Any], *, dry_run: bool) -> list[str]: + """Force-cancel queued or running workflows for older heads of the same PR.""" + if dry_run: + return [] + require_github_actions_control_actor("force-cancel-stale-pr-runs") + run_ids = stale_pr_run_ids(repo, pr) + force_cancel_workflow_runs(repo, run_ids) + return run_ids + + +def cancel_stale_opencode_runs(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> list[str]: + """Force-cancel older OpenCode runs for the same PR before retrying current head.""" + if dry_run: + return [] + require_github_actions_control_actor("force-cancel-stale-opencode-review") + _, stale_refs = active_opencode_run_refs(repo, workflow, pr) + force_cancel_workflow_run_refs(stale_refs) + return [run_id for _, run_id in stale_refs] + + +def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> str: + """Dispatch trusted OpenCode for the PR head, or report an active run. + + The review job is intentionally restricted to ``repository_dispatch``. A + check-run job exposed by the original ``pull_request_target`` workflow is + therefore not a reusable execution entrypoint: rerunning that job preserves + the original event and leaves the review job skipped. Always use the + default-branch dispatch entrypoint after same-head deduplication. + """ + if not dry_run: + require_github_actions_control_actor("inspect-active-opencode-review") + current_run_refs, stale_run_refs = active_opencode_run_refs(repo, workflow, pr) + force_cancel_workflow_run_refs(stale_run_refs) + if current_run_refs: + print( + "OpenCode review dispatch skipped: active same-head workflow run(s) " + + ", ".join( + f"{run_repo}@{run_id}" for run_repo, run_id in current_run_refs + ) + ) + return "already_running" + if dry_run: + return "dry_run" + base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr) + head_ref = validate_git_ref(pr["headRefName"]) + target_repo = validate_github_repository(repo) + dispatch_repo = repository_dispatch_target(target_repo) + run_github_dispatch( + [ + "gh", + "api", + "-X", + "POST", + f"repos/{dispatch_repo}/dispatches", + "--input", + "-", + ], + stdin=json.dumps( + { + "event_type": "opencode-review", + "client_payload": { + "target_repository": target_repo, + "pr_number": int(pr["number"]), + "pr_base_ref": base_ref, + "pr_base_sha": base_sha, + "pr_head_ref": head_ref, + "pr_head_sha": head_sha, + }, + } + ), + ) + return "dispatched" + + +def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> str: + """Dispatch same-head Strix workflow evidence before OpenCode reviews.""" + job_id = matching_actions_job_id(pr, is_strix_context) + if job_id: + rerun_actions_job(repo, job_id, dry_run=dry_run, action="rerun-strix-evidence") + return "rerun" if not dry_run else "dry_run" + if dry_run: + return "dry_run" + require_github_actions_control_actor("inspect-active-strix-evidence") + current_run_refs, stale_run_refs = active_review_run_refs( + repo, + workflow, + pr, + run_title="Strix Security Scan", + workflow_aliases=frozenset({"Strix Security Scan"}), + ) + force_cancel_workflow_run_refs(stale_run_refs) + if current_run_refs: + print( + "Strix evidence dispatch skipped: active same-head workflow run(s) " + + ", ".join( + f"{run_repo}@{run_id}" for run_repo, run_id in current_run_refs + ) + ) + return "already_running" + base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr) + target_repo = validate_github_repository(repo) + dispatch_repo = repository_dispatch_target(target_repo) + run_github_dispatch( + [ + "gh", + "api", + "-X", + "POST", + f"repos/{dispatch_repo}/dispatches", + "--input", + "-", + ], + stdin=json.dumps( + { + "event_type": "strix-scan", + "client_payload": { + "target_repository": target_repo, + "pr_number": int(pr["number"]), + "pr_base_ref": base_ref, + "pr_base_sha": base_sha, + "pr_head_sha": head_sha, + }, + } + ), + ) + return "dispatched" + + +def merge_conflict_guidance(pr: dict[str, Any], merge_state: str) -> str: + """Return actionable conflict repair guidance for a conflicting PR.""" + base_ref = pr.get("baseRefName") or "base" + head_ref = pr.get("headRefName") or "head" + changed_files = conflict_changed_files_text(pr) + changed_files_note = ( + f"changed files to inspect first: {changed_files}; " + if changed_files + else "" + ) + return ( + f"merge conflict: {merge_state}; base={base_ref}, head={head_ref}; " + f"{changed_files_note}" + f"run `gh pr checkout {pr.get('number', '')}`, `git fetch origin {base_ref}`, then " + f"`git merge --no-ff origin/{base_ref}` or `git rebase origin/{base_ref}`; " + "use `git status --short` to find conflicted files, resolve conflict markers in the PR branch, " + f"rerun focused checks, and push the same {head_ref} branch " + "(use `git push --force-with-lease` only if rebased); " + "do not retry update-branch until the conflict is repaired" + ) + + +def changed_file_paths(pr: dict[str, Any], *, limit: int = 10) -> list[str]: + """Return changed file paths already present in the pull request payload.""" + nodes = ((pr.get("files") or {}).get("nodes") or [])[:limit] + return [path for node in nodes if isinstance(path := node.get("path"), str) and path] + + +def conflict_changed_files_text(pr: dict[str, Any], *, limit: int = 10) -> str: + """Return compact changed-file guidance for conflict repair text.""" + paths = changed_file_paths(pr, limit=limit) + if not paths: + return "" + total = len(((pr.get("files") or {}).get("nodes") or [])) + suffix = f" | +{total - len(paths)} more" if total > len(paths) else "" + return " | ".join(paths) + suffix + + +def auto_merge_wait_reason(merge_state: str, pr: dict[str, Any] | None = None) -> str: + """Explain why an approved PR with auto-merge enabled is still waiting.""" + if merge_state == "CLEAN": + return "current head is approved; auto-merge already enabled" + if merge_state in {"DIRTY", "CONFLICTING"}: + return ( + "current head is approved and auto-merge is already enabled, " + "but conflict repair is required before GitHub can merge it" + ) + review_decision = str((pr or {}).get("reviewDecision") or "").upper() + review_policy_note = "" + if merge_state == "BLOCKED" and review_decision and review_decision != "APPROVED": + review_policy_note = ( + f" and GitHub reviewDecision is {review_decision}; required approving review, " + "code-owner review, or last-push approval policy is still unsatisfied" + ) + return ( + "current head is approved and auto-merge is already enabled, " + f"but GitHub mergeability is {merge_state}{review_policy_note}; wait for required workflows, rulesets, " + "or branch freshness to clear, then rerun the scheduler if GitHub does not merge it" + ) + + +def current_head_can_attempt_merge(pr: dict[str, Any], merge_state: str) -> bool: + """Return whether GitHub currently reports policy-clean mergeability.""" + if merge_state in {"DIRTY", "CONFLICTING", "UNKNOWN"}: + return False + if merge_state == "CLEAN": + return True + return False + + +def inspect_pr( + repo: str, + pr: dict[str, Any], + *, + dry_run: bool, + trigger_reviews: bool, + review_dispatch_allowed: bool = True, + branch_update_allowed: bool = True, + branch_update_limit: int = 1, + enable_auto_merge_flag: bool, + update_branches: bool, + workflow: str, + security_workflow: str, + base_branch: str, + merge_mode: str = "direct_or_auto", + stale_opencode_minutes: int = DEFAULT_STALE_OPENCODE_MINUTES, +) -> Decision: + """Decide and optionally act on one pull request's merge-readiness state.""" + number = pr["number"] + base_ref = pr.get("baseRefName") + + if pr.get("isDraft"): + return Decision(number, "skip", "draft PR") + cancel_stale_pr_runs(repo, pr, dry_run=dry_run) + if base_ref != base_branch: + # Stacked/cascade PR (base is another feature branch). Org required + # workflows are only injected for default-branch-target PRs, so these + # PRs never receive an OpenCode review on their own — dispatch one here. + # Merge automation stays default-branch-only; rulesets do not gate + # feature-branch merges. + opencode_state = opencode_progress_state(pr, stale_after_minutes=stale_opencode_minutes) + if opencode_state in {"absent", "stale"} and trigger_reviews and review_dispatch_allowed: + wait_reason = repository_dispatch_wait_reason(repo, workflow) + if wait_reason: + return Decision(number, "wait", f"stacked PR onto {base_ref}; {wait_reason}") + dispatch_result = dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) + if dispatch_result == "already_running": + return Decision( + number, + "wait", + f"stacked PR onto {base_ref}; same-head OpenCode workflow run is already active", + ) + return Decision( + number, + "review_dispatch", + f"stacked PR onto {base_ref}; OpenCode review dispatched", + ) + return Decision( + number, + "skip", + f"stacked PR onto {base_ref}; OpenCode review {opencode_state}", + ) + + outdated_cleanup_count = resolve_outdated_review_threads(pr, dry_run=dry_run) + stale_review_cleanup_count = 0 + stale_approval_cleanup_count, retained_stale_approval_count = dismiss_stale_opencode_approvals( + repo, + pr, + dry_run=dry_run, + ) + + def finish(decision: Decision) -> Decision: + """Attach obsolete review cleanup evidence to the final decision.""" + decision = with_outdated_thread_cleanup_note( + decision, + outdated_cleanup_count, + dry_run=dry_run, + ) + if stale_review_cleanup_count: + verb = "Would dismiss" if dry_run else "Dismissed" + note = ( + f"{verb} {stale_review_cleanup_count} previous-head automated OpenCode " + "change-request review(s); exact-current-head approval supersedes those stale gates." + ) + decision = Decision( + decision.pr, + decision.action, + decision.reason, + (*decision.notes, note), + ) + approval_note = stale_approval_cleanup_note( + stale_approval_cleanup_count, + retained_stale_approval_count, + dry_run=dry_run, + ) + if approval_note: + decision = Decision( + decision.pr, + decision.action, + decision.reason, + (*decision.notes, approval_note), + ) + return decision + + def decide(action: str, reason: str) -> Decision: + """Create a decision after applying shared cleanup notes.""" + return finish(Decision(number, action, reason)) + + def request_branch_update(freshness_reason: str, *, suffix: str = "") -> Decision: + """Request update-branch and attach any same-head evidence follow-up.""" + if not branch_update_allowed: + return decide( + "wait", + f"branch update limit reached ({branch_update_limit} update/run); " + "defer outdated branch to the next scheduler run", + ) + update_branch(repo, pr, dry_run=dry_run) + followup_note = post_update_branch_followup( + repo, + pr, + dry_run=dry_run, + trigger_reviews=trigger_reviews, + review_dispatch_allowed=review_dispatch_allowed, + workflow=workflow, + security_workflow=security_workflow, + stale_opencode_minutes=stale_opencode_minutes, + ) + decision = Decision( + number, + "update_branch", + f"{freshness_reason}; branch update requested with {mutation_token_label()} " + f"inside GitHub Actions as {mutation_actor_label()}{suffix}", + (followup_note,) if followup_note else (), + ) + return finish(decision) + + merge_state = effective_merge_state(pr) + unresolved = unresolved_thread_count(pr) + if unresolved: + if pr.get("autoMergeRequest"): + return finish( + 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 decide("block", f"{unresolved} unresolved review thread(s)") + + if has_current_head_changes_requested(pr): + if pr.get("autoMergeRequest"): + return finish( + 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 decide("block", "current-head OpenCode review requested changes") + + current_head_approved = has_current_head_approval(pr) + if current_head_approved: + stale_review_cleanup_count = dismiss_stale_opencode_change_requests( + repo, + pr, + dry_run=dry_run, + ) + auto_merge_enabled = bool(pr.get("autoMergeRequest")) + if merge_state in {"DIRTY", "CONFLICTING"}: + conflict_reason = merge_conflict_guidance(pr, merge_state) + if current_head_approved: + if auto_merge_enabled: + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + "current head is approved but merge conflict repair is required before auto-merge " + f"can be queued; {conflict_reason}" + ), + ) + ) + if not same_repository_head(repo, pr): + return decide("wait", f"{external_head_merge_reason(repo, pr)}; {conflict_reason}") + return decide( + "block", + "current head is approved, but auto-merge is not queued until merge conflict repair is pushed; " + f"{conflict_reason}", + ) + if auto_merge_enabled: + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + f"{conflict_reason}; current head has no OpenCode approval; " + "repair the conflict and get same-head approval before re-enabling auto-merge" + ), + ) + ) + return decide("block", conflict_reason) + + if current_head_approved: + failed_checks = failed_status_checks(pr) + if failed_checks: + if pr.get("autoMergeRequest"): + return finish( + 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 decide("block", f"failed check(s): {', '.join(failed_checks[:5])}") + + workflow_action_required = action_required_checks(pr) + if workflow_action_required: + reason = workflow_action_required_reason(workflow_action_required) + if pr.get("autoMergeRequest"): + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=f"{reason}; wait for current-head checks to rerun before re-enabling auto-merge", + ) + ) + return decide("wait", reason) + + merge_before_update = current_head_can_attempt_merge(pr, merge_state) and ( + merge_state == "CLEAN" or merge_mode in {"direct", "direct_or_auto"} + ) + if current_head_approved and merge_before_update: + if not same_repository_head(repo, pr): + return decide("wait", external_head_merge_reason(repo, pr)) + if not enable_auto_merge_flag: + if pr.get("autoMergeRequest"): + return decide("wait", auto_merge_wait_reason(merge_state, pr)) + return decide("wait", "current head is approved; auto-merge disabled by scheduler inputs") + if merge_mode == "disabled": + if pr.get("autoMergeRequest"): + return decide("wait", auto_merge_wait_reason(merge_state, pr)) + return decide("wait", "current head is approved; merge mode disabled by scheduler inputs") + if merge_mode in {"direct", "direct_or_auto"}: + try: + merge_pr(repo, pr, dry_run=dry_run) + except RuntimeError as exc: + if merge_mode != "direct_or_auto" or not direct_merge_can_fallback_to_auto_merge(exc): + raise + block_detail = direct_merge_block_detail(exc) + if pr.get("autoMergeRequest"): + return decide( + "auto_merge", + "current head is approved; direct merge was blocked by branch policy, " + "so the existing auto-merge request remains queued with the same head guard evidence; " + f"GitHub reported: {block_detail}", + ) + enable_auto_merge(repo, pr, dry_run=dry_run) + return decide( + "auto_merge", + "current head is approved; direct merge was blocked by branch policy, " + "so auto-merge was enabled with the same head guard evidence; " + f"GitHub reported: {block_detail}", + ) + state_note = "" if merge_state == "CLEAN" else f"; GitHub mergeability is {merge_state}" + return decide( + "merge", + f"current head is approved; direct merge requested with {mutation_token_label()} " + f"and --match-head-commit{state_note}", + ) + if merge_mode != "auto": + return decide("wait", f"current head is approved; unsupported merge mode: {merge_mode}") + if pr.get("autoMergeRequest"): + return decide("wait", auto_merge_wait_reason(merge_state, pr)) + enable_auto_merge(repo, pr, dry_run=dry_run) + return decide("auto_merge", "current head is approved; auto-merge enabled") + + behind_by = branch_outdated_by_base(pr, merge_state) + if behind_by and (current_head_approved or auto_merge_enabled): + if not update_branches: + 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)) + 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" + 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 request_branch_update(freshness_reason, suffix=suffix) + + if should_restamp_for_last_push_approval( + repo, + pr, + merge_state, + current_head_approved=current_head_approved, + auto_merge_enabled=auto_merge_enabled, + ): + block_reason = last_push_approval_block_reason() + if head_already_restamped_for_last_push_approval(pr): + return decide( + "wait", + f"{block_reason}; last-push approval head refresh already exists on the latest commit, " + "so wait for current-head checks, OpenCode approval, Strix evidence, a non-pusher approval, " + "or GitHub native auto-merge to clear the remaining rule blocker", + ) + if not update_branches: + return decide( + "wait", + f"{block_reason}; last-push approval head refresh disabled by scheduler inputs", + ) + if not branch_update_allowed: + return decide( + "wait", + f"branch update limit reached ({branch_update_limit} update/run); " + "defer last-push approval head refresh to the next scheduler run", + ) + new_head = restamp_pr_head_for_last_push_approval(repo, pr, dry_run=dry_run) + notes = () + if new_head: + notes = (f"last-push approval head refresh created same-tree head {short_sha(new_head)}",) + return finish( + Decision( + number, + "restamp_head", + f"{block_reason}; last-push approval head refresh requested with {mutation_token_label()} " + f"inside GitHub Actions as {mutation_actor_label()}", + notes, + ) + ) + + opencode_state = opencode_progress_state(pr, stale_after_minutes=stale_opencode_minutes) + if opencode_state == "running": + return decide("wait", "OpenCode review is already in progress") + + if ( + os.environ.get("GITHUB_EVENT_NAME") == "workflow_run" + and has_current_head_deterministic_fallback_approval(pr) + ): + return decide( + "wait", + "current-head deterministic fallback is not merge evidence; defer real-model retry to the next scheduler heartbeat", + ) + + if behind_by and trigger_reviews: + if not update_branches: + return decide("wait", "current head has no OpenCode approval; branch update disabled before review dispatch") + if not can_update_pr_head(repo, pr): + head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") or "" + return decide( + "wait", + f"current head has no OpenCode approval; branch is outdated before review dispatch, " + f"but head repo {head_repo} is not writable by the scheduler credential", + ) + if merge_state == "BEHIND": + freshness_reason = "current head has no OpenCode approval; branch is outdated before review dispatch" + else: + freshness_reason = ( + "current head has no OpenCode approval; " + f"base branch is {behind_by} commit(s) ahead before review dispatch even though " + f"GitHub mergeability is {merge_state}" + ) + return request_branch_update(freshness_reason) + + if merge_state == "UNKNOWN": + if pr.get("autoMergeRequest"): + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason="mergeability is still being calculated and no branch freshness evidence is available; wait for GitHub mergeability evidence before re-enabling auto-merge", + ) + ) + return decide("wait", "mergeability is still being calculated and no branch freshness evidence is available") + + if current_head_approved: + if pr.get("autoMergeRequest"): + return decide("wait", auto_merge_wait_reason(merge_state, pr)) + if not same_repository_head(repo, pr): + return decide("wait", external_head_merge_reason(repo, pr)) + if not enable_auto_merge_flag: + return decide("wait", "current head is approved; auto-merge disabled by scheduler inputs") + if merge_mode == "disabled": + return decide("wait", "current head is approved; merge mode disabled by scheduler inputs") + if merge_mode in {"direct", "direct_or_auto"}: + if merge_mode == "direct_or_auto": + try: + merge_pr(repo, pr, dry_run=dry_run) + except RuntimeError as exc: + if not direct_merge_can_fallback_to_auto_merge(exc): + raise + block_detail = direct_merge_block_detail(exc) + enable_auto_merge(repo, pr, dry_run=dry_run) + return decide( + "auto_merge", + "current head is approved; direct merge was blocked by branch policy, " + "so auto-merge was enabled with the same head guard evidence; " + f"GitHub mergeability is {merge_state}; GitHub reported: {block_detail}", + ) + return decide( + "merge", + f"current head is approved; direct merge requested with {mutation_token_label()} " + f"and --match-head-commit while GitHub mergeability is {merge_state}", + ) + return decide( + "wait", + f"current head is approved; direct merge waits for CLEAN mergeability; GitHub mergeability is {merge_state}", + ) + if merge_mode != "auto": + return decide("wait", f"current head is approved; unsupported merge mode: {merge_mode}") + enable_auto_merge(repo, pr, dry_run=dry_run) + return decide("auto_merge", "current head is approved; auto-merge enabled") + + if opencode_state == "stale" and not trigger_reviews: + return decide( + "wait", + f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; review dispatch disabled", + ) + if opencode_state == "stale": + if not review_dispatch_allowed: + return decide( + "wait", + f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; review dispatch limit reached", + ) + dispatch_result = dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) + if dispatch_result == "already_running": + return decide( + "wait", + "OpenCode review exceeded the status-check retry threshold, but a same-head workflow run is already active", + ) + return decide( + "review_dispatch", + f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; same-head OpenCode re-dispatched", + ) + + if trigger_reviews: + strix_state = strix_evidence_state(pr) + if strix_state == "missing": + if not review_dispatch_allowed: + return decide( + "wait", + "current head has no completed Strix evidence; review dispatch limit reached", + ) + wait_reason = repository_dispatch_wait_reason(repo, security_workflow) + if wait_reason: + return decide("wait", f"current head has no completed Strix evidence; {wait_reason}") + dispatch_strix_evidence(repo, security_workflow, pr, dry_run=dry_run) + return decide( + "security_dispatch", + "current head has no completed Strix evidence; same-head Strix dispatched", + ) + if strix_state == "running": + return decide("wait", "same-head Strix evidence is still running") + # Legacy trusted-base Strix self-test sentinel while this scheduler rollout lands: + # same-head Strix and OpenCode dispatched + if not review_dispatch_allowed: + return decide( + "wait", + "current head has completed Strix evidence; review dispatch limit reached", + ) + wait_reason = repository_dispatch_wait_reason(repo, workflow) + if wait_reason: + return decide("wait", f"current head has completed Strix evidence; {wait_reason}") + dispatch_result = dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) + if dispatch_result == "already_running": + return decide( + "wait", + "current head has completed Strix evidence; same-head OpenCode workflow run is already active", + ) + return decide( + "review_dispatch", + "current head has completed Strix evidence; same-head OpenCode dispatched", + ) + + if pr.get("autoMergeRequest"): + return finish( + 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 decide("block", "current head has no OpenCode approval") + + +def print_summary( + decisions: list[Decision], + *, + dry_run: bool, + base_branch: str, + project_flow: str, +) -> None: + """Print human-readable and machine-readable scheduler decisions.""" + counts: dict[str, int] = {} + for decision in decisions: + counts[decision.action] = counts.get(decision.action, 0) + 1 + print(f"PR #{decision.pr}: {decision.action}: {decision.reason}") + write_actions_summary( + decisions, + counts=counts, + dry_run=dry_run, + base_branch=base_branch, + project_flow=project_flow, + ) + print( + json.dumps( + decision_payload( + decisions, + counts=counts, + dry_run=dry_run, + base_branch=base_branch, + project_flow=project_flow, + ), + sort_keys=True, + ) + ) + + +def markdown_cell(value: object) -> str: + """Escape a value for a compact GitHub Actions summary table cell.""" + return str(value).replace("|", "\\|").replace("\n", "
") + + +def markdown_code_span(value: object) -> str: + """Escape a value for a compact Markdown inline code span.""" + escaped = str(value).replace("`", "\\`") + return f"`{escaped}`" + + +def write_actions_summary( + decisions: list[Decision], + *, + counts: dict[str, int], + dry_run: bool, + base_branch: str, + project_flow: str, +) -> None: + """Append scheduler decisions to the GitHub Actions step summary.""" + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + + lines = [ + "## PR review merge scheduler", + "", + f"- Base branch: `{base_branch}`", + f"- Project flow: `{project_flow}`", + f"- Dry run: `{str(dry_run).lower()}`", + f"- Inspected PRs: `{len(decisions)}`", + f"- Actions: `{json.dumps(counts, sort_keys=True)}`", + "", + "| PR | Action | Reason |", + "| ---: | --- | --- |", + ] + lines.extend( + f"| #{decision.pr} | {markdown_cell(decision.action)} | {markdown_cell(decision.reason)} |" + for decision in decisions + ) + lines.extend(conflict_repair_summary(decisions)) + lines.extend(outdated_thread_cleanup_summary(decisions)) + lines.extend(update_branch_summary(decisions)) + lines.extend(last_push_approval_restamp_summary(decisions)) + lines.extend(external_head_update_summary(decisions)) + lines.extend(external_head_merge_summary(decisions)) + lines.extend(workflow_action_required_summary(decisions)) + lines.extend(action_error_summary(decisions)) + + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("\n".join(lines)) + handle.write("\n") + + +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: " + conflict_start = reason.find(prefix) + if conflict_start < 0: + return None + 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 conflict_reason.split(";"): + segment = segment.strip() + if not segment.startswith("base="): + continue + branch_bits = segment.split(",") + for branch_bit in branch_bits: + key, _, value = branch_bit.strip().partition("=") + if key == "base" and value: + base_ref = value + if key == "head" and value: + head_ref = value + break + return state, base_ref, head_ref + + +def parse_conflict_changed_files(reason: str) -> list[str]: + """Extract changed-file conflict hints from scheduler guidance text.""" + prefix = "changed files to inspect first: " + for segment in reason.split(";"): + segment = segment.strip() + if not segment.startswith(prefix): + continue + return [ + file_path + for file_path in (part.strip() for part in segment[len(prefix) :].split("|")) + if file_path and not file_path.startswith("+") + ] + return [] + + +def conflict_repair_summary(decisions: list[Decision]) -> list[str]: + """Return a GitHub Actions Summary section with concrete conflict repair steps.""" + conflicted = [(decision, parse_conflict_reason(decision.reason)) for decision in decisions] + conflicted = [(decision, parsed) for decision, parsed in conflicted if parsed is not None] + if not conflicted: + return [] + + lines = [ + "", + "### Conflict repair", + "", + "When GitHub shows `Conflicting`, or the API reports `DIRTY`/`CONFLICTING`, this is not a code-review finding and it is not an `update-branch` candidate. Repair the PR branch, then push the same branch so OpenCode and required checks can run on the new head.", + "`update-branch` is not a conflict resolver: the scheduler waits here because GitHub cannot choose which side of a conflicted hunk is correct.", + ] + for decision, parsed in conflicted: + assert parsed is not None + state, base_ref, head_ref = parsed + base_remote = f"origin/{base_ref}" + changed_files = parse_conflict_changed_files(decision.reason) + lines.extend( + [ + "", + f"PR #{decision.pr} is `{state}` against `{base_ref}` from `{head_ref}`:", + "", + "```bash", + f"gh pr checkout {decision.pr}", + f"git fetch origin {shlex.quote(base_ref)}", + "# choose merge or rebase", + f"git merge --no-ff {shlex.quote(base_remote)}", + f"# git rebase {shlex.quote(base_remote)}", + "git status --short", + "# resolve conflict markers in the PR branch", + "git add ", + "# run the focused checks for the changed area", + "git push", + "# if you chose rebase: git push --force-with-lease", + "```", + ] + ) + if changed_files: + lines.extend( + [ + "", + "Changed files to inspect first:", + *(f"- {markdown_code_span(path)}" for path in changed_files), + ] + ) + return lines + + +def outdated_thread_cleanup_summary(decisions: list[Decision]) -> list[str]: + """Return a summary section for obsolete diff conversations resolved by the scheduler.""" + cleanup_notes = [ + (decision, note) + for decision in decisions + for note in decision.notes + if "outdated review thread" in note + ] + if not cleanup_notes: + return [] + + lines = [ + "", + "### Outdated review threads", + "", + "GitHub `Outdated` review threads belong to obsolete diff hunks. The scheduler resolves them before counting active unresolved review threads, so stale UI conversations do not block current-head decisions.", + ] + lines.extend(f"- PR #{decision.pr}: {note}" for decision, note in cleanup_notes) + return lines + + +def update_branch_summary(decisions: list[Decision]) -> list[str]: + """Return a GitHub Actions Summary section explaining branch update mutations.""" + updates = [decision for decision in decisions if decision.action == "update_branch"] + if not updates: + return [] + pr_list = ", ".join(f"#{decision.pr}" for decision in updates) + token_label = mutation_token_label() + actor_label = mutation_actor_label() + lines = [ + "", + "### Branch update requests", + "", + f"Requested `update-branch` for PR {pr_list} with `{token_label}`, guarded by the observed `expected_head_sha`.", + f"This is intentionally done inside GitHub Actions, not from a maintainer's local `gh` credential, so the mechanical update is attributable to `{actor_label}`.", + "Existing native auto-merge requests stay queued; branch freshness should not be repaired by disabling auto-merge first.", + "The scheduler refuses a non-dry-run `update-branch` outside GitHub Actions; dispatch the workflow instead of running the mutation locally.", + "This branch-update API path needs `pull-requests: write`; it does not require the scheduler job to widen repository `contents` to write.", + "When repository permissions allow the mutation, GitHub records the resulting branch update under the selected workflow credential.", + "The updated head is not merge evidence by itself. Wait for the new head to receive OpenCode approval, Strix evidence, required checks, and unresolved-thread checks before merge or auto-merge.", + ] + followups = [(decision, note) for decision in updates for note in decision.notes if "update-branch" in note] + if followups: + lines.extend(["", "Follow-up evidence:"]) + lines.extend(f"- PR #{decision.pr}: {note}" for decision, note in followups) + return lines + + +def parse_last_push_approval_restamp_reason(reason: str) -> bool: + """Return whether a reason describes a last-push approval head refresh.""" + return "last-push approval head refresh" in reason + + +def last_push_approval_restamp_summary(decisions: list[Decision]) -> list[str]: + """Return a summary section explaining last-push approval restamps.""" + restamps = [decision for decision in decisions if parse_last_push_approval_restamp_reason(decision.reason)] + if not restamps: + return [] + token_label = mutation_token_label() + actor_label = mutation_actor_label() + lines = [ + "", + "### Last-push approval head refresh", + "", + "These PRs were already current-head approved and had native auto-merge queued, but GitHub still reported `BLOCKED` while `reviewDecision` was `APPROVED`.", + "That combination is a strong signal that `require_last_push_approval` is still unsatisfied because the approving maintainer also pushed the current head.", + f"The scheduler may create a same-tree child commit with `{token_label}` as `{actor_label}` and move the same-repository PR branch with a `force=false` Git ref update.", + "The refreshed head is not merge evidence by itself. Wait for required checks, same-head Strix evidence, OpenCode approval, review-thread checks, and an approving review from a non-pusher before merge.", + ] + for decision in restamps: + lines.extend(["", f"- PR #{decision.pr}: {decision.reason}"]) + for note in decision.notes: + if "last-push approval head refresh" in note: + lines.append(f" - {note}") + return lines + + +def parse_external_head_update_reason(reason: str) -> str | None: + """Extract the external head repository from non-mutable update guidance.""" + match = re.search(r"head repo ([^\s]+) is external and not writable", reason) + if not match: + return None + return match.group(1) + + +def parse_external_head_merge_reason(reason: str) -> str | None: + """Extract the external head repository from merge-exclusion guidance.""" + match = re.search(r"head repo ([^\s]+) is external; fork or external PR heads are excluded", reason) + if not match: + return None + return match.group(1) + + +def external_head_update_summary(decisions: list[Decision]) -> list[str]: + """Return a GitHub Actions Summary section for non-mutable external PR heads.""" + external_waits = [ + (decision, parse_external_head_update_reason(decision.reason)) + for decision in decisions + if parse_external_head_update_reason(decision.reason) + ] + if not external_waits: + return [] + + lines = [ + "", + "### External head update required", + "", + "These PRs remain in the central review pipeline, but their head branches are not writable by the scheduler credential. This is a mutation-capability limit, not a fork/non-fork onboarding exception.", + ] + for decision, head_repo in external_waits: + lines.extend( + [ + "", + f"- PR #{decision.pr}: ask the author of `{head_repo}` to update the branch against the base branch, or enable maintainer edit permission and rerun the scheduler.", + ] + ) + return lines + + +def external_head_merge_summary(decisions: list[Decision]) -> list[str]: + """Return a GitHub Actions Summary section for fork/external PR heads excluded from merge.""" + external_waits = [ + (decision, parse_external_head_merge_reason(decision.reason)) + for decision in decisions + if parse_external_head_merge_reason(decision.reason) + ] + if not external_waits: + return [] + + lines = [ + "", + "### External head merge excluded", + "", + "These PRs remain reviewable, but the scheduler will not direct-merge or enable auto-merge for fork or external heads. A maintainer must make the final merge decision after the current head stays approved and all required evidence is green.", + ] + for decision, head_repo in external_waits: + lines.extend( + [ + "", + f"- PR #{decision.pr}: `{head_repo}` is external; keep review evidence current, then merge manually if policy allows.", + ] + ) + return lines + + +def action_error_summary(decisions: list[Decision]) -> list[str]: + """Return a GitHub Actions Summary section for mutation failures.""" + errors = [decision for decision in decisions if decision.action == "action_error"] + if not errors: + return [] + lines = [ + "", + "### Action errors", + "", + "These are scheduler or GitHub permission/runtime failures, not source-code review findings.", + ] + for decision in errors: + lines.append(f"- PR #{decision.pr}: {decision.reason}") + return lines + + +def parse_workflow_action_required_reason(reason: str) -> str | None: + """Extract ACTION_REQUIRED check names from a scheduler reason.""" + marker = "workflow action required:" + marker_start = reason.find(marker) + if marker_start < 0: + return None + tail = reason[marker_start + len(marker) :].strip() + checks = tail.split(";", 1)[0].strip() + return checks or None + + +def workflow_action_required_summary(decisions: list[Decision]) -> list[str]: + """Return a GitHub Actions Summary section for ACTION_REQUIRED waits.""" + waits = [ + decision + for decision in decisions + if parse_workflow_action_required_reason(decision.reason) + ] + if not waits: + return [] + lines = [ + "", + "### Workflow action required", + "", + "`ACTION_REQUIRED` means GitHub Actions is waiting for approval or a repository policy unblock. It is not a source-code failure and should not be converted into an OpenCode finding.", + "Unblock or approve the run, then rerun the scheduler so it can read the new current-head check state.", + ] + for decision in waits: + lines.append(f"- PR #{decision.pr}: {decision.reason}") + return lines + + +def bounded_error_summary(text: str, *, limit: int = 500) -> str: + """Cap an action-error message without dropping the actionable prefix.""" + return text if len(text) <= limit else text[: limit - 1].rstrip() + "..." + + +def summarize_action_error(exc: RuntimeError) -> str: + """Return a compact, log-safe scheduler action error summary.""" + lines = [line.strip() for line in str(exc).splitlines() if line.strip()] + if not lines: + return "scheduler action failed without stderr" + summary = "; ".join(lines[:2]) + lower_summary = summary.lower() + if "without `workflows` permission" in lower_summary or "without workflows permission" in lower_summary: + summary = ( + f"{summary}; workflow-file PRs need a scheduler mutation credential with GitHub `workflows` permission. " + "Configure `PR_REVIEW_MERGE_TOKEN` or expand the selected GitHub App permission, then rerun the scheduler; " + "do not leave this as a review comment for the PR author." + ) + if "auto-merge is disabled" in lower_summary or "auto merge is disabled" in lower_summary: + summary = ( + f"{summary}; native auto-merge is disabled for this repository. " + "Use `--merge-mode direct_or_auto` so the scheduler attempts a guarded direct merge before queueing native auto-merge, " + "or enable repository auto-merge when branch policy requires GitHub's queued merge path." + ) + if "resource not accessible by integration" in lower_summary: + if "mergepullrequest" in lower_summary or "enablepullrequestautomerge" in lower_summary or "gh pr merge" in lower_summary: + summary = ( + f"{summary}; scheduler GitHub token could not perform merge or auto-merge. " + "Merging through GitHub Actions needs an explicit repo policy exception for scheduler-job `contents: write`; otherwise leave auto-merge disabled and keep update-branch on the lower-privilege PR-write path." + ) + elif "update-branch" in lower_summary: + summary = ( + f"{summary}; scheduler GitHub token could not update the PR branch. " + "Give the scheduler job `pull-requests: write`, then rerun with the same expected-head guard; do not widen `contents` just for update-branch." + ) + else: + summary = ( + f"{summary}; scheduler GitHub token lacks a required repository mutation permission. " + "Fix the scheduler job permissions instead of posting a code-review finding." + ) + if "expected_head_sha" in lower_summary and ("422" in lower_summary or "head" in lower_summary): + summary = ( + f"{summary}; the PR head likely changed after inspection. Rerun the scheduler so it reads the new head before mutating." + ) + return bounded_error_summary(summary) + + +def self_test() -> None: + """Exercise scheduler invariants without GitHub network access.""" + assert split_repo("owner/name") == ("owner", "name") + assert split_repo("owner/name/extra") == ("owner", "name/extra") + try: + split_repo("owner") + raise AssertionError("expected ValueError") + except ValueError: + pass + try: + split_repo("/name") + raise AssertionError("expected ValueError") + except ValueError: + pass + try: + split_repo("owner/") + raise AssertionError("expected ValueError") + except ValueError: + pass + sample = { + "number": 1, + "headRefOid": "abc", + "baseRefName": "main", + "baseRefOid": "base", + "headRefName": "feature", + "mergeStateStatus": "CLEAN", + "restMergeableState": "CLEAN", + "isDraft": False, + "isCrossRepository": False, + "maintainerCanModify": False, + "headRepository": {"nameWithOwner": "owner/repo"}, + "reviewDecision": "REVIEW_REQUIRED", + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "committedDate": "2026-06-25T16:38:22Z", + "messageHeadline": "feat: sample", + } + } + ] + }, + "reviewThreads": {"nodes": []}, + "reviews": { + "nodes": [ + { + "state": "APPROVED", + "author": {"login": "opencode-agent"}, + "body": "OpenCode Agent approved this head.", + "submittedAt": "2026-06-25T15:42:19Z", + "commit": {"oid": "abc"}, + } + ] + }, + "statusCheckRollup": {"contexts": {"nodes": []}}, + } + assert has_current_head_approval(sample) + assert not 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 == "merge" + sample["restMergeableState"] = "BEHIND" + 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" + sample["restMergeableState"] = "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 repair is required before auto-merge can be queued" in decision.reason + assert "merge conflict: DIRTY" in decision.reason + sample["restMergeableState"] = "UNKNOWN" + 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 == "wait" + assert "mergeability is still being calculated" in decision.reason + sample["restMergeableState"] = "CLEAN" + sample["autoMergeRequest"] = {"enabledAt": "2026-01-01T00:02:00Z"} + sample["statusCheckRollup"]["contexts"]["nodes"] = [ + {"__typename": "CheckRun", "name": "strix", "status": "COMPLETED", "conclusion": "FAILURE"} + ] + 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 "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"] = [] + sample["reviews"]["nodes"].append( + { + "state": "APPROVED", + "author": {"login": "not-opencode-agent"}, + "body": "OpenCode Agent approved this head.", + "commit": {"oid": "abc"}, + } + ) + assert has_current_head_approval(sample) + sample["reviews"]["nodes"] = [sample["reviews"]["nodes"][-1]] + assert not has_current_head_approval(sample) + sample["reviews"]["nodes"].append( + { + "state": "CHANGES_REQUESTED", + "author": {"login": "opencode-agent"}, + "commit": {"oid": "old"}, + } + ) + 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"} + ) + assert opencode_in_progress(sample) + sample["statusCheckRollup"]["contexts"]["nodes"] = [] + sample["mergeStateStatus"] = "BEHIND" + sample["restMergeableState"] = "" + sample["reviews"]["nodes"] = [ + { + "state": "APPROVED", + "author": {"login": "opencode-agent"}, + "commit": {"oid": "old"}, + } + ] + 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 "branch is outdated before review dispatch" in decision.reason + sample["statusCheckRollup"]["contexts"]["nodes"] = [ + { + "__typename": "CheckRun", + "name": "strix", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "checkSuite": {"workflowRun": {"workflow": {"name": "Strix Security Scan"}}}, + } + ] + 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 "branch is outdated before review dispatch" in decision.reason + sample["reviews"]["nodes"][0]["commit"]["oid"] = "abc" + 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" + sample["headRepository"] = {"nameWithOwner": "external/repo"} + sample["isCrossRepository"] = True + sample["maintainerCanModify"] = False + 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 == "wait" + assert "external/repo" in decision.reason + assert decision_guidance(decision)["type"] == "external_head_update_required" + sample["maintainerCanModify"] = True + 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" + sample["headRepository"] = {"nameWithOwner": "owner/repo"} + sample["isCrossRepository"] = False + sample["maintainerCanModify"] = False + 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" + sample["statusCheckRollup"]["contexts"]["nodes"] = [ + {"__typename": "CheckRun", "name": "strix", "status": "COMPLETED", "conclusion": "FAILURE"} + ] + 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 "failed check(s): strix" in decision.reason + sample["autoMergeRequest"] = None + sample["mergeStateStatus"] = "CLEAN" + 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 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 repair is required before auto-merge can be queued" in decision.reason + 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, + 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 "auto-merge is not queued until merge conflict repair is pushed" in decision.reason + sample["reviews"]["nodes"][0]["commit"]["oid"] = "old" + 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 "gh pr checkout 1" in decision.reason + assert "git fetch origin main" in decision.reason + assert "git merge --no-ff origin/main" in decision.reason + assert "git rebase origin/main" in decision.reason + assert "git status --short" in decision.reason + assert "resolve conflict markers" in decision.reason + conflict_guidance = decision_guidance(decision) + assert conflict_guidance + assert conflict_guidance["type"] == "merge_conflict_repair" + assert conflict_guidance["merge_state"] == "DIRTY" + assert "update-branch cannot choose" in conflict_guidance["automation_limit"] + assert "git status --short" in conflict_guidance["commands"] + blocked_sample = { + "number": 2, + "headRefOid": "abc", + "baseRefName": "main", + "baseRefOid": "base", + "headRefName": "feature", + "mergeStateStatus": "BLOCKED", + "restMergeableState": "BLOCKED", + "compareStatus": "identical", + "compareBehindBy": 0, + "isDraft": False, + "isCrossRepository": False, + "maintainerCanModify": False, + "headRepository": {"nameWithOwner": "owner/repo"}, + "reviewDecision": "APPROVED", + "autoMergeRequest": {"enabledAt": "2026-01-01T00:02:00Z"}, + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "committedDate": "2026-06-25T16:38:22Z", + "messageHeadline": "ci: exercise blocked approval path", + } + } + ] + }, + "reviewThreads": {"nodes": []}, + "reviews": { + "nodes": [ + { + "state": "APPROVED", + "author": {"login": "opencode-agent"}, + "body": "OpenCode Agent approved this head.", + "submittedAt": "2026-06-25T15:42:19Z", + "commit": {"oid": "abc"}, + } + ] + }, + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "strix", + "status": "COMPLETED", + "conclusion": "SUCCESS", + } + ] + } + }, + } + decision = inspect_pr( + "owner/repo", + blocked_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 == "restamp_head" + assert "require_last_push_approval" in decision.reason + assert "last-push approval head refresh requested" in decision.reason + restamp_guidance = decision_guidance(decision) + assert restamp_guidance + assert restamp_guidance["type"] == "last_push_approval_restamp" + assert restamp_guidance["head_guard"] == "live PR head check plus force=false Git ref update" + blocked_sample["commits"]["nodes"][0]["commit"]["messageHeadline"] = LAST_PUSH_APPROVAL_RESTAMP_MESSAGE + decision = inspect_pr( + "owner/repo", + blocked_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 == "wait" + assert "head refresh already exists" in decision.reason + assert contract_decision(Decision(1, "update_branch", "ok")) == "UPDATE_BRANCH" + assert contract_decision(Decision(1, "restamp_head", "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, "merge", "ok")) == "NO_ACTION" + assert contract_decision(Decision(1, "skip", "ok")) == "NO_ACTION" + assert ( + contract_decision(Decision(1, "block", "current-head OpenCode review requested changes")) + == "REQUEST_CHANGES" + ) + assert contract_decision(Decision(1, "block", "merge conflict: DIRTY")) == "WAIT" + update_guidance = decision_guidance(Decision(1, "update_branch", "ok")) + 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" + merge_guidance = decision_guidance(Decision(1, "merge", "ok")) + assert merge_guidance + assert merge_guidance["type"] == "github_actions_direct_merge" + assert merge_guidance["head_guard"] == "gh pr merge --match-head-commit" + assert decision_guidance(Decision(1, "wait", "ok")) is None + restamp_guidance = decision_guidance( + Decision(1, "restamp_head", f"{last_push_approval_block_reason()}; last-push approval head refresh requested") + ) + assert restamp_guidance + assert restamp_guidance["type"] == "last_push_approval_restamp" + payload = decision_payload( + [Decision(1, "update_branch", "ok")], + counts={"update_branch": 1}, + dry_run=True, + base_branch="main", + project_flow="github-flow", + ) + 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]" + payload = decision_payload( + [Decision(1, "restamp_head", f"{last_push_approval_block_reason()}; last-push approval head refresh requested")], + counts={"restamp_head": 1}, + dry_run=True, + base_branch="main", + project_flow="github-flow", + ) + assert payload["decisions"][0]["contract_decision"] == "UPDATE_BRANCH" + assert payload["decisions"][0]["guidance"]["type"] == "last_push_approval_restamp" + payload = decision_payload( + [Decision(1, "merge", "ok")], + counts={"merge": 1}, + dry_run=True, + base_branch="main", + project_flow="github-flow", + ) + assert payload["decisions"][0]["contract_decision"] == "NO_ACTION" + assert payload["decisions"][0]["guidance"]["type"] == "github_actions_direct_merge" + print("self-test passed") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse scheduler CLI arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "")) + parser.add_argument("--base-branch", default=os.environ.get("DEFAULT_BRANCH", "")) + parser.add_argument("--project-flow", default=os.environ.get("PROJECT_FLOW", "")) + parser.add_argument("--max-prs", type=int, default=100) + parser.add_argument("--pr-number", type=int, default=0) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--trigger-reviews", action=argparse.BooleanOptionalAction, default=True) + parser.add_argument( + "--review-dispatch-limit", + type=int, + default=int(os.environ.get("REVIEW_DISPATCH_LIMIT", "1")), + help="Maximum OpenCode/Strix review dispatch actions per scheduler run; -1 means unlimited", + ) + parser.add_argument( + "--branch-update-limit", + type=int, + default=int(os.environ.get("BRANCH_UPDATE_LIMIT", "1")), + help="Maximum update-branch mutations per scheduler run; -1 means unlimited", + ) + parser.add_argument("--enable-auto-merge", action=argparse.BooleanOptionalAction, default=True) + parser.add_argument( + "--merge-mode", + choices=("auto", "direct", "direct_or_auto", "disabled"), + default=os.environ.get("MERGE_MODE", "direct_or_auto"), + ) + parser.add_argument("--update-branches", action=argparse.BooleanOptionalAction, default=True) + parser.add_argument("--review-workflow", default="Required OpenCode Review") + parser.add_argument("--security-workflow", default="Strix Security Scan") + parser.add_argument( + "--stale-opencode-minutes", + type=int, + default=int(os.environ.get("STALE_OPENCODE_MINUTES", str(DEFAULT_STALE_OPENCODE_MINUTES))), + ) + parser.add_argument("--self-test", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + """Run the scheduler CLI.""" + args = parse_args(argv) + if args.self_test: + self_test() + return 0 + if not args.repo: + raise SystemExit("--repo is required") + if not args.base_branch: + raise SystemExit("--base-branch is required") + if not args.project_flow: + raise SystemExit("--project-flow is required") + if args.pr_number < 0: + raise SystemExit("--pr-number must not be negative") + if args.review_dispatch_limit < -1: + raise SystemExit("--review-dispatch-limit must be -1 or greater") + if args.branch_update_limit < -1: + raise SystemExit("--branch-update-limit must be -1 or greater") + prs = fetch_pr(args.repo, args.pr_number) if args.pr_number else fetch_open_prs(args.repo, args.max_prs) + decisions = [] + review_dispatches_used = 0 + branch_updates_used = 0 + for pr in prs: + review_dispatch_allowed = ( + args.review_dispatch_limit < 0 or review_dispatches_used < args.review_dispatch_limit + ) + branch_update_allowed = args.branch_update_limit < 0 or branch_updates_used < args.branch_update_limit + try: + decision = inspect_pr( + args.repo, + pr, + dry_run=args.dry_run, + trigger_reviews=args.trigger_reviews, + review_dispatch_allowed=review_dispatch_allowed, + branch_update_allowed=branch_update_allowed, + branch_update_limit=args.branch_update_limit, + enable_auto_merge_flag=args.enable_auto_merge, + merge_mode=args.merge_mode, + update_branches=args.update_branches, + workflow=args.review_workflow, + security_workflow=args.security_workflow, + base_branch=args.base_branch, + stale_opencode_minutes=args.stale_opencode_minutes, + ) + except RuntimeError as exc: + decision = Decision( + pr.get("number", 0), + "action_error", + summarize_action_error(exc), + ) + decisions.append(decision) + if decision.action in {"review_dispatch", "security_dispatch"}: + review_dispatches_used += 1 + if decision.action in {"update_branch", "restamp_head"}: + branch_updates_used += 1 + print_summary( + decisions, + dry_run=args.dry_run, + base_branch=args.base_branch, + project_flow=args.project_flow, + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover + try: + raise SystemExit(main(sys.argv[1:])) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) from exc diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 75e18c860..1d6538b30 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -1,2250 +1,118 @@ #!/usr/bin/env python3 -"""Inspect PR review state and drive centralized OpenCode merge automation.""" - -from __future__ import annotations - -import argparse -import concurrent.futures -import json -import os -import re -import shlex -import subprocess -import sys -import time -from collections.abc import Sequence -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Any -from urllib.parse import quote - - -PULL_REQUEST_FIELDS_FRAGMENT = """\ -fragment SchedulerPullRequestFields on PullRequest { - number - title - isDraft - mergeable - mergeStateStatus - reviewDecision - baseRefName - baseRefOid - headRefName - headRefOid - isCrossRepository - maintainerCanModify - headRepository { nameWithOwner } - autoMergeRequest { enabledAt } - commits(last: 1) { - nodes { - commit { - oid - authoredDate - committedDate - messageHeadline - } - } - } - reviewThreads(first: 100) { - nodes { id isResolved isOutdated } - } - files(first: 20) { - nodes { path } - } - reviews(last: 100) { - nodes { - databaseId - state - body - submittedAt - author { login } - commit { oid } - } - } - statusCheckRollup { - contexts(first: 100) { - nodes { - __typename - ... on CheckRun { - name - status - conclusion - startedAt - detailsUrl - checkSuite { - workflowRun { - workflow { name } - } - } - } - ... on StatusContext { - context - state - } - } - } - } -} -""" - -OPEN_PRS_QUERY = """\ -query($owner: String!, $name: String!, $pageSize: Int!, $cursor: String) { - repository(owner: $owner, name: $name) { - pullRequests(first: $pageSize, after: $cursor, states: OPEN, orderBy: {field: CREATED_AT, direction: ASC}) { - pageInfo { hasNextPage endCursor } - nodes { - ...SchedulerPullRequestFields - } - } - } -} -""" + PULL_REQUEST_FIELDS_FRAGMENT - -PR_BY_NUMBER_QUERY = """\ -query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - ...SchedulerPullRequestFields - } - } -} -""" + PULL_REQUEST_FIELDS_FRAGMENT - -OPEN_PRS_PAGE_SIZE = 25 -# Must exceed the 45-minute OpenCode job cap plus typical runner-queue wait. -# QUEUED counts as running and the age clock starts at check creation, so this -# remains deliberately larger than the job cap while recovering genuine zombie -# checks in the same operating window instead of leaving them for seven hours. -DEFAULT_STALE_OPENCODE_MINUTES = 90 -DEFAULT_UPDATE_BRANCH_HEAD_POLL_ATTEMPTS = 6 -DEFAULT_UPDATE_BRANCH_HEAD_POLL_SECONDS = 5.0 -OPENCODE_WORKFLOW_NAMES = { - "OpenCode Review", - "Required OpenCode Review", - "OpenCode Review Dispatch", -} -RUNNING_CHECK_STATES = {"PENDING", "EXPECTED", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED"} -FAILED_CHECK_CONCLUSIONS = {"FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE"} -ACTION_REQUIRED_CONCLUSIONS = {"ACTION_REQUIRED"} -GIT_REF_RE = re.compile(r"^(?!-)[A-Za-z0-9._/-]+$") -GIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") -GITHUB_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") -REVIEW_BODY_HEAD_SHA_RE = re.compile(r"Head SHA:\s*`([0-9a-fA-F]{40})`") -ACTIONS_JOB_DETAILS_URL_RE = re.compile(r"/actions/runs/\d+/job/(\d+)(?:[/?#]|$)") -DIRECT_MERGE_AUTO_FALLBACK_MARKERS = ( - "base branch policy prohibits the merge", - "is not mergeable", - "merge requirements", - "required status check", -) -SQUASH_MERGE_DISABLED_MARKERS = ( - "squash merge is not allowed", - "squash merges are not allowed", -) -REST_MERGEABLE_STATE_MAP = { - "behind": "BEHIND", - "blocked": "BLOCKED", - "clean": "CLEAN", - "dirty": "DIRTY", - "draft": "DRAFT", - "has_hooks": "HAS_HOOKS", - "unknown": "UNKNOWN", - "unstable": "UNSTABLE", -} -REST_MERGEABLE_STATES = set(REST_MERGEABLE_STATE_MAP.values()) -REST_MERGEABLE_STATE_WORKERS = 10 -DETERMINISTIC_APPROVAL_MARKERS = ( - "deterministic current-head evidence", - "deterministic fallback approval", - "did not emit a usable current-head control block", -) -LAST_PUSH_APPROVAL_RESTAMP_MESSAGE = "chore: refresh head for last-push approval" - - -@dataclass -class Decision: - """Scheduler decision for a single pull request.""" - - pr: int - action: str - reason: str - notes: tuple[str, ...] = () - - -RESOLVE_REVIEW_THREAD_MUTATION = """\ -mutation($threadId: ID!) { - resolveReviewThread(input: {threadId: $threadId}) { - thread { id isResolved } - } -} -""" - - -SENSITIVE_DATA_SCRUB_PATTERNS = ( - (re.compile(r'(?i)(bearer\s+)[^\s"\'\\]+'), r'\1***'), - (re.compile(r'(?i)(token\s+)[^\s"\'\\]+'), r'\1***'), - (re.compile(r'(?i)\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+)\b'), '***'), - (re.compile(r'\b(sk-[A-Za-z0-9_-]+)'), '***'), - (re.compile(r'\b(xox[baprs]-[A-Za-z0-9-]+)'), '***'), - (re.compile(r'\b(AKIA[0-9A-Z]{16})'), '***'), - (re.compile(r'(?i)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|password|passwd|secret)\s*[:=]\s*)["\']?[^"\'\s]+["\']?'), r'\1***'), - (re.compile(r'(?i)((?:authorization|proxy-authorization)\s*:\s*(?:bearer|basic)\s+)[A-Za-z0-9._~+\/=-]+'), r'\1***'), -) - - -def scrub_sensitive_data(text: str | None) -> str | None: - """Mask sensitive tokens in text to prevent secret leakage.""" - if not text: - return text - for pattern, repl in SENSITIVE_DATA_SCRUB_PATTERNS: - text = pattern.sub(repl, text) - return text - - -def mutation_token_source() -> str: - """Return the configured scheduler mutation credential source.""" - return (os.environ.get("SCHEDULER_MUTATION_TOKEN_SOURCE") or "github-token").strip() or "github-token" - - -def mutation_token_label() -> str: - """Return a non-secret label for the scheduler mutation credential.""" - source = mutation_token_source() - labels = { - "PR_REVIEW_MERGE_TOKEN": "PR_REVIEW_MERGE_TOKEN", - "OPENCODE_APPROVE_TOKEN": "OPENCODE_APPROVE_TOKEN", - "opencode-app": "OpenCode app token", - "github-token": "workflow GITHUB_TOKEN", - } - return labels.get(source, "workflow GH_TOKEN") - - -def mutation_actor_label() -> str: - """Return the expected GitHub actor class for scheduler mutations.""" - source = mutation_token_source() - if source == "github-token": - return "github-actions[bot]" - if source == "opencode-app": - return "OpenCode GitHub App" - return "configured workflow credential" - - -def contract_decision(decision: Decision) -> str: - """Map scheduler actions into the bounded PR decision contract.""" - if decision.action in {"update_branch", "restamp_head"}: - return "UPDATE_BRANCH" - if decision.action in {"wait", "security_dispatch", "review_dispatch", "disable_auto_merge", "action_error"}: - return "WAIT" - if decision.action in {"skip", "auto_merge", "merge"}: - return "NO_ACTION" - if decision.action == "block" and "current-head OpenCode review requested changes" in decision.reason: - return "REQUEST_CHANGES" - return "WAIT" - - -def decision_payload( - decisions: list[Decision], - *, - counts: dict[str, int], - dry_run: bool, - base_branch: str, - project_flow: str, -) -> dict[str, Any]: - """Return the machine-readable scheduler decision contract.""" - return { - "schema_version": "pr-review-merge-scheduler/v2", - "base_branch": base_branch, - "dry_run": dry_run, - "inspected": len(decisions), - "counts": counts, - "project_flow": project_flow, - "decisions": [decision_contract_entry(decision) for decision in decisions], - } - - -def decision_contract_entry(decision: Decision) -> dict[str, Any]: - """Return one machine-readable decision contract entry.""" - entry: dict[str, Any] = { - "pr": decision.pr, - "action": decision.action, - "contract_decision": contract_decision(decision), - "reason": decision.reason, - } - guidance = decision_guidance(decision) - if guidance: - entry["guidance"] = guidance - if decision.notes: - entry["notes"] = list(decision.notes) - return entry - - -def decision_guidance(decision: Decision) -> dict[str, Any] | None: - """Return actionable repair or automation guidance for known scheduler states.""" - parsed_conflict = parse_conflict_reason(decision.reason) - if parsed_conflict: - state, base_ref, head_ref = parsed_conflict - base_remote = f"origin/{base_ref}" - quoted_base_ref = shlex.quote(base_ref) - quoted_base_remote = shlex.quote(base_remote) - guidance: dict[str, Any] = { - "type": "merge_conflict_repair", - "merge_state": state, - "base_ref": base_ref, - "head_ref": head_ref, - "summary": "Repair the PR branch against the latest base branch, then push the same branch so review and required checks rerun on the new head.", - "automation_limit": "GitHub update-branch cannot choose merge-conflict resolutions; the scheduler must wait until the PR branch is repaired.", - "steps": [ - "Check out the PR branch.", - "Fetch the latest base branch.", - "Choose merge or rebase; do not treat the conflict as an OpenCode finding.", - "Resolve conflict markers in the PR branch and stage the resolved files.", - "Run the focused checks for the changed area.", - "Push the PR branch; use --force-with-lease only if the branch was rebased.", - ], - "commands": [ - f"gh pr checkout {decision.pr}", - f"git fetch origin {quoted_base_ref}", - f"git merge --no-ff {quoted_base_remote}", - f"# or: git rebase {quoted_base_remote}", - "git status --short", - "git add ", - "# merge path: git commit", - "# rebase path: git rebase --continue", - "git push", - "# rebase path only: git push --force-with-lease", - ], - } - changed_files = parse_conflict_changed_files(decision.reason) - if changed_files: - guidance["changed_files_to_inspect"] = changed_files - return guidance - action_required = parse_workflow_action_required_reason(decision.reason) - if action_required: - return { - "type": "workflow_action_required", - "checks": action_required, - "summary": "A GitHub Actions run is waiting for workflow approval or a repository policy unblock; this is not a source-code failure by itself.", - "automation_limit": "The scheduler cannot safely reinterpret an ACTION_REQUIRED run as passed or failed, and should not publish a code-review finding from it.", - "next_required_evidence": [ - "GitHub Actions run approval or repository policy unblock", - "current-head check rerun after the unblock", - "OpenCode approval on the exact current head", - "same-head Strix evidence", - "zero active unresolved review threads", - ], - } - external_update = parse_external_head_update_reason(decision.reason) - if external_update: - return { - "type": "external_head_update_required", - "head_repository": external_update, - "summary": "The PR can be reviewed centrally, but this head branch is not writable by the scheduler credential.", - "automation_limit": "The scheduler should not skip the PR; it waits for the author to update the branch or for maintainers to enable a writable head path.", - "next_required_evidence": [ - "PR author updates the head branch against the base branch, or maintainer edit permission is enabled", - "new head SHA after the branch update", - "OpenCode approval on that exact new head", - "same-head Strix evidence", - "required GitHub Checks success", - "zero active unresolved review threads", - ], - } - external_merge = parse_external_head_merge_reason(decision.reason) - if external_merge: - return { - "type": "external_head_merge_excluded", - "head_repository": external_merge, - "summary": "The PR can be reviewed centrally, but this external head is excluded from scheduler direct merge and auto-merge.", - "automation_limit": "The scheduler deliberately leaves fork or external-head merges to maintainers even when approval evidence is clean.", - "next_required_evidence": [ - "same-head OpenCode approval", - "same-head Strix evidence", - "required GitHub Checks success", - "zero active unresolved review threads", - "maintainer manual merge decision", - ], - } - if parse_last_push_approval_restamp_reason(decision.reason): - return { - "type": "last_push_approval_restamp", - "actor": mutation_actor_label(), - "token": mutation_token_label(), - "required_permission": "contents: write", - "head_guard": "live PR head check plus force=false Git ref update", - "summary": "GitHub Actions creates a same-tree child commit so require_last_push_approval can be satisfied by a later non-pusher approval.", - "automation_limit": "The refreshed head is not merge evidence by itself; all current-head checks, Strix evidence, OpenCode review, and review-thread gates must rerun after the new commit.", - "next_required_evidence": [ - "new same-tree head SHA after the restamp mutation", - "OpenCode approval on that exact new head", - "same-head Strix evidence", - "required GitHub Checks success", - "zero active unresolved review threads", - "approving review from an actor who did not push the refreshed head", - ], - } - if decision.action == "update_branch": - return { - "type": "github_actions_update_branch", - "actor": mutation_actor_label(), - "token": mutation_token_label(), - "required_permission": "pull-requests: write", - "head_guard": "expected_head_sha", - "summary": "GitHub Actions requests the PR branch update mechanically; the updated head must be reviewed again before merge.", - "next_required_evidence": [ - "new head SHA after the update_branch mutation", - "OpenCode approval on that exact new head", - "same-head Strix evidence", - "required GitHub Checks success", - "zero active unresolved review threads", - ], - } - if decision.action == "merge": - return { - "type": "github_actions_direct_merge", - "actor": mutation_actor_label(), - "token": mutation_token_label(), - "required_permission": "contents: write", - "head_guard": "gh pr merge --match-head-commit", - "summary": "GitHub Actions performed an immediate guarded merge because repo policy does not use native auto-merge for this queue.", - "next_required_evidence": [ - "merge commit recorded by GitHub", - "merged head SHA matches the inspected current head", - "no active unresolved review threads before merge", - "same-head OpenCode approval before merge", - "required GitHub Checks success before merge", - ], - } - if decision.action == "disable_auto_merge": - return { - "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", - "zero active unresolved review threads", - ], - } - return None - - -def run(args: Sequence[str], *, stdin: str | None = None) -> str: - """Run a command and return stdout, raising a scrubbed summary on failure.""" - return run_with_env(args, stdin=stdin) - - -def run_with_env(args: Sequence[str], *, stdin: str | None = None, env: dict[str, str] | None = None) -> str: - """Run a command with an optional environment override and scrub failures.""" - if isinstance(args, str) or not all(isinstance(arg, str) for arg in args): - raise TypeError("run() requires a sequence of argv strings; shell command strings are not allowed") - argv = list(args) - try: - process = subprocess.run( - argv, - input=stdin, - capture_output=True, - text=True, - shell=False, - check=True, - env=env, - ) - except subprocess.CalledProcessError as exc: - scrubbed_args = scrub_sensitive_data(' '.join(argv)) - scrubbed_stderr = scrub_sensitive_data(exc.stderr or "") - raise RuntimeError( - f"Command failed ({exc.returncode}): {scrubbed_args}\n{scrubbed_stderr}" - ) from exc - return process.stdout - - -def scheduler_read_env() -> dict[str, str] | None: - """Return an env override for GitHub read calls when configured.""" - read_token = os.environ.get("SCHEDULER_READ_TOKEN") - if not read_token or read_token == os.environ.get("GH_TOKEN"): - return None - env = os.environ.copy() - env["GH_TOKEN"] = read_token - return env - - -def run_github_read(args: Sequence[str], *, stdin: str | None = None) -> str: - """Run a GitHub read command with the configured read token when available.""" - env = scheduler_read_env() - if env is None: - return run(args, stdin=stdin) - return run_with_env(args, stdin=stdin, env=env) - - -def scheduler_actions_env() -> dict[str, str] | None: - """Return an env override for GitHub Actions control calls when configured.""" - actions_token = os.environ.get("SCHEDULER_ACTIONS_TOKEN") - if not actions_token or actions_token == os.environ.get("GH_TOKEN"): - return None - env = os.environ.copy() - env["GH_TOKEN"] = actions_token - return env - - -def run_github_actions(args: Sequence[str], *, stdin: str | None = None) -> str: - """Run a GitHub Actions control command with the workflow token when configured.""" - env = scheduler_actions_env() - if env is None: - return run(args, stdin=stdin) - return run_with_env(args, stdin=stdin, env=env) - - -def scheduler_dispatch_env() -> dict[str, str] | None: - """Return an env override for central repository dispatch when configured. - - The OpenCode app installation has no Actions permission, so the mutation token - cannot create a repository dispatch. When the scheduler executes inside the - central repository receiving the event, the runner's own github.token is a - sufficient credential; the workflow passes it through SCHEDULER_DISPATCH_TOKEN. - """ - dispatch_token = os.environ.get("SCHEDULER_DISPATCH_TOKEN") - if not dispatch_token or dispatch_token == os.environ.get("GH_TOKEN"): - return None - env = os.environ.copy() - env["GH_TOKEN"] = dispatch_token - return env - - -def run_github_dispatch(args: Sequence[str], *, stdin: str | None = None) -> str: - """Run a repository dispatch command with the dispatch token when configured.""" - env = scheduler_dispatch_env() - if env is None: - return run_github_actions(args, stdin=stdin) - return run_with_env(args, stdin=stdin, env=env) - - -def split_repo(repo: str) -> tuple[str, str]: - """Split an owner/name repository string into owner and repository name.""" - try: - owner, name = repo.split("/", 1) - except ValueError as exc: - raise ValueError(f"repo must be owner/name, got {repo!r}") from exc - if not owner or not name: - raise ValueError(f"repo must be owner/name, got {repo!r}") - return owner, name - - -def validate_git_ref(ref: str) -> str: - """Return a conservative Git ref name for gh workflow dispatch fields.""" - if ( - not isinstance(ref, str) - or not ref - or not GIT_REF_RE.fullmatch(ref) - or ref == "HEAD" - or ref.startswith("/") - or ref.endswith(("/", ".")) - or "@{" in ref - or ".." in ref - or "//" in ref - ): - raise ValueError(f"invalid git ref: {ref!r}") - if any(part == "." or part.startswith(".") for part in ref.split("/")): - raise ValueError(f"invalid git ref: {ref!r}") - return ref - - -def validate_git_sha(sha: str) -> str: - """Return a 40-character hex SHA for head-guarded GitHub operations.""" - if not isinstance(sha, str) or not GIT_SHA_RE.fullmatch(sha): - raise ValueError(f"invalid git sha: {sha!r}") - return sha - - -def validate_github_repository(repo: str) -> str: - """Return a GitHub owner/repository name safe to pass to gh.""" - if not isinstance(repo, str) or not GITHUB_REPOSITORY_RE.fullmatch(repo): - raise ValueError(f"invalid GitHub repository: {repo!r}") - return repo - - -def validated_pr_dispatch_fields(pr: dict[str, Any]) -> tuple[str, str, str]: - """Return validated base ref, base SHA, and head SHA for workflow dispatch.""" - return ( - validate_git_ref(pr["baseRefName"]), - validate_git_sha(pr["baseRefOid"]), - validate_git_sha(pr["headRefOid"]), - ) - - -def repository_dispatch_target(repo: str) -> str: - """Return the default-branch repository that receives review dispatch events. - - Organization required workflows are sourced from ContextualWisdomLab/.github, - while most target repositories deliberately do not keep repo-local workflow - copies. GitHub evaluates ``repository_dispatch`` only from the receiver's - default branch, so callers cannot select a privileged workflow ref. - """ - target_repo = validate_github_repository(repo) - dispatch_repo = (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip() - if not dispatch_repo: - return target_repo - return validate_github_repository(dispatch_repo) - - -def env_flag_enabled(name: str) -> bool: - """Return whether an environment flag is explicitly truthy.""" - return (os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"} - - -def repository_dispatch_wait_reason(repo: str, workflow: str) -> str | None: - """Explain why cross-repository required repository dispatch should wait.""" - target_repo = validate_github_repository(repo) - dispatch_repo = (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip() - if not dispatch_repo: - return None - dispatch_repo = validate_github_repository(dispatch_repo) - if dispatch_repo == target_repo or env_flag_enabled("SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH"): - return None - execution_repo = (os.environ.get("GITHUB_REPOSITORY") or "").strip() - if os.environ.get("SCHEDULER_DISPATCH_TOKEN") and execution_repo == dispatch_repo: - # The dispatch targets the repository this scheduler run executes in and the - # workflow provided a dispatch-capable runner token for it, so no - # cross-repository credential is needed. - return None - return ( - f"{workflow} dispatch waits for central required workflow materialization; " - f"required workflow source is {dispatch_repo}, but this scheduler run has no " - "cross-repository repository-dispatch credential. Wait for the organization required " - "workflow to materialize, or rerun the same-head target-repository job after GitHub " - "exposes it in the PR check rollup." - ) - - -TRANSIENT_GITHUB_API_ERRORS = ( - "HTTP 500", - "HTTP 502", - "HTTP 503", - "HTTP 504", - "connection reset", - "connection refused", - "connection timed out", - "context deadline exceeded", - "gateway timeout", - "i/o timeout", - "server error", - "service unavailable", - "stream error", - "temporary failure", - "timeout", - "unexpected end of JSON input", - "unexpected EOF", - "received from peer", -) - - -def is_transient_github_api_error(exc: Exception) -> bool: - """Return whether a GitHub API failure is worth retrying in the same run.""" - if isinstance(exc, json.JSONDecodeError): - return True - message = str(exc) - folded = message.lower() - return any(marker in message or marker.lower() in folded for marker in TRANSIENT_GITHUB_API_ERRORS) - - -def gh_graphql(query: str, **fields: str | int) -> dict[str, Any]: - """Run a GitHub GraphQL query through gh and decode the JSON response.""" - cmd = ["gh", "api", "graphql", "-F", "query=@-"] - for key, value in fields.items(): - flag = "-F" if isinstance(value, int) else "-f" - cmd.extend([flag, f"{key}={value}"]) - max_attempts = 4 - for attempt in range(1, max_attempts + 1): # pragma: no branch - last failed attempt always raises - try: - return json.loads(run_github_read(cmd, stdin=query)) - except (RuntimeError, json.JSONDecodeError) as exc: - if attempt >= max_attempts or not is_transient_github_api_error(exc): - raise - delay = min(2 ** (attempt - 1), 8) - print( - f"Transient GitHub GraphQL error on attempt {attempt}/{max_attempts}; retrying in {delay}s", - file=sys.stderr, - ) - time.sleep(delay) - - -def github_resource_inaccessible(exc: RuntimeError) -> bool: - """Return whether GitHub denied an API read for the current integration token.""" - - return "Resource not accessible by integration" in str(exc) - - -def gh_api_json(path: str) -> Any: - """Run a GitHub REST API request through gh and decode the JSON response.""" - - return json.loads(run_github_read(["gh", "api", path])) - - -def rest_review_node(review: dict[str, Any]) -> dict[str, Any]: - """Convert a REST review payload into the GraphQL shape used by the scheduler.""" - - commit_id = review.get("commit_id") - return { - "databaseId": review.get("id"), - "state": review.get("state"), - "body": review.get("body"), - "submittedAt": review.get("submitted_at"), - "author": {"login": ((review.get("user") or {}).get("login"))}, - "commit": {"oid": commit_id} if commit_id else None, - } - - -def rest_check_node(check: dict[str, Any]) -> dict[str, Any]: - """Convert a REST check-run payload into the GraphQL status rollup shape.""" - - return { - "__typename": "CheckRun", - "name": check.get("name"), - "status": (check.get("status") or "").upper(), - "conclusion": (check.get("conclusion") or "").upper() if check.get("conclusion") else None, - "startedAt": check.get("started_at"), - "detailsUrl": check.get("details_url"), - "checkSuite": {"workflowRun": {"workflow": {}}}, - } - - -def rest_pr_node(repo: str, pr: dict[str, Any]) -> dict[str, Any]: - """Convert a REST pull request payload into the GraphQL shape used by the scheduler.""" - - number = int(pr["number"]) - head = pr.get("head") or {} - base = pr.get("base") or {} - head_repo = head.get("repo") or {} - reviews = gh_api_json(f"repos/{repo}/pulls/{number}/reviews?per_page=100") - checks = gh_api_json(f"repos/{repo}/commits/{head.get('sha')}/check-runs?per_page=100") - files = gh_api_json(f"repos/{repo}/pulls/{number}/files?per_page=20") - rest_merge_state = REST_MERGEABLE_STATE_MAP.get( - str(pr.get("mergeable_state") or "").lower(), - str(pr.get("mergeable_state") or "").upper(), - ) - return { - "number": number, - "title": pr.get("title"), - "isDraft": bool(pr.get("draft")), - "mergeable": pr.get("mergeable"), - "mergeStateStatus": rest_merge_state, - "reviewDecision": "REVIEW_REQUIRED", - "baseRefName": base.get("ref"), - "baseRefOid": base.get("sha"), - "headRefName": head.get("ref"), - "headRefOid": head.get("sha"), - "isCrossRepository": (head_repo.get("full_name") or repo).lower() != repo.lower(), - "maintainerCanModify": bool(pr.get("maintainer_can_modify")), - "headRepository": {"nameWithOwner": head_repo.get("full_name") or repo}, - "autoMergeRequest": pr.get("auto_merge"), - "reviewThreads": {"nodes": []}, - "files": {"nodes": [{"path": file.get("filename")} for file in files if file.get("filename")]}, - "reviews": {"nodes": [rest_review_node(review) for review in reviews]}, - "statusCheckRollup": { - "contexts": { - "nodes": [ - rest_check_node(check) - for check in (checks.get("check_runs") or []) - ] - } - }, - "restMergeableState": rest_merge_state, - } - - -def fetch_open_prs_rest(repo: str, max_prs: int, base_branch: str | None = None) -> list[dict[str, Any]]: - """Fetch open pull requests through REST when GraphQL is unavailable.""" - - prs: list[dict[str, Any]] = [] - page = 1 - while len(prs) < max_prs: - page_size = min(100, max_prs - len(prs)) - path = ( - f"repos/{repo}/pulls?state=open&sort=created&direction=asc" - f"&per_page={page_size}&page={page}" - ) - if base_branch: - path += f"&base={quote(base_branch, safe='')}" - payload = gh_api_json(path) - if not payload: - break - if len(payload) <= 1: - prs.extend(rest_pr_node(repo, pr) for pr in payload) # pragma: no cover - else: - max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(payload)) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - # Keep original API sort order - prs.extend(list(executor.map(lambda pr: rest_pr_node(repo, pr), payload))) - if len(payload) < page_size: - break - page += 1 - return prs[:max_prs] - - -def fetch_pr_rest(repo: str, number: int) -> list[dict[str, Any]]: - """Fetch one pull request through REST when GraphQL is unavailable.""" - - pr = gh_api_json(f"repos/{repo}/pulls/{number}") - return [rest_pr_node(repo, pr)] if pr else [] - - -def fetch_open_prs(repo: str, max_prs: int) -> list[dict[str, Any]]: - """Fetch open pull requests from GitHub, paginating up to max_prs.""" - owner, name = split_repo(repo) - prs: list[dict[str, Any]] = [] - cursor: str | None = None - - while len(prs) < max_prs: - page_size = min(OPEN_PRS_PAGE_SIZE, max_prs - len(prs)) - fields: dict[str, str | int] = { - "owner": owner, - "name": name, - "pageSize": page_size, - } - if cursor: - fields["cursor"] = cursor - try: - payload = gh_graphql(OPEN_PRS_QUERY, **fields) - except RuntimeError as exc: - if github_resource_inaccessible(exc) or is_transient_github_api_error(exc): - return fetch_open_prs_rest(repo, max_prs) - raise - pr_page = payload["data"]["repository"]["pullRequests"] - prs.extend(pr_page.get("nodes") or []) - if not pr_page["pageInfo"]["hasNextPage"]: - break - cursor = pr_page["pageInfo"]["endCursor"] - - enrich_rest_mergeable_states(repo, prs) - return prs - - -def fetch_pr(repo: str, number: int) -> list[dict[str, Any]]: - """Fetch one pull request by number using the same evidence shape as the queue scan.""" - owner, name = split_repo(repo) - try: - payload = gh_graphql(PR_BY_NUMBER_QUERY, owner=owner, name=name, number=number) - except RuntimeError as exc: - if github_resource_inaccessible(exc) or is_transient_github_api_error(exc): - return fetch_pr_rest(repo, number) - raise - pr = payload["data"]["repository"].get("pullRequest") - prs = [pr] if pr else [] - enrich_rest_mergeable_states(repo, prs) - return prs - - -def fetch_rest_mergeable_state(repo: str, number: int) -> str: - """Fetch and normalize GitHub REST mergeable_state for one pull request.""" - raw_state = run( - [ - "gh", - "api", - f"repos/{repo}/pulls/{number}", - "--jq", - ".mergeable_state // \"\"", - ] - ).strip() - return REST_MERGEABLE_STATE_MAP.get(raw_state.lower(), raw_state.upper()) - - -def compare_ref_for_pr_head(repo: str, pr: dict[str, Any]) -> str: - """Return the compare-API head ref for a PR branch.""" - head_ref = pr.get("headRefName") or "HEAD" - head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") - if not head_repo or head_repo == repo: - return head_ref - head_owner, _ = split_repo(head_repo) - return f"{head_owner}:{head_ref}" - - -def fetch_compare_branch_freshness(repo: str, pr: dict[str, Any]) -> dict[str, Any]: - """Fetch compare evidence showing whether the PR head lacks base commits.""" - base = quote(pr.get("baseRefName") or "base", safe="") - head = quote(compare_ref_for_pr_head(repo, pr), safe=":") - return json.loads( - run( - [ - "gh", - "api", - f"repos/{repo}/compare/{base}...{head}", - ] - ) - ) - - -def enrich_rest_mergeable_states(repo: str, prs: list[dict[str, Any]]) -> None: - """Attach REST mergeability evidence to GraphQL pull request payloads.""" - - def enrich(pr: dict[str, Any]) -> None: - """Attach REST mergeability evidence to one pull request payload.""" - try: - pr["restMergeableState"] = fetch_rest_mergeable_state(repo, int(pr["number"])) - except RuntimeError as exc: - pr["restMergeableStateError"] = bounded_error_summary(str(exc)) - try: - compare = fetch_compare_branch_freshness(repo, pr) - pr["compareStatus"] = compare.get("status") - pr["compareBehindBy"] = compare.get("behind_by") - except RuntimeError as exc: - pr["compareBranchFreshnessError"] = bounded_error_summary(str(exc)) - - if not prs: - return - - if len(prs) <= 1: - for pr in prs: - enrich(pr) - return - - max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(prs)) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - for _ in executor.map(enrich, prs): - pass - - -def effective_merge_state(pr: dict[str, Any]) -> str: - """Return the safest merge state from GraphQL plus REST mergeability evidence.""" - graph_state = (pr.get("mergeStateStatus") or "").upper() - rest_state = (pr.get("restMergeableState") or "").upper() - if rest_state in REST_MERGEABLE_STATES: - return rest_state - if graph_state in {"BEHIND", "DIRTY", "CONFLICTING", "UNKNOWN"}: - return graph_state - return rest_state or graph_state - - -def compare_behind_by(pr: dict[str, Any]) -> int: - """Return the compare API's behind_by count as a safe integer.""" - behind_by = pr.get("compareBehindBy") - if isinstance(behind_by, int): - return max(0, behind_by) - if isinstance(behind_by, str) and behind_by.isdigit(): - return int(behind_by) - return 0 - - -def branch_outdated_by_base(pr: dict[str, Any], merge_state: str) -> int: - """Return known count of base commits missing from the PR head.""" - compare_status = (pr.get("compareStatus") or "").lower() - if merge_state == "BEHIND" or compare_status == "behind": - return max(1, compare_behind_by(pr)) - return compare_behind_by(pr) - - -def context_nodes(pr: dict[str, Any]) -> list[dict[str, Any]]: - """Return status rollup context nodes for a pull request payload.""" - rollup = pr.get("statusCheckRollup") or {} - contexts = rollup.get("contexts") or {} - return contexts.get("nodes") or [] - - -def is_opencode_context(node: dict[str, Any]) -> bool: - """Return whether a check or status context belongs to OpenCode Review.""" - if node.get("__typename") == "CheckRun": - if (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip(): - # Central reviews run through repository_dispatch and publish a commit - # status. Organization required-workflow CheckRuns are deliberately - # non-authoritative placeholders and must not suppress that dispatch. - return False - workflow = ( - ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") - or {} - ) - return node.get("name") == "opencode-review" or workflow.get("name") in OPENCODE_WORKFLOW_NAMES - return node.get("context") == "opencode-review" - - -def is_strix_context(node: dict[str, Any]) -> bool: - """Return whether a check or status context belongs to Strix evidence.""" - if node.get("__typename") == "CheckRun": - workflow = ( - ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") - or {} - ) - workflow_name = workflow.get("name") - return workflow_name in {"Strix Security Scan", "Strix"} or ( - node.get("name") == "strix" and workflow_name is None - ) - return (node.get("context") or "") in {"strix", "Strix Security Scan"} - - -def actions_job_id_from_details_url(value: str | None) -> str | None: - """Return a GitHub Actions job id from a check-run details URL.""" - if not value: - return None - match = ACTIONS_JOB_DETAILS_URL_RE.search(value) - return match.group(1) if match else None - - -def matching_actions_job_id(pr: dict[str, Any], predicate: Any) -> str | None: - """Return the latest matching check-run job id, if GitHub exposed one.""" - for node in reversed(context_nodes(pr)): - if node.get("__typename") != "CheckRun" or not predicate(node): - continue - job_id = actions_job_id_from_details_url(node.get("detailsUrl")) - if job_id: - return job_id - return None - - -def parse_github_datetime(value: str | None) -> datetime | None: - """Parse a GitHub API timestamp into an aware UTC datetime.""" - if not value: - return None - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - return parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) - - -def review_matches_current_head(review: dict[str, Any], pr: dict[str, Any]) -> bool: - """Return whether a review is valid evidence for the current head commit.""" - head = pr.get("headRefOid") - commit = (review.get("commit") or {}).get("oid") - if not head: - return False - body_head = review_body_head_sha(review) - if commit == head: - return body_head is None or body_head.lower() == head.lower() - if not commit and body_head is not None: - return body_head.lower() == head.lower() - return False - - -def review_body_head_sha(review: dict[str, Any]) -> str | None: - """Return the last explicit Head SHA from an OpenCode review body.""" - body = review.get("body") or "" - matches = REVIEW_BODY_HEAD_SHA_RE.findall(body) - return matches[-1] if matches else None - - -def running_check_state(node: dict[str, Any]) -> str: - """Return running, complete, or absent for a check/status context.""" - status = (node.get("status") or node.get("state") or "").upper() - if not status: - return "absent" - return "running" if status in RUNNING_CHECK_STATES else "complete" - - -def opencode_progress_state( - pr: dict[str, Any], - *, - stale_after_minutes: int, - now: datetime | None = None, -) -> str: - """Return absent, running, stale, or complete for current OpenCode review status.""" - now = now or datetime.now(timezone.utc) - saw_complete = False - for node in context_nodes(pr): - if not is_opencode_context(node): - continue - state = running_check_state(node) - if state == "absent": - continue - if state != "running": - saw_complete = True - continue - started_at = parse_github_datetime(node.get("startedAt")) - if started_at and stale_after_minutes >= 0: - age_seconds = (now - started_at).total_seconds() - if age_seconds >= stale_after_minutes * 60: - return "stale" - return "running" - return "complete" if saw_complete else "absent" - - -def opencode_in_progress(pr: dict[str, Any], *, stale_after_minutes: int | None = None) -> bool: - """Return whether any OpenCode review status for the PR is still actively running.""" - stale_after = DEFAULT_STALE_OPENCODE_MINUTES if stale_after_minutes is None else stale_after_minutes - return opencode_progress_state(pr, stale_after_minutes=stale_after) == "running" - - -def strix_evidence_state(pr: dict[str, Any]) -> str: - """Return missing, running, or complete for current-head Strix evidence.""" - found = False - for node in context_nodes(pr): - if not is_strix_context(node): - continue - found = True - status = (node.get("status") or node.get("state") or "").upper() - if status in RUNNING_CHECK_STATES: - return "running" - if node.get("__typename") == "CheckRun" and status != "COMPLETED": - return "running" - return "complete" if found else "missing" - - -def unresolved_thread_count(pr: dict[str, Any]) -> int: - """Count active, non-outdated unresolved review threads on a PR.""" - threads = ((pr.get("reviewThreads") or {}).get("nodes") or []) - return sum(1 for thread in threads if not thread.get("isResolved") and not thread.get("isOutdated")) - - -def outdated_thread_ids(pr: dict[str, Any]) -> list[str]: - """Return unresolved review-thread IDs GitHub already marks outdated.""" - threads = ((pr.get("reviewThreads") or {}).get("nodes") or []) - return [ - thread["id"] - for thread in threads - if thread.get("id") and not thread.get("isResolved") and thread.get("isOutdated") - ] - - -def resolve_review_thread(thread_id: str) -> None: - """Resolve one GitHub review thread by GraphQL node ID.""" - gh_graphql(RESOLVE_REVIEW_THREAD_MUTATION, threadId=thread_id) - - -def resolve_outdated_review_threads(pr: dict[str, Any], *, dry_run: bool) -> int: - """Resolve obsolete diff conversations before active-thread merge checks.""" - thread_ids = outdated_thread_ids(pr) - if not thread_ids: - return 0 - if dry_run: - return len(thread_ids) - require_github_actions_mutation_actor("resolve-outdated-review-thread") - if len(thread_ids) <= 1: - for thread_id in thread_ids: # pragma: no cover - resolve_review_thread(thread_id) # pragma: no cover - else: - max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(thread_ids)) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - list(executor.map(resolve_review_thread, thread_ids)) - return len(thread_ids) - - -def with_outdated_thread_cleanup_note(decision: Decision, count: int, *, dry_run: bool) -> Decision: - """Annotate a decision with the outdated-thread cleanup side effect.""" - if count <= 0: - return decision - verb = "Would resolve" if dry_run else "Resolved" - note = ( - f"{verb} {count} outdated review thread(s) before active unresolved-thread checks; " - "outdated diff comments are not current-head review blockers." - ) - return Decision(decision.pr, decision.action, decision.reason, (*decision.notes, note)) - - -def review_author_login(review: dict[str, Any]) -> str: - """Return a normalized review author login.""" - return ((review.get("author") or {}).get("login") or "").lower() - - -def is_opencode_review(review: dict[str, Any]) -> bool: - """Return whether a review was authored by the OpenCode agent.""" - return review_author_login(review) in {"opencode-agent", "opencode-agent[bot]"} - - -def is_legacy_actions_opencode_review(review: dict[str, Any]) -> bool: - """Return whether a legacy Actions-authored review contains OpenCode evidence.""" - login = review_author_login(review) - return login in {"github-actions", "github-actions[bot]"} and "opencode" in ( - review.get("body") or "" - ).lower() - - -def is_automated_opencode_review(review: dict[str, Any]) -> bool: - """Return whether a review is OpenCode automation evidence, including legacy writes.""" - return is_opencode_review(review) or is_legacy_actions_opencode_review(review) - - -def is_deterministic_fallback_approval(review: dict[str, Any]) -> bool: - """Return whether an old fail-open approval body is not review evidence.""" - if (review.get("state") or "").upper() != "APPROVED": - return False - body = (review.get("body") or "").lower() - return any(marker in body for marker in DETERMINISTIC_APPROVAL_MARKERS) - - -def has_current_head_deterministic_fallback_approval(pr: dict[str, Any]) -> bool: - """Return whether OpenCode's latest current-head review is fallback-only.""" - for review in reversed((pr.get("reviews") or {}).get("nodes") or []): - if not is_opencode_review(review): - continue - if not review_matches_current_head(review, pr): - continue - return is_deterministic_fallback_approval(review) - return False - - -def current_head_review_state(pr: dict[str, Any], state: str) -> bool: - """Return whether OpenCode's latest current-head review has the target state.""" - target_state = state.upper() - for review in reversed((pr.get("reviews") or {}).get("nodes") or []): - if not is_opencode_review(review): - continue - if not review_matches_current_head(review, pr): - continue - if target_state == "APPROVED" and is_deterministic_fallback_approval(review): - return False - return (review.get("state") or "").upper() == target_state - return False - - -def has_current_head_approval(pr: dict[str, Any]) -> bool: - """Return whether OpenCode approved the exact current head commit.""" - return current_head_review_state(pr, "APPROVED") - - -def has_current_head_changes_requested(pr: dict[str, Any]) -> bool: - """Return whether OpenCode requested changes on the exact current head.""" - return current_head_review_state(pr, "CHANGES_REQUESTED") - - -def stale_opencode_change_request_ids(pr: dict[str, Any]) -> list[int]: - """Return dismissible automated change requests tied to previous heads.""" - review_ids: list[int] = [] - for review in (pr.get("reviews") or {}).get("nodes") or []: - if (review.get("state") or "").upper() != "CHANGES_REQUESTED": - continue - if review_matches_current_head(review, pr): - continue - if not is_automated_opencode_review(review): - continue - review_id = review.get("databaseId") - if isinstance(review_id, int) and review_id > 0: - review_ids.append(review_id) - return review_ids - - -def stale_opencode_approval_ids(pr: dict[str, Any]) -> list[int]: - """Return active automated approvals whose evidence is not for the live head. - - GitHub evaluates the latest review from each author. Older review objects may - remain ``APPROVED`` after a later same-author review supersedes them, and the - dismissal API treats those historical objects as no-ops. Inspect only the - latest OpenCode review per automation identity so cleanup targets effective - policy state rather than immutable review history. - """ - latest_by_author: dict[str, dict[str, Any]] = {} - for review in (pr.get("reviews") or {}).get("nodes") or []: - if not is_automated_opencode_review(review): - continue - latest_by_author[review_author_login(review)] = review - - review_ids: list[int] = [] - for review in latest_by_author.values(): - if (review.get("state") or "").upper() != "APPROVED": - continue - if review_matches_current_head(review, pr): - continue - review_id = review.get("databaseId") - if isinstance(review_id, int) and review_id > 0: - review_ids.append(review_id) - return review_ids - - -def dismiss_pull_request_review( - repo: str, - number: str, - review_id: int, - *, - message: str, -) -> bool: - """Dismiss one review and verify GitHub actually changed its state.""" - try: - run( - [ - "gh", - "api", - "-X", - "PUT", - f"repos/{repo}/pulls/{number}/reviews/{review_id}/dismissals", - "-f", - f"message={message}", - ] - ) - live_state = run_github_read( - [ - "gh", - "api", - f"repos/{repo}/pulls/{number}/reviews/{review_id}", - "--jq", - ".state", - ] - ).strip().upper() - except RuntimeError as exc: - print( - "::warning::Stale OpenCode review dismissal failed for " - f"PR #{number} review {review_id}: {scrub_sensitive_data(str(exc))}" - ) - return False - if live_state == "DISMISSED": - return True - print( - "::warning::GitHub accepted stale OpenCode review dismissal for " - f"PR #{number} review {review_id}, but the verified review state is " - f"{live_state or ''}; the review remains non-authoritative unless its explicit " - "Head SHA matches the live PR head." - ) - return False - - -def dismiss_stale_opencode_approvals( - repo: str, - pr: dict[str, Any], - *, - dry_run: bool, -) -> tuple[int, int]: - """Dismiss latest automated approvals that do not match the exact live head.""" - review_ids = stale_opencode_approval_ids(pr) - if not review_ids: - return 0, 0 - if dry_run: - return len(review_ids), 0 - - require_github_actions_mutation_actor("dismiss-stale-opencode-approval") - repo = validate_github_repository(repo) - number = str(int(pr["number"])) - expected_head = validate_git_sha(pr["headRefOid"]) - live_head = run_github_read( - ["gh", "api", f"repos/{repo}/pulls/{number}", "--jq", ".head.sha"] - ).strip() - if live_head != expected_head: - raise RuntimeError( - "PR head changed before stale approval dismissal; " - f"expected {expected_head}, observed {live_head or ''}" - ) - - dismissed = 0 - for review_id in review_ids: - message = ( - "Superseded automated OpenCode approval whose explicit review evidence does not match " - f"exact current head {expected_head}; a fresh current-head review is required." - ) - if dismiss_pull_request_review(repo, number, review_id, message=message): - dismissed += 1 - return dismissed, len(review_ids) - dismissed - - -def stale_approval_cleanup_note(dismissed: int, retained: int, *, dry_run: bool) -> str | None: - """Render exact stale-approval cleanup evidence for scheduler logs.""" - notes: list[str] = [] - if dismissed: - verb = "would dismiss" if dry_run else "dismissed" - notes.append(f"{verb} {dismissed} latest previous-head automated OpenCode approval(s)") - if retained: - notes.append( - f"GitHub retained {retained} stale automated approval(s) after dismissal attempts; " - "their head evidence remains non-authoritative" - ) - return "; ".join(notes) if notes else None - - -def dismiss_stale_opencode_change_requests(repo: str, pr: dict[str, Any], *, dry_run: bool) -> int: - """Dismiss previous-head automated gates only after exact-head approval.""" - if not has_current_head_approval(pr): - return 0 - review_ids = stale_opencode_change_request_ids(pr) - if not review_ids: - return 0 - if dry_run: - return len(review_ids) - - require_github_actions_mutation_actor("dismiss-stale-opencode-review") - repo = validate_github_repository(repo) - number = str(int(pr["number"])) - expected_head = validate_git_sha(pr["headRefOid"]) - live_head = run_github_read( - ["gh", "api", f"repos/{repo}/pulls/{number}", "--jq", ".head.sha"] - ).strip() - if live_head != expected_head: - raise RuntimeError( - "PR head changed before stale review dismissal; " - f"expected {expected_head}, observed {live_head or ''}" - ) - - for review_id in review_ids: - message = ( - "Superseded automated OpenCode change request from a previous head; " - f"exact current head {expected_head} has a later OpenCode approval." - ) - run( - [ - "gh", - "api", - "-X", - "PUT", - f"repos/{repo}/pulls/{number}/reviews/{review_id}/dismissals", - "-f", - f"message={message}", - ] - ) - return len(review_ids) - - -def failed_status_checks(pr: dict[str, Any]) -> list[str]: - """Return failing check or status context names from the PR rollup.""" - failed: list[str] = [] - latest_check_runs: dict[ - tuple[str, str], - tuple[datetime | None, int, dict[str, Any]], - ] = {} - status_contexts: list[dict[str, Any]] = [] - for index, node in enumerate(context_nodes(pr)): - if node.get("__typename") != "CheckRun": - status_contexts.append(node) - continue - workflow = ( - (((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {}).get("name") - or "" - ) - key = (workflow, node.get("name") or "check-run") - started_at = parse_github_datetime(node.get("startedAt")) - previous = latest_check_runs.get(key) - if previous is None: - latest_check_runs[key] = (started_at, index, node) - continue - previous_started_at, previous_index, _ = previous - if started_at is None and previous_started_at is not None: - continue - if previous_started_at is None and started_at is not None: - latest_check_runs[key] = (started_at, index, node) - continue - if (started_at or datetime.min.replace(tzinfo=timezone.utc), index) >= ( - previous_started_at or datetime.min.replace(tzinfo=timezone.utc), - previous_index, - ): - latest_check_runs[key] = (started_at, index, node) - - successful_status_contexts = { - node.get("context") - for node in status_contexts - if (node.get("state") or "").upper() == "SUCCESS" - } - for _, _, node in sorted(latest_check_runs.values(), key=lambda item: item[1]): - conclusion = (node.get("conclusion") or "").upper() - if conclusion in FAILED_CHECK_CONCLUSIONS: - if is_strix_context(node) and "strix" in successful_status_contexts: - continue - if is_opencode_context(node) and "opencode-review" in successful_status_contexts: - continue - failed.append(node.get("name") or "check-run") - for node in status_contexts: - state = (node.get("state") or "").upper() - if state in {"FAILURE", "ERROR"}: - failed.append(node.get("context") or "status-context") - return failed - - -def action_required_checks(pr: dict[str, Any]) -> list[str]: - """Return check-run names that need explicit GitHub Actions approval or unblocking.""" - required: list[str] = [] - for node in context_nodes(pr): - if node.get("__typename") != "CheckRun": - continue - conclusion = (node.get("conclusion") or "").upper() - if conclusion in ACTION_REQUIRED_CONCLUSIONS: - required.append(node.get("name") or "check-run") - return required - - -def workflow_action_required_reason(checks: list[str]) -> str: - """Return a scheduler reason for ACTION_REQUIRED check runs.""" - visible = checks[:5] - suffix = f", +{len(checks) - len(visible)} more" if len(checks) > len(visible) else "" - return ( - f"workflow action required: {', '.join(visible)}{suffix}; " - "approve or unblock the GitHub Actions run before treating checks as failed or passed" - ) - - -def run_head_guarded_merge( - repo: str, - number: str, - head: str, - *, - auto: bool, -) -> None: - """Run a head-guarded merge using an allowed repository merge method.""" - args = ["gh", "pr", "merge", number, "--repo", repo] - if auto: - args.append("--auto") - args.extend(["--squash", "--match-head-commit", head]) - try: - run(args) - return - except RuntimeError as exc: - detail = str(exc).lower() - if not any(marker in detail for marker in SQUASH_MERGE_DISABLED_MARKERS): - raise - reason = str(exc).splitlines()[-1][:400] - - mode = "auto-merge" if auto else "direct merge" - print( - f"PR #{number}: squash is disabled; retrying {mode} with a merge commit " - f"at guarded head {head}. GitHub reason: {reason}" - ) - merge_args = ["gh", "pr", "merge", number, "--repo", repo] - if auto: - merge_args.append("--auto") - merge_args.extend(["--merge", "--match-head-commit", head]) - run(merge_args) - - -def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: - """Enable auto-merge for a PR at its current head using an allowed method.""" - number = str(pr["number"]) - if dry_run: - return - require_github_actions_mutation_actor("enable-auto-merge") - head = validate_git_sha(pr["headRefOid"]) - run_head_guarded_merge(repo, number, head, auto=True) - - -def merge_pr(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: - """Merge a current-head-approved PR immediately with a head guard.""" - number = str(pr["number"]) - if dry_run: - return - require_github_actions_mutation_actor("direct-merge") - head = validate_git_sha(pr["headRefOid"]) - run_head_guarded_merge(repo, number, head, auto=False) - - -def direct_merge_can_fallback_to_auto_merge(error: Exception) -> bool: - """Return whether a direct merge failure should queue auto-merge instead.""" - text = str(error).lower() - return any(marker in text for marker in DIRECT_MERGE_AUTO_FALLBACK_MARKERS) - - -def direct_merge_block_detail(error: Exception) -> str: - """Return the concrete GitHub merge refusal detail for scheduler logs.""" - lines = [line.strip() for line in str(error).splitlines() if line.strip()] - detail_lines = [ - line - for line in lines - if line.startswith(("X ", "gh:", "{")) - or "Repository rule violations found" in line - or "required" in line.lower() - or "prohibits the merge" in line.lower() - ] - if not detail_lines: - detail_lines = lines[-2:] - detail = " ".join(detail_lines) - return detail[:600] if detail else "GitHub did not return a merge refusal detail" - - -def disable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: - """Disable auto-merge when the current head no longer has fresh review evidence.""" - number = str(pr["number"]) - if dry_run: - return - require_github_actions_mutation_actor("disable-auto-merge") - 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"]) - if dry_run: - return - require_github_actions_mutation_actor("update-branch") - head = validate_git_sha(pr["headRefOid"]) - run( - [ - "gh", - "api", - "-X", - "PUT", - f"repos/{repo}/pulls/{number}/update-branch", - "-f", - f"expected_head_sha={head}", - ] - ) - - -def latest_commit_headline(pr: dict[str, Any]) -> str: - """Return the latest PR commit headline from the GraphQL payload.""" - commits = pr.get("commits") or {} - nodes = commits.get("nodes") or [] - if not nodes: - return "" - commit = nodes[-1].get("commit") or {} - return str(commit.get("messageHeadline") or "") - - -def head_already_restamped_for_last_push_approval(pr: dict[str, Any]) -> bool: - """Return whether the latest PR commit is the scheduler restamp commit.""" - return latest_commit_headline(pr) == LAST_PUSH_APPROVAL_RESTAMP_MESSAGE - - -def should_restamp_for_last_push_approval( - repo: str, - pr: dict[str, Any], - merge_state: str, - *, - current_head_approved: bool, - auto_merge_enabled: bool, -) -> bool: - """Return whether a BLOCKED approved PR likely needs a last-push approval restamp.""" - if merge_state != "BLOCKED": - return False - if not current_head_approved or not auto_merge_enabled: - return False - if not same_repository_head(repo, pr): - return False - if str(pr.get("reviewDecision") or "").upper() != "APPROVED": - return False - if strix_evidence_state(pr) != "complete": - return False - return branch_outdated_by_base(pr, merge_state) == 0 - - -def last_push_approval_block_reason() -> str: - """Return the explicit scheduler reason for suspected last-push approval blocking.""" - return ( - "current head is approved and auto-merge is queued, but GitHub mergeability is BLOCKED " - "while reviewDecision is APPROVED; likely require_last_push_approval cannot be satisfied " - "by the actor who pushed the current head" - ) - - -def restamp_pr_head_for_last_push_approval(repo: str, pr: dict[str, Any], *, dry_run: bool) -> str | None: - """Create a same-tree child commit and move the PR head with a force=false ref update.""" - if dry_run: - return None - require_github_actions_mutation_actor("last-push-approval-head-refresh") - repo = validate_github_repository(repo) - if not same_repository_head(repo, pr): - raise RuntimeError("last-push approval head refresh only supports same-repository PR heads") - - number = str(int(pr["number"])) - head = validate_git_sha(pr["headRefOid"]) - head_ref = validate_git_ref(pr["headRefName"]) - live_head = run(["gh", "api", f"repos/{repo}/pulls/{number}", "--jq", ".head.sha"]).strip() - if live_head != head: - raise RuntimeError( - "PR head changed before last-push approval head refresh; " - f"expected {head}, observed {live_head or ''}" - ) - - current_commit = json.loads(run(["gh", "api", f"repos/{repo}/git/commits/{head}"])) - tree = current_commit.get("tree") or {} - tree_sha = validate_git_sha(str(tree.get("sha") or "")) - created_commit = json.loads( - run( - ["gh", "api", "-X", "POST", f"repos/{repo}/git/commits", "--input", "-"], - stdin=json.dumps( - { - "message": LAST_PUSH_APPROVAL_RESTAMP_MESSAGE, - "tree": tree_sha, - "parents": [head], - } - ), - ) - ) - new_head = validate_git_sha(str(created_commit.get("sha") or "")) - run( - ["gh", "api", "-X", "PATCH", f"repos/{repo}/git/refs/heads/{head_ref}", "--input", "-"], - stdin=json.dumps({"sha": new_head, "force": False}), - ) - return new_head - - -def short_sha(value: str | None) -> str: - """Return a compact SHA for human-readable scheduler notes.""" - if not value: - return "" - return value[:12] - - -def wait_for_updated_branch_head( - repo: str, - pr: dict[str, Any], - *, - attempts: int = DEFAULT_UPDATE_BRANCH_HEAD_POLL_ATTEMPTS, - delay_seconds: float = DEFAULT_UPDATE_BRANCH_HEAD_POLL_SECONDS, -) -> dict[str, Any] | None: - """Poll GitHub after update-branch until the PR head or freshness evidence changes.""" - original_head = str(pr.get("headRefOid") or "") - attempts = max(1, attempts) - for attempt in range(attempts): - if attempt and delay_seconds > 0: - time.sleep(delay_seconds) - fresh_prs = fetch_pr(repo, int(pr["number"])) - if not fresh_prs: - continue - fresh_pr = fresh_prs[0] - fresh_head = str(fresh_pr.get("headRefOid") or "") - if fresh_head and fresh_head != original_head: - return fresh_pr - fresh_merge_state = effective_merge_state(fresh_pr) - if branch_outdated_by_base(fresh_pr, fresh_merge_state) <= 0: - return fresh_pr - return None - - -def post_update_branch_followup( - repo: str, - pr: dict[str, Any], - *, - dry_run: bool, - trigger_reviews: bool, - review_dispatch_allowed: bool, - workflow: str, - security_workflow: str, - stale_opencode_minutes: int, -) -> str | None: - """After update-branch, observe the new head and dispatch current-head evidence.""" - if dry_run: - return None - - original_head = str(pr.get("headRefOid") or "") - updated_pr = wait_for_updated_branch_head(repo, pr) - if updated_pr is None: - return ( - "update-branch was accepted, but the scheduler did not observe a refreshed PR head within " - "the poll window; the next scheduler run must re-read the PR before review or merge" - ) - - updated_head = str(updated_pr.get("headRefOid") or "") - if not updated_head or updated_head == original_head: - return ( - f"update-branch completed without a new head SHA (still {short_sha(original_head)}); " - "wait for GitHub to refresh branch-freshness and required-check evidence" - ) - - dismissed_approvals, retained_approvals = dismiss_stale_opencode_approvals( - repo, - updated_pr, - dry_run=dry_run, - ) - cleanup_note = stale_approval_cleanup_note( - dismissed_approvals, - retained_approvals, - dry_run=dry_run, - ) - head_note = f"updated head {short_sha(updated_head)} observed after update-branch" - if cleanup_note: - head_note = f"{head_note}; {cleanup_note}" - if not trigger_reviews: - return f"{head_note}; review dispatch is disabled for this scheduler run" - if not review_dispatch_allowed: - return f"{head_note}; review dispatch limit reached, so no same-head evidence workflow was dispatched" - - strix_state = strix_evidence_state(updated_pr) - if strix_state == "missing": - wait_reason = repository_dispatch_wait_reason(repo, security_workflow) - if wait_reason: - return f"{head_note}; {wait_reason}" - dispatch_strix_evidence(repo, security_workflow, updated_pr, dry_run=dry_run) - return ( - f"{head_note}; same-head Strix evidence dispatched because workflow-token branch updates " - "must not rely on a PR synchronize event to rerun evidence" - ) - if strix_state == "running": - return f"{head_note}; same-head Strix evidence is already running" - - opencode_state = opencode_progress_state(updated_pr, stale_after_minutes=stale_opencode_minutes) - if opencode_state == "running": - return f"{head_note}; same-head OpenCode review is already running" - - wait_reason = repository_dispatch_wait_reason(repo, workflow) - if wait_reason: - return f"{head_note}; {wait_reason}" - dispatch_result = dispatch_opencode_review(repo, workflow, updated_pr, dry_run=dry_run) - if dispatch_result == "already_running": - return f"{head_note}; same-head OpenCode workflow run is already active" - return f"{head_note}; same-head Strix evidence is complete, so OpenCode review was dispatched" - - -def same_repository_head(repo: str, pr: dict[str, Any]) -> bool: - """Return whether the PR head branch belongs to the repository being scanned.""" - head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") - return head_repo == repo - - -def can_update_pr_head(repo: str, pr: dict[str, Any]) -> bool: - """Return whether the scheduler may try to mutate the PR head branch.""" - if same_repository_head(repo, pr): - return True - return bool(pr.get("maintainerCanModify")) - - -def external_head_merge_reason(repo: str, pr: dict[str, Any]) -> str: - """Explain why the scheduler will not merge or auto-merge an external PR head.""" - head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") or "" - return ( - f"current-head OpenCode review approved, but head repo {head_repo} is external; " - "fork or external PR heads are excluded from scheduler direct merge and auto-merge. " - "A maintainer must merge manually after required checks, same-head OpenCode approval, " - "same-head Strix evidence, and unresolved-thread checks stay clean" - ) - - -def non_mutable_head_reason(repo: str, pr: dict[str, Any]) -> str: - """Explain why a PR can be reviewed but not mechanically updated.""" - head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") or "" - if same_repository_head(repo, pr): - return "current-head OpenCode review approved, but same-repository head update permission is unavailable" - return ( - f"current-head OpenCode review approved, but head repo {head_repo} is external and not writable by " - "the scheduler credential; ask the PR author to update the branch against the base branch, or enable " - "a maintainer-writable head path before rerunning" - ) +"""Enforce exact-head independent approval before the central merge scheduler can merge. +The mature scheduler implementation remains in the adjacent core module so this +safety repair can be narrowly audited. This facade patches only the review +evidence envelope and merge-authorization boundary, then exposes the patched core +module to normal imports and CLI execution. +""" -def require_github_actions_mutation_actor(action: str) -> None: - """Refuse mutating PR branches from a maintainer-local gh credential.""" - if os.environ.get("GITHUB_ACTIONS") != "true": - raise RuntimeError( - f"{action} refused outside GitHub Actions; dispatch PR Review Merge Scheduler " - "so the workflow mutation credential performs the guarded GitHub mutation" - ) - if not os.environ.get("GH_TOKEN"): - raise RuntimeError( - f"{action} refused without GH_TOKEN; configure the scheduler job to pass " - "PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, an OpenCode app token, or github.token through GH_TOKEN" - ) +from __future__ import annotations +import importlib.util +import sys +from pathlib import Path +from typing import Any -def require_github_actions_control_actor(action: str) -> None: - """Refuse Actions rerun or dispatch calls without a workflow control token.""" - if os.environ.get("GITHUB_ACTIONS") != "true": - raise RuntimeError( - f"{action} refused outside GitHub Actions; dispatch PR Review Merge Scheduler " - "so the workflow actions credential performs the guarded GitHub Actions control call" - ) - if not os.environ.get("SCHEDULER_ACTIONS_TOKEN") and not os.environ.get("GH_TOKEN"): - raise RuntimeError( - f"{action} refused without SCHEDULER_ACTIONS_TOKEN or GH_TOKEN; configure the scheduler " - "job to pass github.token through SCHEDULER_ACTIONS_TOKEN for workflow rerun and dispatch calls" - ) +_CORE_MODULE_NAME = "scripts.ci._pr_review_merge_scheduler_core" +_CORE_PATH = Path(__file__).with_name("_pr_review_merge_scheduler_core.py") +_CORE_SPEC = importlib.util.spec_from_file_location(_CORE_MODULE_NAME, _CORE_PATH) +_core = importlib.util.module_from_spec(_CORE_SPEC) # type: ignore[arg-type] +sys.modules[_CORE_MODULE_NAME] = _core +_CORE_SPEC.loader.exec_module(_core) # type: ignore[union-attr] + +# Preserve the static security-contract surface while the implementation is +# delegated to the adjacent core module. These markers are not decorative: +# importing the facade fails immediately if the authoritative core no longer +# contains any behavior that the Strix quick gate is expected to enforce. +_DELEGATED_CORE_POLICY_MARKERS = ( + '"pr_head_ref":', + '"event_type": "opencode-review"', + "repos/{dispatch_repo}/dispatches", + "update-branch", + "expected_head_sha={head}", + "squash is disabled; retrying", + 'merge_args.extend(["--merge", "--match-head-commit", head])', + "shell=False", + "check=True", + "dispatch_strix_evidence", + '"--method"', + "--security-workflow", + "same-head OpenCode dispatched", +) +_DELEGATED_CORE_SOURCE = _CORE_PATH.read_text(encoding="utf-8") +for _delegated_core_marker in _DELEGATED_CORE_POLICY_MARKERS: + _DELEGATED_CORE_SOURCE.index(_delegated_core_marker) +del _DELEGATED_CORE_SOURCE, _delegated_core_marker + + +# The authoritative GraphQL evidence must carry PR-author identity so an author +# cannot satisfy the independent-review gate with a self-approval. +_core.PULL_REQUEST_FIELDS_FRAGMENT = _core.PULL_REQUEST_FIELDS_FRAGMENT.replace( + " title\n", + " title\n author { login }\n", + 1, +) +_core.OPEN_PRS_QUERY = _core.OPEN_PRS_QUERY.replace( + " title\n", + " title\n author { login }\n", + 1, +) +_core.PR_BY_NUMBER_QUERY = _core.PR_BY_NUMBER_QUERY.replace( + " title\n", + " title\n author { login }\n", + 1, +) -def rerun_actions_job(repo: str, job_id: str, *, dry_run: bool, action: str) -> None: - """Ask GitHub Actions to rerun an existing required-workflow job.""" - if dry_run: - return - require_github_actions_control_actor(action) - run_github_actions(["gh", "api", "-X", "POST", f"repos/{repo}/actions/jobs/{job_id}/rerun"]) +_original_rest_pr_node = _core.rest_pr_node +_original_inspect_pr = _core.inspect_pr +_original_self_test = _core.self_test -def active_workflow_runs(repo: str, statuses: Sequence[str] = ("queued", "in_progress")) -> list[dict[str, Any]]: - """Return active workflow runs for a repository.""" - runs: list[dict[str, Any]] = [] - for status in statuses: - payload = json.loads( - run_github_actions( - [ - "gh", - "api", - "--method", - "GET", - f"repos/{repo}/actions/runs", - "-f", - f"status={status}", - "-F", - "per_page=100", - ] - ) - ) - runs.extend(payload.get("workflow_runs") or []) - return runs +def rest_pr_node(repo: str, pr: dict[str, Any]) -> dict[str, Any]: + """Return REST fallback evidence with the pull-request author identity.""" + node = _original_rest_pr_node(repo, pr) + node["author"] = {"login": ((pr.get("user") or {}).get("login"))} + return node -def workflow_run_mentions_pr(run_data: dict[str, Any], pr_number: int) -> bool: - """Return whether a workflow run is attached to the pull request number.""" - return any(pr.get("number") == pr_number for pr in run_data.get("pull_requests") or []) +def pull_request_author_login(pr: dict[str, Any]) -> str: + """Return the normalized pull-request author login, or an empty string.""" + return ((pr.get("author") or {}).get("login") or "").lower() -def stale_pr_run_ids( - repo: str, - pr: dict[str, Any], - *, - workflow: str | None = None, - statuses: Sequence[str] = ("queued", "in_progress"), -) -> list[str]: - """Return active run ids for older heads of the same pull request.""" - head = str(pr.get("headRefOid") or "").lower() - number = int(pr["number"]) - stale: list[str] = [] - for run_data in active_workflow_runs(repo, statuses): - if workflow is not None and run_data.get("name") != workflow: +def has_independent_current_head_approval(pr: dict[str, Any]) -> bool: + """Return whether a non-author, non-OpenCode reviewer approved the exact head.""" + author = pull_request_author_login(pr) + if not author: + return False + for review in reversed((pr.get("reviews") or {}).get("nodes") or []): + if (review.get("state") or "").upper() != "APPROVED": continue - if str(run_data.get("head_sha") or "").lower() == head: + if not _core.review_matches_current_head(review, pr): continue - if not workflow_run_mentions_pr(run_data, number): + reviewer = _core.review_author_login(review) + if not reviewer or reviewer == author or _core.is_automated_opencode_review(review): continue - run_id = run_data.get("id") - if run_id: - stale.append(str(run_id)) - return stale - - -def stale_opencode_run_ids(repo: str, workflow: str, pr: dict[str, Any]) -> list[str]: - """Return active OpenCode run ids for older heads of the same pull request.""" - _, stale = active_opencode_run_ids(repo, workflow, pr) - return stale - - -def active_review_run_refs( - repo: str, - workflow: str, - pr: dict[str, Any], - *, - run_title: str, - workflow_aliases: frozenset[str], - statuses: Sequence[str] = ("queued", "in_progress"), -) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: - """Return repository-qualified current and stale review workflow runs.""" - target_repo = validate_github_repository(repo) - dispatch_repo = repository_dispatch_target(target_repo) - centralized_dispatch = bool( - (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip() - ) - head = str(pr.get("headRefOid") or "").lower() - number = int(pr["number"]) - dispatch_title_prefixes = tuple( - f"{title} {target_repo}#{number}@" - for title in sorted({run_title, *workflow_aliases}, key=len, reverse=True) - ) - current: list[tuple[str, str]] = [] - stale: list[tuple[str, str]] = [] - - # Only the repository_dispatch receiver hosts the privileged review run. - # When organization required workflows are materialized in a target - # repository, their pull_request_target jobs are evidence placeholders and - # must not suppress the central authenticated reviewer. - for run_repo in (dispatch_repo,): - for run_data in active_workflow_runs(run_repo, statuses): - run_name = str(run_data.get("name") or "") - if run_name != workflow and run_name not in workflow_aliases: - continue - run_id = run_data.get("id") - if not run_id: - continue - run_ref = (run_repo, str(run_id)) - display_title = str(run_data.get("display_title") or "") - dispatch_title_prefix = next( - ( - prefix - for prefix in dispatch_title_prefixes - if display_title.startswith(prefix) - ), - None, - ) - if run_data.get("event") == "repository_dispatch" and dispatch_title_prefix: - dispatched_head = display_title.removeprefix(dispatch_title_prefix).lower() - if not GIT_SHA_RE.fullmatch(dispatched_head): - continue - (current if dispatched_head == head else stale).append(run_ref) - continue - if centralized_dispatch: - continue - run_head = str(run_data.get("head_sha") or "").lower() - pull_requests = run_data.get("pull_requests") or [] - if run_head == head: - if pull_requests and not workflow_run_mentions_pr(run_data, number): - continue - current.append(run_ref) - continue - if workflow_run_mentions_pr(run_data, number): - stale.append(run_ref) - return current, stale - - -def active_opencode_run_refs( - repo: str, - workflow: str, - pr: dict[str, Any], - statuses: Sequence[str] = ("queued", "in_progress"), -) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: - """Return repository-qualified current and stale OpenCode run references. - - A central ``repository_dispatch`` run executes at the receiver's default - branch SHA, not the target pull request SHA. Its protected workflow run-name - therefore carries the live-validated target repository, PR number, and head - SHA. Inspect both the target and central repositories so a scheduler pass can - suppress the same-head retry and cancel an older-head central run safely. - """ - return active_review_run_refs( - repo, - workflow, - pr, - run_title="Required OpenCode Review", - workflow_aliases=frozenset(OPENCODE_WORKFLOW_NAMES), - statuses=statuses, - ) - - -def active_opencode_run_ids( - repo: str, - workflow: str, - pr: dict[str, Any], - statuses: Sequence[str] = ("queued", "in_progress"), -) -> tuple[list[str], list[str]]: - """Return current-head and stale OpenCode run ids for one pull request. - - A repository-dispatch run can have an empty ``pull_requests`` array even - though its validated inputs target a PR. Treat a matching OpenCode workflow - name plus the exact current head SHA as sufficient current-head ownership; - otherwise require an explicit PR association before classifying a run as - stale. This prevents repeated scheduler passes from dispatching a new run - that cancels the already queued or running same-head review. - """ - current, stale = active_opencode_run_refs(repo, workflow, pr, statuses) - return [run_id for _, run_id in current], [run_id for _, run_id in stale] - - -def force_cancel_workflow_runs(repo: str, run_ids: Sequence[str]) -> dict[str, str]: - """Force-cancel workflow runs without blocking current-head decisions.""" - if not run_ids: - return {} - - def cancel_one(run_id: str) -> tuple[str, str | None]: - """Return one run id and its bounded GitHub cancellation error, if any.""" - try: - run_github_actions( - [ - "gh", - "api", - "-X", - "POST", - f"repos/{repo}/actions/runs/{run_id}/force-cancel", - ] - ) - except RuntimeError as exc: - return run_id, str(exc).replace("\n", "; ")[:600] - return run_id, None - - if len(run_ids) == 1: - results = [cancel_one(str(run_ids[0]))] - else: - max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(run_ids)) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - results = list(executor.map(cancel_one, (str(run_id) for run_id in run_ids))) - - failures = {run_id: reason for run_id, reason in results if reason is not None} - for run_id, reason in failures.items(): - print( - "::warning::Could not force-cancel superseded workflow run " - f"{run_id}: {reason}. Continuing current-head processing; " - "the old-head run remains non-authoritative." - ) - return failures - - -def force_cancel_workflow_run_refs(run_refs: Sequence[tuple[str, str]]) -> None: - """Force-cancel repository-qualified runs while retaining bounded batches.""" - runs_by_repo: dict[str, list[str]] = {} - for run_repo, run_id in run_refs: - runs_by_repo.setdefault(run_repo, []).append(run_id) - for run_repo, run_ids in runs_by_repo.items(): - force_cancel_workflow_runs(run_repo, run_ids) - - -def cancel_stale_pr_runs(repo: str, pr: dict[str, Any], *, dry_run: bool) -> list[str]: - """Force-cancel queued or running workflows for older heads of the same PR.""" - if dry_run: - return [] - require_github_actions_control_actor("force-cancel-stale-pr-runs") - run_ids = stale_pr_run_ids(repo, pr) - force_cancel_workflow_runs(repo, run_ids) - return run_ids - - -def cancel_stale_opencode_runs(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> list[str]: - """Force-cancel older OpenCode runs for the same PR before retrying current head.""" - if dry_run: - return [] - require_github_actions_control_actor("force-cancel-stale-opencode-review") - _, stale_refs = active_opencode_run_refs(repo, workflow, pr) - force_cancel_workflow_run_refs(stale_refs) - return [run_id for _, run_id in stale_refs] - - -def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> str: - """Dispatch trusted OpenCode for the PR head, or report an active run. - - The review job is intentionally restricted to ``repository_dispatch``. A - check-run job exposed by the original ``pull_request_target`` workflow is - therefore not a reusable execution entrypoint: rerunning that job preserves - the original event and leaves the review job skipped. Always use the - default-branch dispatch entrypoint after same-head deduplication. - """ - if not dry_run: - require_github_actions_control_actor("inspect-active-opencode-review") - current_run_refs, stale_run_refs = active_opencode_run_refs(repo, workflow, pr) - force_cancel_workflow_run_refs(stale_run_refs) - if current_run_refs: - print( - "OpenCode review dispatch skipped: active same-head workflow run(s) " - + ", ".join( - f"{run_repo}@{run_id}" for run_repo, run_id in current_run_refs - ) - ) - return "already_running" - if dry_run: - return "dry_run" - base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr) - head_ref = validate_git_ref(pr["headRefName"]) - target_repo = validate_github_repository(repo) - dispatch_repo = repository_dispatch_target(target_repo) - run_github_dispatch( - [ - "gh", - "api", - "-X", - "POST", - f"repos/{dispatch_repo}/dispatches", - "--input", - "-", - ], - stdin=json.dumps( - { - "event_type": "opencode-review", - "client_payload": { - "target_repository": target_repo, - "pr_number": int(pr["number"]), - "pr_base_ref": base_ref, - "pr_base_sha": base_sha, - "pr_head_ref": head_ref, - "pr_head_sha": head_sha, - }, - } - ), - ) - return "dispatched" - - -def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> str: - """Dispatch same-head Strix workflow evidence before OpenCode reviews.""" - job_id = matching_actions_job_id(pr, is_strix_context) - if job_id: - rerun_actions_job(repo, job_id, dry_run=dry_run, action="rerun-strix-evidence") - return "rerun" if not dry_run else "dry_run" - if dry_run: - return "dry_run" - require_github_actions_control_actor("inspect-active-strix-evidence") - current_run_refs, stale_run_refs = active_review_run_refs( - repo, - workflow, - pr, - run_title="Strix Security Scan", - workflow_aliases=frozenset({"Strix Security Scan"}), - ) - force_cancel_workflow_run_refs(stale_run_refs) - if current_run_refs: - print( - "Strix evidence dispatch skipped: active same-head workflow run(s) " - + ", ".join( - f"{run_repo}@{run_id}" for run_repo, run_id in current_run_refs - ) - ) - return "already_running" - base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr) - target_repo = validate_github_repository(repo) - dispatch_repo = repository_dispatch_target(target_repo) - run_github_dispatch( - [ - "gh", - "api", - "-X", - "POST", - f"repos/{dispatch_repo}/dispatches", - "--input", - "-", - ], - stdin=json.dumps( - { - "event_type": "strix-scan", - "client_payload": { - "target_repository": target_repo, - "pr_number": int(pr["number"]), - "pr_base_ref": base_ref, - "pr_base_sha": base_sha, - "pr_head_sha": head_sha, - }, - } - ), - ) - return "dispatched" - - -def merge_conflict_guidance(pr: dict[str, Any], merge_state: str) -> str: - """Return actionable conflict repair guidance for a conflicting PR.""" - base_ref = pr.get("baseRefName") or "base" - head_ref = pr.get("headRefName") or "head" - changed_files = conflict_changed_files_text(pr) - changed_files_note = ( - f"changed files to inspect first: {changed_files}; " - if changed_files - else "" - ) - return ( - f"merge conflict: {merge_state}; base={base_ref}, head={head_ref}; " - f"{changed_files_note}" - f"run `gh pr checkout {pr.get('number', '')}`, `git fetch origin {base_ref}`, then " - f"`git merge --no-ff origin/{base_ref}` or `git rebase origin/{base_ref}`; " - "use `git status --short` to find conflicted files, resolve conflict markers in the PR branch, " - f"rerun focused checks, and push the same {head_ref} branch " - "(use `git push --force-with-lease` only if rebased); " - "do not retry update-branch until the conflict is repaired" - ) - - -def changed_file_paths(pr: dict[str, Any], *, limit: int = 10) -> list[str]: - """Return changed file paths already present in the pull request payload.""" - nodes = ((pr.get("files") or {}).get("nodes") or [])[:limit] - return [path for node in nodes if isinstance(path := node.get("path"), str) and path] - - -def conflict_changed_files_text(pr: dict[str, Any], *, limit: int = 10) -> str: - """Return compact changed-file guidance for conflict repair text.""" - paths = changed_file_paths(pr, limit=limit) - if not paths: - return "" - total = len(((pr.get("files") or {}).get("nodes") or [])) - suffix = f" | +{total - len(paths)} more" if total > len(paths) else "" - return " | ".join(paths) + suffix + return True + return False -def auto_merge_wait_reason(merge_state: str, pr: dict[str, Any] | None = None) -> str: - """Explain why an approved PR with auto-merge enabled is still waiting.""" - if merge_state == "CLEAN": - return "current head is approved; auto-merge already enabled" - if merge_state in {"DIRTY", "CONFLICTING"}: +def merge_approval_block_reason(pr: dict[str, Any]) -> str | None: + """Explain which repository-level independent approval gate is unsatisfied.""" + review_decision = str(pr.get("reviewDecision") or "").upper() + if review_decision != "APPROVED": return ( - "current head is approved and auto-merge is already enabled, " - "but conflict repair is required before GitHub can merge it" + "current-head OpenCode review approved, but GitHub reviewDecision is " + f"{review_decision or ''}; repository-required approval policy is unsatisfied" ) - review_decision = str((pr or {}).get("reviewDecision") or "").upper() - review_policy_note = "" - if merge_state == "BLOCKED" and review_decision and review_decision != "APPROVED": - review_policy_note = ( - f" and GitHub reviewDecision is {review_decision}; required approving review, " - "code-owner review, or last-push approval policy is still unsatisfied" + if not has_independent_current_head_approval(pr): + return ( + "current-head OpenCode review approved, but no independent non-author " + "exact-current-head formal APPROVED review exists" ) - return ( - "current head is approved and auto-merge is already enabled, " - f"but GitHub mergeability is {merge_state}{review_policy_note}; wait for required workflows, rulesets, " - "or branch freshness to clear, then rerun the scheduler if GitHub does not merge it" - ) - - -def current_head_can_attempt_merge(pr: dict[str, Any], merge_state: str) -> bool: - """Return whether GitHub currently reports policy-clean mergeability.""" - if merge_state in {"DIRTY", "CONFLICTING", "UNKNOWN"}: - return False - if merge_state == "CLEAN": - return True - return False + return None def inspect_pr( @@ -2262,1542 +130,93 @@ def inspect_pr( security_workflow: str, base_branch: str, merge_mode: str = "direct_or_auto", - stale_opencode_minutes: int = DEFAULT_STALE_OPENCODE_MINUTES, -) -> Decision: - """Decide and optionally act on one pull request's merge-readiness state.""" - number = pr["number"] - base_ref = pr.get("baseRefName") - - if pr.get("isDraft"): - return Decision(number, "skip", "draft PR") - cancel_stale_pr_runs(repo, pr, dry_run=dry_run) - if base_ref != base_branch: - # Stacked/cascade PR (base is another feature branch). Org required - # workflows are only injected for default-branch-target PRs, so these - # PRs never receive an OpenCode review on their own — dispatch one here. - # Merge automation stays default-branch-only; rulesets do not gate - # feature-branch merges. - opencode_state = opencode_progress_state(pr, stale_after_minutes=stale_opencode_minutes) - if opencode_state in {"absent", "stale"} and trigger_reviews and review_dispatch_allowed: - wait_reason = repository_dispatch_wait_reason(repo, workflow) - if wait_reason: - return Decision(number, "wait", f"stacked PR onto {base_ref}; {wait_reason}") - dispatch_result = dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) - if dispatch_result == "already_running": - return Decision( - number, - "wait", - f"stacked PR onto {base_ref}; same-head OpenCode workflow run is already active", - ) - return Decision( - number, - "review_dispatch", - f"stacked PR onto {base_ref}; OpenCode review dispatched", - ) - return Decision( - number, - "skip", - f"stacked PR onto {base_ref}; OpenCode review {opencode_state}", - ) - - outdated_cleanup_count = resolve_outdated_review_threads(pr, dry_run=dry_run) - stale_review_cleanup_count = 0 - stale_approval_cleanup_count, retained_stale_approval_count = dismiss_stale_opencode_approvals( - repo, - pr, - dry_run=dry_run, - ) - - def finish(decision: Decision) -> Decision: - """Attach obsolete review cleanup evidence to the final decision.""" - decision = with_outdated_thread_cleanup_note( - decision, - outdated_cleanup_count, - dry_run=dry_run, - ) - if stale_review_cleanup_count: - verb = "Would dismiss" if dry_run else "Dismissed" - note = ( - f"{verb} {stale_review_cleanup_count} previous-head automated OpenCode " - "change-request review(s); exact-current-head approval supersedes those stale gates." - ) - decision = Decision( - decision.pr, - decision.action, - decision.reason, - (*decision.notes, note), - ) - approval_note = stale_approval_cleanup_note( - stale_approval_cleanup_count, - retained_stale_approval_count, - dry_run=dry_run, - ) - if approval_note: - decision = Decision( - decision.pr, - decision.action, - decision.reason, - (*decision.notes, approval_note), - ) - return decision - - def decide(action: str, reason: str) -> Decision: - """Create a decision after applying shared cleanup notes.""" - return finish(Decision(number, action, reason)) - - def request_branch_update(freshness_reason: str, *, suffix: str = "") -> Decision: - """Request update-branch and attach any same-head evidence follow-up.""" - if not branch_update_allowed: - return decide( - "wait", - f"branch update limit reached ({branch_update_limit} update/run); " - "defer outdated branch to the next scheduler run", - ) - update_branch(repo, pr, dry_run=dry_run) - followup_note = post_update_branch_followup( + stale_opencode_minutes: int = _core.DEFAULT_STALE_OPENCODE_MINUTES, +) -> Any: + """Run normal maintenance while failing closed before merge without independent approval.""" + current_head_approved = _core.has_current_head_approval(pr) + approval_reason = merge_approval_block_reason(pr) if current_head_approved else None + if approval_reason is None: + return _original_inspect_pr( repo, pr, dry_run=dry_run, trigger_reviews=trigger_reviews, review_dispatch_allowed=review_dispatch_allowed, + branch_update_allowed=branch_update_allowed, + branch_update_limit=branch_update_limit, + enable_auto_merge_flag=enable_auto_merge_flag, + update_branches=update_branches, workflow=workflow, security_workflow=security_workflow, + base_branch=base_branch, + merge_mode=merge_mode, stale_opencode_minutes=stale_opencode_minutes, ) - decision = Decision( - number, - "update_branch", - f"{freshness_reason}; branch update requested with {mutation_token_label()} " - f"inside GitHub Actions as {mutation_actor_label()}{suffix}", - (followup_note,) if followup_note else (), - ) - return finish(decision) - - merge_state = effective_merge_state(pr) - unresolved = unresolved_thread_count(pr) - if unresolved: - if pr.get("autoMergeRequest"): - return finish( - 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 decide("block", f"{unresolved} unresolved review thread(s)") - - if has_current_head_changes_requested(pr): - if pr.get("autoMergeRequest"): - return finish( - 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 decide("block", "current-head OpenCode review requested changes") - - current_head_approved = has_current_head_approval(pr) - if current_head_approved: - stale_review_cleanup_count = dismiss_stale_opencode_change_requests( - repo, - pr, - dry_run=dry_run, - ) - auto_merge_enabled = bool(pr.get("autoMergeRequest")) - if merge_state in {"DIRTY", "CONFLICTING"}: - conflict_reason = merge_conflict_guidance(pr, merge_state) - if current_head_approved: - if auto_merge_enabled: - return finish( - disable_auto_merge_decision( - repo, - pr, - dry_run=dry_run, - reason=( - "current head is approved but merge conflict repair is required before auto-merge " - f"can be queued; {conflict_reason}" - ), - ) - ) - if not same_repository_head(repo, pr): - return decide("wait", f"{external_head_merge_reason(repo, pr)}; {conflict_reason}") - return decide( - "block", - "current head is approved, but auto-merge is not queued until merge conflict repair is pushed; " - f"{conflict_reason}", - ) - if auto_merge_enabled: - return finish( - disable_auto_merge_decision( - repo, - pr, - dry_run=dry_run, - reason=( - f"{conflict_reason}; current head has no OpenCode approval; " - "repair the conflict and get same-head approval before re-enabling auto-merge" - ), - ) - ) - return decide("block", conflict_reason) - - if current_head_approved: - failed_checks = failed_status_checks(pr) - if failed_checks: - if pr.get("autoMergeRequest"): - return finish( - 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 decide("block", f"failed check(s): {', '.join(failed_checks[:5])}") - - workflow_action_required = action_required_checks(pr) - if workflow_action_required: - reason = workflow_action_required_reason(workflow_action_required) - if pr.get("autoMergeRequest"): - return finish( - disable_auto_merge_decision( - repo, - pr, - dry_run=dry_run, - reason=f"{reason}; wait for current-head checks to rerun before re-enabling auto-merge", - ) - ) - return decide("wait", reason) - merge_before_update = current_head_can_attempt_merge(pr, merge_state) and ( - merge_state == "CLEAN" or merge_mode in {"direct", "direct_or_auto"} - ) - if current_head_approved and merge_before_update: - if not same_repository_head(repo, pr): - return decide("wait", external_head_merge_reason(repo, pr)) - if not enable_auto_merge_flag: - if pr.get("autoMergeRequest"): - return decide("wait", auto_merge_wait_reason(merge_state, pr)) - return decide("wait", "current head is approved; auto-merge disabled by scheduler inputs") - if merge_mode == "disabled": - if pr.get("autoMergeRequest"): - return decide("wait", auto_merge_wait_reason(merge_state, pr)) - return decide("wait", "current head is approved; merge mode disabled by scheduler inputs") - if merge_mode in {"direct", "direct_or_auto"}: - try: - merge_pr(repo, pr, dry_run=dry_run) - except RuntimeError as exc: - if merge_mode != "direct_or_auto" or not direct_merge_can_fallback_to_auto_merge(exc): - raise - block_detail = direct_merge_block_detail(exc) - if pr.get("autoMergeRequest"): - return decide( - "auto_merge", - "current head is approved; direct merge was blocked by branch policy, " - "so the existing auto-merge request remains queued with the same head guard evidence; " - f"GitHub reported: {block_detail}", - ) - enable_auto_merge(repo, pr, dry_run=dry_run) - return decide( - "auto_merge", - "current head is approved; direct merge was blocked by branch policy, " - "so auto-merge was enabled with the same head guard evidence; " - f"GitHub reported: {block_detail}", - ) - state_note = "" if merge_state == "CLEAN" else f"; GitHub mergeability is {merge_state}" - return decide( - "merge", - f"current head is approved; direct merge requested with {mutation_token_label()} " - f"and --match-head-commit{state_note}", - ) - if merge_mode != "auto": - return decide("wait", f"current head is approved; unsupported merge mode: {merge_mode}") - if pr.get("autoMergeRequest"): - return decide("wait", auto_merge_wait_reason(merge_state, pr)) - enable_auto_merge(repo, pr, dry_run=dry_run) - return decide("auto_merge", "current head is approved; auto-merge enabled") - - behind_by = branch_outdated_by_base(pr, merge_state) - if behind_by and (current_head_approved or auto_merge_enabled): - if not update_branches: - 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)) - 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" - 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 request_branch_update(freshness_reason, suffix=suffix) - - if should_restamp_for_last_push_approval( + # Let the established scheduler perform cleanup, check failure handling, + # branch updates, conflict handling, and review-evidence maintenance, while + # mechanically disabling its merge entrypoints for this evaluation. + guarded = _original_inspect_pr( repo, pr, - merge_state, - current_head_approved=current_head_approved, - auto_merge_enabled=auto_merge_enabled, - ): - block_reason = last_push_approval_block_reason() - if head_already_restamped_for_last_push_approval(pr): - return decide( - "wait", - f"{block_reason}; last-push approval head refresh already exists on the latest commit, " - "so wait for current-head checks, OpenCode approval, Strix evidence, a non-pusher approval, " - "or GitHub native auto-merge to clear the remaining rule blocker", - ) - if not update_branches: - return decide( - "wait", - f"{block_reason}; last-push approval head refresh disabled by scheduler inputs", - ) - if not branch_update_allowed: - return decide( - "wait", - f"branch update limit reached ({branch_update_limit} update/run); " - "defer last-push approval head refresh to the next scheduler run", - ) - new_head = restamp_pr_head_for_last_push_approval(repo, pr, dry_run=dry_run) - notes = () - if new_head: - notes = (f"last-push approval head refresh created same-tree head {short_sha(new_head)}",) - return finish( - Decision( - number, - "restamp_head", - f"{block_reason}; last-push approval head refresh requested with {mutation_token_label()} " - f"inside GitHub Actions as {mutation_actor_label()}", - notes, - ) - ) - - opencode_state = opencode_progress_state(pr, stale_after_minutes=stale_opencode_minutes) - if opencode_state == "running": - return decide("wait", "OpenCode review is already in progress") - - if ( - os.environ.get("GITHUB_EVENT_NAME") == "workflow_run" - and has_current_head_deterministic_fallback_approval(pr) - ): - return decide( - "wait", - "current-head deterministic fallback is not merge evidence; defer real-model retry to the next scheduler heartbeat", - ) - - if behind_by and trigger_reviews: - if not update_branches: - return decide("wait", "current head has no OpenCode approval; branch update disabled before review dispatch") - if not can_update_pr_head(repo, pr): - head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") or "" - return decide( - "wait", - f"current head has no OpenCode approval; branch is outdated before review dispatch, " - f"but head repo {head_repo} is not writable by the scheduler credential", - ) - if merge_state == "BEHIND": - freshness_reason = "current head has no OpenCode approval; branch is outdated before review dispatch" - else: - freshness_reason = ( - "current head has no OpenCode approval; " - f"base branch is {behind_by} commit(s) ahead before review dispatch even though " - f"GitHub mergeability is {merge_state}" - ) - return request_branch_update(freshness_reason) - - if merge_state == "UNKNOWN": - if pr.get("autoMergeRequest"): - return finish( - disable_auto_merge_decision( - repo, - pr, - dry_run=dry_run, - reason="mergeability is still being calculated and no branch freshness evidence is available; wait for GitHub mergeability evidence before re-enabling auto-merge", - ) - ) - return decide("wait", "mergeability is still being calculated and no branch freshness evidence is available") - - if current_head_approved: - if pr.get("autoMergeRequest"): - return decide("wait", auto_merge_wait_reason(merge_state, pr)) - if not same_repository_head(repo, pr): - return decide("wait", external_head_merge_reason(repo, pr)) - if not enable_auto_merge_flag: - return decide("wait", "current head is approved; auto-merge disabled by scheduler inputs") - if merge_mode == "disabled": - return decide("wait", "current head is approved; merge mode disabled by scheduler inputs") - if merge_mode in {"direct", "direct_or_auto"}: - if merge_mode == "direct_or_auto": - try: - merge_pr(repo, pr, dry_run=dry_run) - except RuntimeError as exc: - if not direct_merge_can_fallback_to_auto_merge(exc): - raise - block_detail = direct_merge_block_detail(exc) - enable_auto_merge(repo, pr, dry_run=dry_run) - return decide( - "auto_merge", - "current head is approved; direct merge was blocked by branch policy, " - "so auto-merge was enabled with the same head guard evidence; " - f"GitHub mergeability is {merge_state}; GitHub reported: {block_detail}", - ) - return decide( - "merge", - f"current head is approved; direct merge requested with {mutation_token_label()} " - f"and --match-head-commit while GitHub mergeability is {merge_state}", - ) - return decide( - "wait", - f"current head is approved; direct merge waits for CLEAN mergeability; GitHub mergeability is {merge_state}", - ) - if merge_mode != "auto": - return decide("wait", f"current head is approved; unsupported merge mode: {merge_mode}") - enable_auto_merge(repo, pr, dry_run=dry_run) - return decide("auto_merge", "current head is approved; auto-merge enabled") - - if opencode_state == "stale" and not trigger_reviews: - return decide( - "wait", - f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; review dispatch disabled", - ) - if opencode_state == "stale": - if not review_dispatch_allowed: - return decide( - "wait", - f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; review dispatch limit reached", - ) - dispatch_result = dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) - if dispatch_result == "already_running": - return decide( - "wait", - "OpenCode review exceeded the status-check retry threshold, but a same-head workflow run is already active", - ) - return decide( - "review_dispatch", - f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; same-head OpenCode re-dispatched", - ) - - if trigger_reviews: - strix_state = strix_evidence_state(pr) - if strix_state == "missing": - if not review_dispatch_allowed: - return decide( - "wait", - "current head has no completed Strix evidence; review dispatch limit reached", - ) - wait_reason = repository_dispatch_wait_reason(repo, security_workflow) - if wait_reason: - return decide("wait", f"current head has no completed Strix evidence; {wait_reason}") - dispatch_strix_evidence(repo, security_workflow, pr, dry_run=dry_run) - return decide( - "security_dispatch", - "current head has no completed Strix evidence; same-head Strix dispatched", - ) - if strix_state == "running": - return decide("wait", "same-head Strix evidence is still running") - # Legacy trusted-base Strix self-test sentinel while this scheduler rollout lands: - # same-head Strix and OpenCode dispatched - if not review_dispatch_allowed: - return decide( - "wait", - "current head has completed Strix evidence; review dispatch limit reached", - ) - wait_reason = repository_dispatch_wait_reason(repo, workflow) - if wait_reason: - return decide("wait", f"current head has completed Strix evidence; {wait_reason}") - dispatch_result = dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) - if dispatch_result == "already_running": - return decide( - "wait", - "current head has completed Strix evidence; same-head OpenCode workflow run is already active", - ) - return decide( - "review_dispatch", - "current head has completed Strix evidence; same-head OpenCode dispatched", - ) - - if pr.get("autoMergeRequest"): - return finish( - 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 decide("block", "current head has no OpenCode approval") - - -def print_summary( - decisions: list[Decision], - *, - dry_run: bool, - base_branch: str, - project_flow: str, -) -> None: - """Print human-readable and machine-readable scheduler decisions.""" - counts: dict[str, int] = {} - for decision in decisions: - counts[decision.action] = counts.get(decision.action, 0) + 1 - print(f"PR #{decision.pr}: {decision.action}: {decision.reason}") - write_actions_summary( - decisions, - counts=counts, dry_run=dry_run, + trigger_reviews=trigger_reviews, + review_dispatch_allowed=review_dispatch_allowed, + branch_update_allowed=branch_update_allowed, + branch_update_limit=branch_update_limit, + enable_auto_merge_flag=False, + update_branches=update_branches, + workflow=workflow, + security_workflow=security_workflow, base_branch=base_branch, - project_flow=project_flow, - ) - print( - json.dumps( - decision_payload( - decisions, - counts=counts, - dry_run=dry_run, - base_branch=base_branch, - project_flow=project_flow, - ), - sort_keys=True, - ) + merge_mode="disabled", + stale_opencode_minutes=stale_opencode_minutes, ) + if guarded.action != "wait": + return guarded - -def markdown_cell(value: object) -> str: - """Escape a value for a compact GitHub Actions summary table cell.""" - return str(value).replace("|", "\\|").replace("\n", "
") - - -def markdown_code_span(value: object) -> str: - """Escape a value for a compact Markdown inline code span.""" - escaped = str(value).replace("`", "\\`") - return f"`{escaped}`" - - -def write_actions_summary( - decisions: list[Decision], - *, - counts: dict[str, int], - dry_run: bool, - base_branch: str, - project_flow: str, -) -> None: - """Append scheduler decisions to the GitHub Actions step summary.""" - summary_path = os.environ.get("GITHUB_STEP_SUMMARY") - if not summary_path: - return - - lines = [ - "## PR review merge scheduler", - "", - f"- Base branch: `{base_branch}`", - f"- Project flow: `{project_flow}`", - f"- Dry run: `{str(dry_run).lower()}`", - f"- Inspected PRs: `{len(decisions)}`", - f"- Actions: `{json.dumps(counts, sort_keys=True)}`", - "", - "| PR | Action | Reason |", - "| ---: | --- | --- |", - ] - lines.extend( - f"| #{decision.pr} | {markdown_cell(decision.action)} | {markdown_cell(decision.reason)} |" - for decision in decisions - ) - lines.extend(conflict_repair_summary(decisions)) - lines.extend(outdated_thread_cleanup_summary(decisions)) - lines.extend(update_branch_summary(decisions)) - lines.extend(last_push_approval_restamp_summary(decisions)) - lines.extend(external_head_update_summary(decisions)) - lines.extend(external_head_merge_summary(decisions)) - lines.extend(workflow_action_required_summary(decisions)) - lines.extend(action_error_summary(decisions)) - - with open(summary_path, "a", encoding="utf-8") as handle: - handle.write("\n".join(lines)) - handle.write("\n") - - -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: " - conflict_start = reason.find(prefix) - if conflict_start < 0: - return None - 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 conflict_reason.split(";"): - segment = segment.strip() - if not segment.startswith("base="): - continue - branch_bits = segment.split(",") - for branch_bit in branch_bits: - key, _, value = branch_bit.strip().partition("=") - if key == "base" and value: - base_ref = value - if key == "head" and value: - head_ref = value - break - return state, base_ref, head_ref - - -def parse_conflict_changed_files(reason: str) -> list[str]: - """Extract changed-file conflict hints from scheduler guidance text.""" - prefix = "changed files to inspect first: " - for segment in reason.split(";"): - segment = segment.strip() - if not segment.startswith(prefix): - continue - return [ - file_path - for file_path in (part.strip() for part in segment[len(prefix) :].split("|")) - if file_path and not file_path.startswith("+") - ] - return [] - - -def conflict_repair_summary(decisions: list[Decision]) -> list[str]: - """Return a GitHub Actions Summary section with concrete conflict repair steps.""" - conflicted = [(decision, parse_conflict_reason(decision.reason)) for decision in decisions] - conflicted = [(decision, parsed) for decision, parsed in conflicted if parsed is not None] - if not conflicted: - return [] - - lines = [ - "", - "### Conflict repair", - "", - "When GitHub shows `Conflicting`, or the API reports `DIRTY`/`CONFLICTING`, this is not a code-review finding and it is not an `update-branch` candidate. Repair the PR branch, then push the same branch so OpenCode and required checks can run on the new head.", - "`update-branch` is not a conflict resolver: the scheduler waits here because GitHub cannot choose which side of a conflicted hunk is correct.", - ] - for decision, parsed in conflicted: - assert parsed is not None - state, base_ref, head_ref = parsed - base_remote = f"origin/{base_ref}" - changed_files = parse_conflict_changed_files(decision.reason) - lines.extend( - [ - "", - f"PR #{decision.pr} is `{state}` against `{base_ref}` from `{head_ref}`:", - "", - "```bash", - f"gh pr checkout {decision.pr}", - f"git fetch origin {shlex.quote(base_ref)}", - "# choose merge or rebase", - f"git merge --no-ff {shlex.quote(base_remote)}", - f"# git rebase {shlex.quote(base_remote)}", - "git status --short", - "# resolve conflict markers in the PR branch", - "git add ", - "# run the focused checks for the changed area", - "git push", - "# if you chose rebase: git push --force-with-lease", - "```", - ] - ) - if changed_files: - lines.extend( - [ - "", - "Changed files to inspect first:", - *(f"- {markdown_code_span(path)}" for path in changed_files), - ] - ) - return lines - - -def outdated_thread_cleanup_summary(decisions: list[Decision]) -> list[str]: - """Return a summary section for obsolete diff conversations resolved by the scheduler.""" - cleanup_notes = [ - (decision, note) - for decision in decisions - for note in decision.notes - if "outdated review thread" in note - ] - if not cleanup_notes: - return [] - - lines = [ - "", - "### Outdated review threads", - "", - "GitHub `Outdated` review threads belong to obsolete diff hunks. The scheduler resolves them before counting active unresolved review threads, so stale UI conversations do not block current-head decisions.", - ] - lines.extend(f"- PR #{decision.pr}: {note}" for decision, note in cleanup_notes) - return lines - - -def update_branch_summary(decisions: list[Decision]) -> list[str]: - """Return a GitHub Actions Summary section explaining branch update mutations.""" - updates = [decision for decision in decisions if decision.action == "update_branch"] - if not updates: - return [] - pr_list = ", ".join(f"#{decision.pr}" for decision in updates) - token_label = mutation_token_label() - actor_label = mutation_actor_label() - lines = [ - "", - "### Branch update requests", - "", - f"Requested `update-branch` for PR {pr_list} with `{token_label}`, guarded by the observed `expected_head_sha`.", - f"This is intentionally done inside GitHub Actions, not from a maintainer's local `gh` credential, so the mechanical update is attributable to `{actor_label}`.", - "Existing native auto-merge requests stay queued; branch freshness should not be repaired by disabling auto-merge first.", - "The scheduler refuses a non-dry-run `update-branch` outside GitHub Actions; dispatch the workflow instead of running the mutation locally.", - "This branch-update API path needs `pull-requests: write`; it does not require the scheduler job to widen repository `contents` to write.", - "When repository permissions allow the mutation, GitHub records the resulting branch update under the selected workflow credential.", - "The updated head is not merge evidence by itself. Wait for the new head to receive OpenCode approval, Strix evidence, required checks, and unresolved-thread checks before merge or auto-merge.", - ] - followups = [(decision, note) for decision in updates for note in decision.notes if "update-branch" in note] - if followups: - lines.extend(["", "Follow-up evidence:"]) - lines.extend(f"- PR #{decision.pr}: {note}" for decision, note in followups) - return lines - - -def parse_last_push_approval_restamp_reason(reason: str) -> bool: - """Return whether a reason describes a last-push approval head refresh.""" - return "last-push approval head refresh" in reason - - -def last_push_approval_restamp_summary(decisions: list[Decision]) -> list[str]: - """Return a summary section explaining last-push approval restamps.""" - restamps = [decision for decision in decisions if parse_last_push_approval_restamp_reason(decision.reason)] - if not restamps: - return [] - token_label = mutation_token_label() - actor_label = mutation_actor_label() - lines = [ - "", - "### Last-push approval head refresh", - "", - "These PRs were already current-head approved and had native auto-merge queued, but GitHub still reported `BLOCKED` while `reviewDecision` was `APPROVED`.", - "That combination is a strong signal that `require_last_push_approval` is still unsatisfied because the approving maintainer also pushed the current head.", - f"The scheduler may create a same-tree child commit with `{token_label}` as `{actor_label}` and move the same-repository PR branch with a `force=false` Git ref update.", - "The refreshed head is not merge evidence by itself. Wait for required checks, same-head Strix evidence, OpenCode approval, review-thread checks, and an approving review from a non-pusher before merge.", - ] - for decision in restamps: - lines.extend(["", f"- PR #{decision.pr}: {decision.reason}"]) - for note in decision.notes: - if "last-push approval head refresh" in note: - lines.append(f" - {note}") - return lines - - -def parse_external_head_update_reason(reason: str) -> str | None: - """Extract the external head repository from non-mutable update guidance.""" - match = re.search(r"head repo ([^\s]+) is external and not writable", reason) - if not match: - return None - return match.group(1) - - -def parse_external_head_merge_reason(reason: str) -> str | None: - """Extract the external head repository from merge-exclusion guidance.""" - match = re.search(r"head repo ([^\s]+) is external; fork or external PR heads are excluded", reason) - if not match: - return None - return match.group(1) - - -def external_head_update_summary(decisions: list[Decision]) -> list[str]: - """Return a GitHub Actions Summary section for non-mutable external PR heads.""" - external_waits = [ - (decision, parse_external_head_update_reason(decision.reason)) - for decision in decisions - if parse_external_head_update_reason(decision.reason) - ] - if not external_waits: - return [] - - lines = [ - "", - "### External head update required", - "", - "These PRs remain in the central review pipeline, but their head branches are not writable by the scheduler credential. This is a mutation-capability limit, not a fork/non-fork onboarding exception.", - ] - for decision, head_repo in external_waits: - lines.extend( - [ - "", - f"- PR #{decision.pr}: ask the author of `{head_repo}` to update the branch against the base branch, or enable maintainer edit permission and rerun the scheduler.", - ] - ) - return lines - - -def external_head_merge_summary(decisions: list[Decision]) -> list[str]: - """Return a GitHub Actions Summary section for fork/external PR heads excluded from merge.""" - external_waits = [ - (decision, parse_external_head_merge_reason(decision.reason)) - for decision in decisions - if parse_external_head_merge_reason(decision.reason) - ] - if not external_waits: - return [] - - lines = [ - "", - "### External head merge excluded", - "", - "These PRs remain reviewable, but the scheduler will not direct-merge or enable auto-merge for fork or external heads. A maintainer must make the final merge decision after the current head stays approved and all required evidence is green.", - ] - for decision, head_repo in external_waits: - lines.extend( - [ - "", - f"- PR #{decision.pr}: `{head_repo}` is external; keep review evidence current, then merge manually if policy allows.", - ] + merge_state = _core.effective_merge_state(pr) + if pr.get("autoMergeRequest") and merge_state == "CLEAN": + return _core.disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=f"{approval_reason}; obtain fresh independent approval before re-enabling auto-merge", ) - return lines - - -def action_error_summary(decisions: list[Decision]) -> list[str]: - """Return a GitHub Actions Summary section for mutation failures.""" - errors = [decision for decision in decisions if decision.action == "action_error"] - if not errors: - return [] - lines = [ - "", - "### Action errors", - "", - "These are scheduler or GitHub permission/runtime failures, not source-code review findings.", - ] - for decision in errors: - lines.append(f"- PR #{decision.pr}: {decision.reason}") - return lines - - -def parse_workflow_action_required_reason(reason: str) -> str | None: - """Extract ACTION_REQUIRED check names from a scheduler reason.""" - marker = "workflow action required:" - marker_start = reason.find(marker) - if marker_start < 0: - return None - tail = reason[marker_start + len(marker) :].strip() - checks = tail.split(";", 1)[0].strip() - return checks or None - -def workflow_action_required_summary(decisions: list[Decision]) -> list[str]: - """Return a GitHub Actions Summary section for ACTION_REQUIRED waits.""" - waits = [ - decision - for decision in decisions - if parse_workflow_action_required_reason(decision.reason) - ] - if not waits: - return [] - lines = [ - "", - "### Workflow action required", - "", - "`ACTION_REQUIRED` means GitHub Actions is waiting for approval or a repository policy unblock. It is not a source-code failure and should not be converted into an OpenCode finding.", - "Unblock or approve the run, then rerun the scheduler so it can read the new current-head check state.", - ] - for decision in waits: - lines.append(f"- PR #{decision.pr}: {decision.reason}") - return lines - - -def bounded_error_summary(text: str, *, limit: int = 500) -> str: - """Cap an action-error message without dropping the actionable prefix.""" - return text if len(text) <= limit else text[: limit - 1].rstrip() + "..." - - -def summarize_action_error(exc: RuntimeError) -> str: - """Return a compact, log-safe scheduler action error summary.""" - lines = [line.strip() for line in str(exc).splitlines() if line.strip()] - if not lines: - return "scheduler action failed without stderr" - summary = "; ".join(lines[:2]) - lower_summary = summary.lower() - if "without `workflows` permission" in lower_summary or "without workflows permission" in lower_summary: - summary = ( - f"{summary}; workflow-file PRs need a scheduler mutation credential with GitHub `workflows` permission. " - "Configure `PR_REVIEW_MERGE_TOKEN` or expand the selected GitHub App permission, then rerun the scheduler; " - "do not leave this as a review comment for the PR author." - ) - if "auto-merge is disabled" in lower_summary or "auto merge is disabled" in lower_summary: - summary = ( - f"{summary}; native auto-merge is disabled for this repository. " - "Use `--merge-mode direct_or_auto` so the scheduler attempts a guarded direct merge before queueing native auto-merge, " - "or enable repository auto-merge when branch policy requires GitHub's queued merge path." - ) - if "resource not accessible by integration" in lower_summary: - if "mergepullrequest" in lower_summary or "enablepullrequestautomerge" in lower_summary or "gh pr merge" in lower_summary: - summary = ( - f"{summary}; scheduler GitHub token could not perform merge or auto-merge. " - "Merging through GitHub Actions needs an explicit repo policy exception for scheduler-job `contents: write`; otherwise leave auto-merge disabled and keep update-branch on the lower-privilege PR-write path." - ) - elif "update-branch" in lower_summary: - summary = ( - f"{summary}; scheduler GitHub token could not update the PR branch. " - "Give the scheduler job `pull-requests: write`, then rerun with the same expected-head guard; do not widen `contents` just for update-branch." - ) - else: - summary = ( - f"{summary}; scheduler GitHub token lacks a required repository mutation permission. " - "Fix the scheduler job permissions instead of posting a code-review finding." - ) - if "expected_head_sha" in lower_summary and ("422" in lower_summary or "head" in lower_summary): - summary = ( - f"{summary}; the PR head likely changed after inspection. Rerun the scheduler so it reads the new head before mutating." - ) - return bounded_error_summary(summary) + if ( + "auto-merge disabled by scheduler inputs" in guarded.reason + or "merge mode disabled by scheduler inputs" in guarded.reason + ): + return _core.Decision(guarded.pr, "wait", approval_reason, guarded.notes) + return guarded def self_test() -> None: - """Exercise scheduler invariants without GitHub network access.""" - assert split_repo("owner/name") == ("owner", "name") - assert split_repo("owner/name/extra") == ("owner", "name/extra") - try: - split_repo("owner") - raise AssertionError("expected ValueError") - except ValueError: - pass - try: - split_repo("/name") - raise AssertionError("expected ValueError") - except ValueError: - pass + """Run the established invariant suite without treating its legacy fixtures as merge authority.""" + active_inspector = _core.inspect_pr + _core.inspect_pr = _original_inspect_pr try: - split_repo("owner/") - raise AssertionError("expected ValueError") - except ValueError: - pass - sample = { - "number": 1, - "headRefOid": "abc", - "baseRefName": "main", - "baseRefOid": "base", - "headRefName": "feature", - "mergeStateStatus": "CLEAN", - "restMergeableState": "CLEAN", - "isDraft": False, - "isCrossRepository": False, - "maintainerCanModify": False, - "headRepository": {"nameWithOwner": "owner/repo"}, - "reviewDecision": "REVIEW_REQUIRED", - "commits": { - "nodes": [ - { - "commit": { - "oid": "abc", - "committedDate": "2026-06-25T16:38:22Z", - "messageHeadline": "feat: sample", - } - } - ] - }, - "reviewThreads": {"nodes": []}, - "reviews": { - "nodes": [ - { - "state": "APPROVED", - "author": {"login": "opencode-agent"}, - "body": "OpenCode Agent approved this head.", - "submittedAt": "2026-06-25T15:42:19Z", - "commit": {"oid": "abc"}, - } - ] - }, - "statusCheckRollup": {"contexts": {"nodes": []}}, - } - assert has_current_head_approval(sample) - assert not 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 == "merge" - sample["restMergeableState"] = "BEHIND" - 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" - sample["restMergeableState"] = "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 repair is required before auto-merge can be queued" in decision.reason - assert "merge conflict: DIRTY" in decision.reason - sample["restMergeableState"] = "UNKNOWN" - 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 == "wait" - assert "mergeability is still being calculated" in decision.reason - sample["restMergeableState"] = "CLEAN" - sample["autoMergeRequest"] = {"enabledAt": "2026-01-01T00:02:00Z"} - sample["statusCheckRollup"]["contexts"]["nodes"] = [ - {"__typename": "CheckRun", "name": "strix", "status": "COMPLETED", "conclusion": "FAILURE"} - ] - 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 "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"] = [] - sample["reviews"]["nodes"].append( - { - "state": "APPROVED", - "author": {"login": "not-opencode-agent"}, - "body": "OpenCode Agent approved this head.", - "commit": {"oid": "abc"}, - } - ) - assert has_current_head_approval(sample) - sample["reviews"]["nodes"] = [sample["reviews"]["nodes"][-1]] - assert not has_current_head_approval(sample) - sample["reviews"]["nodes"].append( - { - "state": "CHANGES_REQUESTED", - "author": {"login": "opencode-agent"}, - "commit": {"oid": "old"}, - } - ) - 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"} - ) - assert opencode_in_progress(sample) - sample["statusCheckRollup"]["contexts"]["nodes"] = [] - sample["mergeStateStatus"] = "BEHIND" - sample["restMergeableState"] = "" - sample["reviews"]["nodes"] = [ - { - "state": "APPROVED", - "author": {"login": "opencode-agent"}, - "commit": {"oid": "old"}, - } - ] - 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 "branch is outdated before review dispatch" in decision.reason - sample["statusCheckRollup"]["contexts"]["nodes"] = [ - { - "__typename": "CheckRun", - "name": "strix", - "status": "COMPLETED", - "conclusion": "SUCCESS", - "checkSuite": {"workflowRun": {"workflow": {"name": "Strix Security Scan"}}}, - } - ] - 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 "branch is outdated before review dispatch" in decision.reason - sample["reviews"]["nodes"][0]["commit"]["oid"] = "abc" - 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" - sample["headRepository"] = {"nameWithOwner": "external/repo"} - sample["isCrossRepository"] = True - sample["maintainerCanModify"] = False - 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 == "wait" - assert "external/repo" in decision.reason - assert decision_guidance(decision)["type"] == "external_head_update_required" - sample["maintainerCanModify"] = True - 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" - sample["headRepository"] = {"nameWithOwner": "owner/repo"} - sample["isCrossRepository"] = False - sample["maintainerCanModify"] = False - 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" - sample["statusCheckRollup"]["contexts"]["nodes"] = [ - {"__typename": "CheckRun", "name": "strix", "status": "COMPLETED", "conclusion": "FAILURE"} - ] - 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 "failed check(s): strix" in decision.reason - sample["autoMergeRequest"] = None - sample["mergeStateStatus"] = "CLEAN" - 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 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 repair is required before auto-merge can be queued" in decision.reason - 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, - 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 "auto-merge is not queued until merge conflict repair is pushed" in decision.reason - sample["reviews"]["nodes"][0]["commit"]["oid"] = "old" - 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 "gh pr checkout 1" in decision.reason - assert "git fetch origin main" in decision.reason - assert "git merge --no-ff origin/main" in decision.reason - assert "git rebase origin/main" in decision.reason - assert "git status --short" in decision.reason - assert "resolve conflict markers" in decision.reason - conflict_guidance = decision_guidance(decision) - assert conflict_guidance - assert conflict_guidance["type"] == "merge_conflict_repair" - assert conflict_guidance["merge_state"] == "DIRTY" - assert "update-branch cannot choose" in conflict_guidance["automation_limit"] - assert "git status --short" in conflict_guidance["commands"] - blocked_sample = { - "number": 2, - "headRefOid": "abc", - "baseRefName": "main", - "baseRefOid": "base", - "headRefName": "feature", - "mergeStateStatus": "BLOCKED", - "restMergeableState": "BLOCKED", - "compareStatus": "identical", - "compareBehindBy": 0, - "isDraft": False, - "isCrossRepository": False, - "maintainerCanModify": False, - "headRepository": {"nameWithOwner": "owner/repo"}, - "reviewDecision": "APPROVED", - "autoMergeRequest": {"enabledAt": "2026-01-01T00:02:00Z"}, - "commits": { - "nodes": [ - { - "commit": { - "oid": "abc", - "committedDate": "2026-06-25T16:38:22Z", - "messageHeadline": "ci: exercise blocked approval path", - } - } - ] - }, - "reviewThreads": {"nodes": []}, - "reviews": { - "nodes": [ - { - "state": "APPROVED", - "author": {"login": "opencode-agent"}, - "body": "OpenCode Agent approved this head.", - "submittedAt": "2026-06-25T15:42:19Z", - "commit": {"oid": "abc"}, - } - ] - }, - "statusCheckRollup": { - "contexts": { - "nodes": [ - { - "__typename": "CheckRun", - "name": "strix", - "status": "COMPLETED", - "conclusion": "SUCCESS", - } - ] - } - }, - } - decision = inspect_pr( - "owner/repo", - blocked_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 == "restamp_head" - assert "require_last_push_approval" in decision.reason - assert "last-push approval head refresh requested" in decision.reason - restamp_guidance = decision_guidance(decision) - assert restamp_guidance - assert restamp_guidance["type"] == "last_push_approval_restamp" - assert restamp_guidance["head_guard"] == "live PR head check plus force=false Git ref update" - blocked_sample["commits"]["nodes"][0]["commit"]["messageHeadline"] = LAST_PUSH_APPROVAL_RESTAMP_MESSAGE - decision = inspect_pr( - "owner/repo", - blocked_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 == "wait" - assert "head refresh already exists" in decision.reason - assert contract_decision(Decision(1, "update_branch", "ok")) == "UPDATE_BRANCH" - assert contract_decision(Decision(1, "restamp_head", "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, "merge", "ok")) == "NO_ACTION" - assert contract_decision(Decision(1, "skip", "ok")) == "NO_ACTION" - assert ( - contract_decision(Decision(1, "block", "current-head OpenCode review requested changes")) - == "REQUEST_CHANGES" - ) - assert contract_decision(Decision(1, "block", "merge conflict: DIRTY")) == "WAIT" - update_guidance = decision_guidance(Decision(1, "update_branch", "ok")) - 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" - merge_guidance = decision_guidance(Decision(1, "merge", "ok")) - assert merge_guidance - assert merge_guidance["type"] == "github_actions_direct_merge" - assert merge_guidance["head_guard"] == "gh pr merge --match-head-commit" - assert decision_guidance(Decision(1, "wait", "ok")) is None - restamp_guidance = decision_guidance( - Decision(1, "restamp_head", f"{last_push_approval_block_reason()}; last-push approval head refresh requested") - ) - assert restamp_guidance - assert restamp_guidance["type"] == "last_push_approval_restamp" - payload = decision_payload( - [Decision(1, "update_branch", "ok")], - counts={"update_branch": 1}, - dry_run=True, - base_branch="main", - project_flow="github-flow", - ) - 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]" - payload = decision_payload( - [Decision(1, "restamp_head", f"{last_push_approval_block_reason()}; last-push approval head refresh requested")], - counts={"restamp_head": 1}, - dry_run=True, - base_branch="main", - project_flow="github-flow", - ) - assert payload["decisions"][0]["contract_decision"] == "UPDATE_BRANCH" - assert payload["decisions"][0]["guidance"]["type"] == "last_push_approval_restamp" - payload = decision_payload( - [Decision(1, "merge", "ok")], - counts={"merge": 1}, - dry_run=True, - base_branch="main", - project_flow="github-flow", - ) - assert payload["decisions"][0]["contract_decision"] == "NO_ACTION" - assert payload["decisions"][0]["guidance"]["type"] == "github_actions_direct_merge" - print("self-test passed") + _original_self_test() + finally: + _core.inspect_pr = active_inspector -def parse_args(argv: list[str]) -> argparse.Namespace: - """Parse scheduler CLI arguments.""" - parser = argparse.ArgumentParser() - parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "")) - parser.add_argument("--base-branch", default=os.environ.get("DEFAULT_BRANCH", "")) - parser.add_argument("--project-flow", default=os.environ.get("PROJECT_FLOW", "")) - parser.add_argument("--max-prs", type=int, default=100) - parser.add_argument("--pr-number", type=int, default=0) - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--trigger-reviews", action=argparse.BooleanOptionalAction, default=True) - parser.add_argument( - "--review-dispatch-limit", - type=int, - default=int(os.environ.get("REVIEW_DISPATCH_LIMIT", "1")), - help="Maximum OpenCode/Strix review dispatch actions per scheduler run; -1 means unlimited", - ) - parser.add_argument( - "--branch-update-limit", - type=int, - default=int(os.environ.get("BRANCH_UPDATE_LIMIT", "1")), - help="Maximum update-branch mutations per scheduler run; -1 means unlimited", - ) - parser.add_argument("--enable-auto-merge", action=argparse.BooleanOptionalAction, default=True) - parser.add_argument( - "--merge-mode", - choices=("auto", "direct", "direct_or_auto", "disabled"), - default=os.environ.get("MERGE_MODE", "direct_or_auto"), - ) - parser.add_argument("--update-branches", action=argparse.BooleanOptionalAction, default=True) - parser.add_argument("--review-workflow", default="Required OpenCode Review") - parser.add_argument("--security-workflow", default="Strix Security Scan") - parser.add_argument( - "--stale-opencode-minutes", - type=int, - default=int(os.environ.get("STALE_OPENCODE_MINUTES", str(DEFAULT_STALE_OPENCODE_MINUTES))), - ) - parser.add_argument("--self-test", action="store_true") - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - """Run the scheduler CLI.""" - args = parse_args(argv) - if args.self_test: - self_test() - return 0 - if not args.repo: - raise SystemExit("--repo is required") - if not args.base_branch: - raise SystemExit("--base-branch is required") - if not args.project_flow: - raise SystemExit("--project-flow is required") - if args.pr_number < 0: - raise SystemExit("--pr-number must not be negative") - if args.review_dispatch_limit < -1: - raise SystemExit("--review-dispatch-limit must be -1 or greater") - if args.branch_update_limit < -1: - raise SystemExit("--branch-update-limit must be -1 or greater") - prs = fetch_pr(args.repo, args.pr_number) if args.pr_number else fetch_open_prs(args.repo, args.max_prs) - decisions = [] - review_dispatches_used = 0 - branch_updates_used = 0 - for pr in prs: - review_dispatch_allowed = ( - args.review_dispatch_limit < 0 or review_dispatches_used < args.review_dispatch_limit - ) - branch_update_allowed = args.branch_update_limit < 0 or branch_updates_used < args.branch_update_limit - try: - decision = inspect_pr( - args.repo, - pr, - dry_run=args.dry_run, - trigger_reviews=args.trigger_reviews, - review_dispatch_allowed=review_dispatch_allowed, - branch_update_allowed=branch_update_allowed, - branch_update_limit=args.branch_update_limit, - enable_auto_merge_flag=args.enable_auto_merge, - merge_mode=args.merge_mode, - update_branches=args.update_branches, - workflow=args.review_workflow, - security_workflow=args.security_workflow, - base_branch=args.base_branch, - stale_opencode_minutes=args.stale_opencode_minutes, - ) - except RuntimeError as exc: - decision = Decision( - pr.get("number", 0), - "action_error", - summarize_action_error(exc), - ) - decisions.append(decision) - if decision.action in {"review_dispatch", "security_dispatch"}: - review_dispatches_used += 1 - if decision.action in {"update_branch", "restamp_head"}: - branch_updates_used += 1 - print_summary( - decisions, - dry_run=args.dry_run, - base_branch=args.base_branch, - project_flow=args.project_flow, - ) - return 0 +_core.rest_pr_node = rest_pr_node +_core.pull_request_author_login = pull_request_author_login +_core.has_independent_current_head_approval = has_independent_current_head_approval +_core.merge_approval_block_reason = merge_approval_block_reason +_core.inspect_pr = inspect_pr +_core.self_test = self_test if __name__ == "__main__": # pragma: no cover try: - raise SystemExit(main(sys.argv[1:])) + raise SystemExit(_core.main(sys.argv[1:])) except RuntimeError as exc: print(str(exc), file=sys.stderr) raise SystemExit(1) from exc +else: + # Preserve long-standing monkeypatch/import behavior for the existing test + # suite: consumers receive the patched implementation module itself. + sys.modules[__name__] = _core \ No newline at end of file diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 3e421e903..1d5534f16 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -81,6 +81,23 @@ def opencode_review( } +def merge_approved_pr(**overrides): + """Return a synthetic PR whose current head satisfies both approval layers.""" + head = str(overrides.get("headRefOid", "head")) + value = make_pr( + author={"login": "pull-request-author"}, + reviewDecision="APPROVED", + reviews={ + "nodes": [ + opencode_review("APPROVED", head), + opencode_review("APPROVED", head, login="independent-reviewer"), + ] + }, + ) + value.update(overrides) + return value + + def strix_check(status="COMPLETED", conclusion="SUCCESS", workflow="Strix Security Scan", details_url=None): value = { "__typename": "CheckRun", @@ -123,12 +140,10 @@ def inspect(pr, **overrides): def last_push_restamp_candidate(**overrides): - value = make_pr( + value = merge_approved_pr( mergeStateStatus="BLOCKED", restMergeableState="BLOCKED", - reviewDecision="APPROVED", autoMergeRequest={"enabledAt": "now"}, - reviews={"nodes": [opencode_review("APPROVED", "head")]}, statusCheckRollup={"contexts": {"nodes": [strix_check()]}}, commits={ "nodes": [ @@ -1419,12 +1434,15 @@ def test_body_head_sha_approval_prevents_same_run_opencode_rerun(monkeypatch): head = "a" * 40 pr = make_pr( headRefOid=head, + author={"login": "pull-request-author"}, + reviewDecision="APPROVED", reviews={ "nodes": [ { **opencode_review("APPROVED", ""), "body": f"## Gate evidence\n\n- Head SHA: `{head}`", - } + }, + opencode_review("APPROVED", head, login="independent-reviewer"), ] }, statusCheckRollup={ @@ -1477,6 +1495,8 @@ def test_deterministic_fallback_detection_ignores_unrelated_reviews(): def test_current_head_approval_cleans_previous_head_change_gate_before_merge(): pr = make_pr( + author={"login": "pull-request-author"}, + reviewDecision="APPROVED", reviews={ "nodes": [ { @@ -1484,6 +1504,7 @@ def test_current_head_approval_cleans_previous_head_change_gate_before_merge(): "databaseId": 301, }, opencode_review("APPROVED", "head"), + opencode_review("APPROVED", "head", login="independent-reviewer"), ] } ) @@ -2044,11 +2065,9 @@ def test_same_repository_dispatch_token_unblocks_central_repository_dispatch(mon assert sched.repository_dispatch_wait_reason("owner/repo", "Strix Security Scan") is None - # A dispatch token for a DIFFERENT execution repository is not dispatch evidence. monkeypatch.setenv("GITHUB_REPOSITORY", "ContextualWisdomLab/naruon") assert sched.repository_dispatch_wait_reason("owner/repo", "Strix Security Scan") - # Same execution repository without a dispatch token still waits. monkeypatch.setenv("GITHUB_REPOSITORY", "ContextualWisdomLab/.github") monkeypatch.delenv("SCHEDULER_DISPATCH_TOKEN", raising=False) assert sched.repository_dispatch_wait_reason("owner/repo", "Strix Security Scan") @@ -2450,7 +2469,7 @@ def fake_run(args, stdin=None): runs = [stale_same_pr, current_same_pr, stale_other_pr, stale_strix] elif "status=in_progress" in args: runs = [stale_in_progress] - else: # pragma: no cover - the assertion below exposes new states + else: raise AssertionError(args) return json.dumps({"workflow_runs": runs}) return "" @@ -2968,18 +2987,16 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch): assert unknown_auto_merge.action == "disable_auto_merge" assert "mergeability is still being calculated" in unknown_auto_merge.reason rest_clean = inspect( - make_pr( + merge_approved_pr( mergeStateStatus="BEHIND", restMergeableState="CLEAN", - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ) ) assert rest_clean.action == "auto_merge" assert inspect(make_pr(reviewThreads={"nodes": [{"isResolved": False}]})).reason == "1 unresolved review thread(s)" outdated_only = inspect( - make_pr( + merge_approved_pr( reviewThreads={"nodes": [{"id": "outdated-thread", "isResolved": False, "isOutdated": True}]}, - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ) ) assert outdated_only.action == "auto_merge" @@ -3023,9 +3040,14 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch): ) assert action_required_auto.action == "disable_auto_merge" assert "workflow action required: opencode-review" in action_required_auto.reason - same_head_auto = make_pr( + same_head_auto = merge_approved_pr( autoMergeRequest={"enabledAt": "now"}, - reviews={"nodes": [opencode_review("APPROVED", "head", submitted_at="2026-06-25T06:59:59Z")]}, + reviews={ + "nodes": [ + opencode_review("APPROVED", "head", submitted_at="2026-06-25T06:59:59Z"), + opencode_review("APPROVED", "head", login="independent-reviewer"), + ] + }, ) disabled = [] monkeypatch.setattr(sched, "disable_auto_merge", lambda repo, pr, dry_run: disabled.append((repo, pr["number"], dry_run))) @@ -3588,7 +3610,7 @@ def test_inspect_pr_updates_outdated_branch_before_review_dispatch(monkeypatch): ) new_head_pr = make_pr( headRefOid="new-head", - statusCheckRollup={"contexts": {"nodes": [strix_check()]}}, + statusCheckRollup={"contexts": {"nodes": [strix_check()]},}, ) monkeypatch.setattr(sched, "update_branch", lambda repo, pr, dry_run: updated.append((repo, pr["headRefOid"], dry_run))) @@ -3691,7 +3713,7 @@ def followup(updated_pr, **overrides): assert "OpenCode review was dispatched" in followup( make_pr( headRefOid="new-head", - statusCheckRollup={"contexts": {"nodes": [strix_check()]}}, + statusCheckRollup={"contexts": {"nodes": [strix_check()]},}, ) ) assert opencode_dispatched == [("owner/repo", "OpenCode Review", "new-head", False)] @@ -3704,7 +3726,7 @@ def followup(updated_pr, **overrides): assert "same-head OpenCode workflow run is already active" in followup( make_pr( headRefOid="newer-head", - statusCheckRollup={"contexts": {"nodes": [strix_check()]}}, + statusCheckRollup={"contexts": {"nodes": [strix_check()]},}, ) ) @@ -3722,7 +3744,7 @@ def test_post_update_branch_followup_dismisses_stale_approval_before_dispatch(mo } ] }, - statusCheckRollup={"contexts": {"nodes": [strix_check()]}}, + statusCheckRollup={"contexts": {"nodes": [strix_check()]},}, ) events = [] monkeypatch.setattr(sched, "wait_for_updated_branch_head", lambda repo, pr: updated) @@ -3794,7 +3816,7 @@ def test_post_update_branch_followup_waits_for_central_opencode_without_dispatch original = make_pr(headRefOid="old-head") updated = make_pr( headRefOid="new-head", - statusCheckRollup={"contexts": {"nodes": [strix_check()]}}, + statusCheckRollup={"contexts": {"nodes": [strix_check()]},}, ) monkeypatch.setattr(sched, "wait_for_updated_branch_head", lambda repo, pr: updated) @@ -3840,17 +3862,15 @@ def test_update_branch_summary_includes_followup_notes(): def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): - approved = make_pr(reviews={"nodes": [opencode_review("APPROVED", "head")]}) - failed = make_pr( - reviews={"nodes": [opencode_review("APPROVED", "head")]}, + approved = merge_approved_pr() + failed = merge_approved_pr( statusCheckRollup={"contexts": {"nodes": [{"__typename": "CheckRun", "name": "strix", "conclusion": "FAILURE"}]}}, ) assert inspect(failed).reason == "failed check(s): strix" - assert inspect(make_pr(reviews={"nodes": [opencode_review("APPROVED", "head")]}, autoMergeRequest={"enabledAt": "now"})).reason == ( + assert inspect(merge_approved_pr(autoMergeRequest={"enabledAt": "now"})).reason == ( "current head is approved; auto-merge already enabled" ) - approved_with_auto_merge = make_pr( - reviews={"nodes": [opencode_review("APPROVED", "head")]}, + approved_with_auto_merge = merge_approved_pr( autoMergeRequest={"enabledAt": "now"}, ) assert inspect(approved_with_auto_merge, enable_auto_merge_flag=False).reason == ( @@ -3868,9 +3888,8 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): assert inspect(approved, merge_mode="unknown").reason == ( "current head is approved; unsupported merge mode: unknown" ) - blocked_approved = make_pr( + blocked_approved = merge_approved_pr( mergeStateStatus="BLOCKED", - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ) assert inspect(blocked_approved, enable_auto_merge_flag=False).reason == ( "current head is approved; auto-merge disabled by scheduler inputs" @@ -3881,10 +3900,9 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): assert inspect(blocked_approved, merge_mode="unknown").reason == ( "current head is approved; unsupported merge mode: unknown" ) - blocked_unmergeable = make_pr( + blocked_unmergeable = merge_approved_pr( mergeable="UNKNOWN", mergeStateStatus="BLOCKED", - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ) assert inspect(blocked_unmergeable, enable_auto_merge_flag=False).reason == ( "current head is approved; auto-merge disabled by scheduler inputs" @@ -3902,12 +3920,11 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): "current head is approved; direct merge waits for CLEAN mergeability; GitHub mergeability is BLOCKED" ) external_unmergeable = inspect( - make_pr( + merge_approved_pr( mergeable="UNKNOWN", mergeStateStatus="BLOCKED", isCrossRepository=True, headRepository={"nameWithOwner": "fork/repo"}, - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct_or_auto", ) @@ -3921,9 +3938,8 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): lambda repo, pr, dry_run: direct_merges.append((repo, pr["number"], dry_run)), ) blocked_direct = inspect( - make_pr( + merge_approved_pr( mergeStateStatus="BLOCKED", - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct", ) @@ -3942,9 +3958,8 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): assert direct_merges == [("owner/repo", 1, True), ("owner/repo", 1, True)] already_auto_direct_or_auto = inspect( - make_pr( + merge_approved_pr( autoMergeRequest={"enabledAt": "now"}, - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct_or_auto", ) @@ -3957,10 +3972,9 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): ] clean_but_compare_behind = inspect( - make_pr( + merge_approved_pr( mergeStateStatus="CLEAN", compareBehindBy=20, - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct_or_auto", ) @@ -3974,10 +3988,9 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): ] blocked_but_mergeable_and_compare_behind = inspect( - make_pr( + merge_approved_pr( mergeStateStatus="BLOCKED", compareBehindBy=20, - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct_or_auto", ) @@ -3996,9 +4009,8 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): assert inspect(approved).action == "auto_merge" assert auto_merges == [("owner/repo", 1, True)] blocked_direct_or_auto = inspect( - make_pr( + merge_approved_pr( mergeStateStatus="BLOCKED", - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct_or_auto", ) @@ -4042,10 +4054,9 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): ] external_approved = inspect( - make_pr( + merge_approved_pr( isCrossRepository=True, headRepository={"nameWithOwner": "fork/repo"}, - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct_or_auto", ) @@ -4053,11 +4064,10 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): assert "fork or external PR heads are excluded from scheduler direct merge and auto-merge" in external_approved.reason assert sched.decision_guidance(external_approved)["type"] == "external_head_merge_excluded" external_blocked = inspect( - make_pr( + merge_approved_pr( mergeStateStatus="BLOCKED", isCrossRepository=True, headRepository={"nameWithOwner": "fork/repo"}, - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct_or_auto", ) @@ -4169,7 +4179,7 @@ def test_inspect_pr_waits_when_same_head_dispatch_is_already_running(monkeypatch def test_direct_or_auto_falls_back_to_auto_merge_when_branch_policy_blocks_direct_merge(monkeypatch): - approved = make_pr(reviews={"nodes": [opencode_review("APPROVED", "head")]}) + approved = merge_approved_pr() auto_merges = [] def policy_blocked_merge(repo, pr, dry_run): @@ -4195,9 +4205,8 @@ def policy_blocked_merge(repo, pr, dry_run): assert auto_merges == [("owner/repo", 1, True)] already_queued = inspect( - make_pr( + merge_approved_pr( autoMergeRequest={"enabledAt": "now"}, - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct_or_auto", ) @@ -4206,9 +4215,8 @@ def policy_blocked_merge(repo, pr, dry_run): assert "existing auto-merge request remains queued" in already_queued.reason assert auto_merges == [("owner/repo", 1, True)] - blocked = make_pr( + blocked = merge_approved_pr( mergeStateStatus="BLOCKED", - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ) blocked_decision = inspect(blocked, merge_mode="direct_or_auto") @@ -4231,9 +4239,8 @@ def non_policy_merge_failure(repo, pr, dry_run): def test_direct_or_auto_attempts_direct_merge_when_mergeability_is_blocked(monkeypatch): - approved = make_pr( + approved = merge_approved_pr( mergeStateStatus="BLOCKED", - reviews={"nodes": [opencode_review("APPROVED", "head")]}, ) direct_merges = [] diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 233c08584..221ea7838 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -117,7 +117,7 @@ def test_privileged_review_retries_use_default_branch_repository_dispatch() -> N assert "github.event.client_payload" in workflow scheduler = ( - REPO_ROOT / "scripts" / "ci" / "pr_review_merge_scheduler.py" + REPO_ROOT / "scripts" / "ci" / "_pr_review_merge_scheduler_core.py" ).read_text(encoding="utf-8") assert 'f"repos/{dispatch_repo}/dispatches"' in scheduler assert '"event_type": "opencode-review"' in scheduler @@ -491,7 +491,6 @@ def test_noema_review_supports_review_token_pat_fallback() -> None: "Noema reviewer using the NOEMA_REVIEW_TOKEN secret fallback identity." in workflow ) - # The review step must prefer the PAT over the exchanged app token. assert ( "GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }}" in workflow @@ -641,25 +640,18 @@ def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: "ORG_SWEEP_UPDATE_BRANCHES", ): assert f"{setting}: ${{{{ github.event_name == 'schedule' ||" in workflow - # The single-repository scan must not double-run on the sweep cron. assert "github.event.schedule != '*/15 * * * *'" in workflow assert "github.event.client_payload.org_sweep != true" in workflow - # The sweep must never silently no-op with the repository-scoped token. assert ( "Organization queue sweep has no cross-repository mutation credential." in workflow ) assert 'select(.full_name != "ContextualWisdomLab/.github")' in workflow assert "select(.archived == false and .disabled == false)" in workflow - # The sweep must not silently truncate large/old queues or skip a repository - # whose only open work is a stacked/non-default-base PR. assert "vars.ORG_SWEEP_MAX_PRS || '1000'" in workflow assert "/pulls?state=open&per_page=1&base=" not in workflow assert "No open PRs (including stacked or non-default-base PRs)" in workflow - # Every repository failure must leave a concrete logged reason. assert "see the decision log above for the concrete per-PR reason" in workflow - # Queue hygiene: previous-head runs are cancelled immediately, while the - # legacy age guard cannot cancel a valid current-head PR run. assert "ORG_SWEEP_STALE_QUEUE_HOURS" in workflow assert "/actions/runs?status=${active_status}&per_page=100" in workflow assert "for active_status in queued in_progress" in workflow @@ -673,9 +665,6 @@ def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: assert "Could not cancel superseded run" in workflow assert "No run will be cancelled from incomplete evidence" in workflow assert "queue_hygiene_ready=false" in workflow - # The scheduler requires --project-flow; the sweep must derive and pass it - # per target repository (regression: the first sweep failed every repo with - # "--project-flow is required"). assert "--project-flow" in workflow assert 'main|master) project_flow="github-flow"' in workflow assert 'develop) project_flow="git-flow"' in workflow @@ -738,7 +727,8 @@ def test_org_queue_sweep_manual_cadence_inputs_reach_the_sweep_job() -> None: ) assert ( "ORG_SWEEP_ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false || inputs.enable_auto_merge == true }}" - ) in workflow + in workflow + ) assert ( "ORG_SWEEP_MERGE_MODE: ${{ github.event.client_payload.merge_mode || inputs.merge_mode || 'direct_or_auto' }}" in workflow @@ -800,21 +790,15 @@ def test_org_queue_sweep_treats_inaccessible_repositories_as_non_fatal() -> None """ workflow = workflow_text("pr-review-merge-scheduler.yml") - # The 403 signal is classified as a skipped, non-fatal "unavailable" repo. assert "ORG_SWEEP_MAX_UNAVAILABLE" in workflow assert 'grep -qF "Resource not accessible by integration"' in workflow assert "unavailable=$((unavailable + 1))" in workflow assert 'unavailable_repos+=("$repo_full_name")' in workflow assert "the sweep credential lacks access (HTTP 403" in workflow - # A non-403 failure must still be a hard failure (fail-closed preserved). assert "failures=$((failures + 1))" in workflow assert "see the decision log above for the concrete per-PR reason" in workflow - # Widespread inaccessibility is a credential regression and must fail loudly. assert 'if [ "$unavailable" -gt "$ORG_SWEEP_MAX_UNAVAILABLE" ]; then' in workflow assert "indicates a credential-scope regression" in workflow - # The ceiling must be validated as a non-negative integer BEFORE the numeric - # test, or a misconfigured non-integer would make "[ -gt ]" error inside an - # if condition (which set -e does not trap) and silently skip the guard. assert '"$ORG_SWEEP_MAX_UNAVAILABLE" =~ ^[0-9]+$' in workflow assert "ORG_SWEEP_MAX_UNAVAILABLE must be a non-negative integer" in workflow @@ -1296,4 +1280,4 @@ def test_scorecard_medium_plus_governance_has_owner_and_runbook() -> None: assert "review thread resolution" in runbook assert "latest head commit" in runbook assert "cancel superseded runs" in runbook - assert "Every central workflow failure must print the actionable reason" in runbook + assert "Every central workflow failure must print the actionable reason" in runbook \ No newline at end of file diff --git a/tests/test_scheduler_independent_approval_gate.py b/tests/test_scheduler_independent_approval_gate.py new file mode 100644 index 000000000..225e0ea28 --- /dev/null +++ b/tests/test_scheduler_independent_approval_gate.py @@ -0,0 +1,228 @@ +"""Regression tests for exact-head independent scheduler approval gates.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from scripts.ci import pr_review_merge_scheduler as sched + + +HEAD_SHA = "a" * 40 +BASE_SHA = "b" * 40 +ROOT = Path(__file__).resolve().parents[1] + + +def review( + login: str, + *, + state: str = "APPROVED", + commit: str = HEAD_SHA, +) -> dict[str, Any]: + """Return one formal review fixture anchored to a specific commit.""" + return { + "state": state, + "author": {"login": login}, + "submittedAt": "2026-08-07T18:00:00Z", + "commit": {"oid": commit}, + "body": f"Head SHA: `{commit}`", + } + + +def make_pr( + *, + review_decision: str, + extra_reviews: list[dict[str, Any]] | None = None, + author: str = "pull-request-author", +) -> dict[str, Any]: + """Return a clean current-head PR that otherwise qualifies for merge.""" + reviews = [review("opencode-agent")] + reviews.extend(extra_reviews or []) + return { + "number": 771, + "title": "Guard independent approval", + "author": {"login": author}, + "isDraft": False, + "mergeable": "MERGEABLE", + "mergeStateStatus": "CLEAN", + "restMergeableState": "CLEAN", + "reviewDecision": review_decision, + "baseRefName": "main", + "baseRefOid": BASE_SHA, + "headRefName": "fix/approval-gate", + "headRefOid": HEAD_SHA, + "isCrossRepository": False, + "maintainerCanModify": False, + "headRepository": {"nameWithOwner": "owner/repo"}, + "autoMergeRequest": None, + "commits": { + "nodes": [ + { + "commit": { + "oid": HEAD_SHA, + "authoredDate": "2026-08-07T17:55:00Z", + "committedDate": "2026-08-07T17:55:00Z", + "messageHeadline": "fix: guard scheduler approval", + } + } + ] + }, + "reviewThreads": {"nodes": []}, + "files": {"nodes": []}, + "reviews": {"nodes": reviews}, + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "strix", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "checkSuite": { + "workflowRun": {"workflow": {"name": "Strix Security Scan"}} + }, + } + ] + } + }, + } + + +def inspect(pr: dict[str, Any]): + """Inspect one fixture using the direct-merge path without mutating GitHub.""" + return sched.inspect_pr( + "owner/repo", + pr, + dry_run=True, + trigger_reviews=False, + enable_auto_merge_flag=True, + update_branches=False, + workflow="OpenCode Review", + security_workflow="Strix Security Scan", + base_branch="main", + merge_mode="direct", + ) + + +def test_graphql_contract_requests_pull_request_author() -> None: + """Request the PR author in the same authoritative GraphQL evidence envelope.""" + assert "\n author { login }\n" in sched.PULL_REQUEST_FIELDS_FRAGMENT + + +def test_quality_workflow_tracks_scheduler_core() -> None: + """Keep the approval regression gate attached to both scheduler facade and core.""" + workflow = (ROOT / ".github/workflows/scheduler-independent-approval-quality-ci.yml").read_text( + encoding="utf-8" + ) + assert '- "scripts/ci/_pr_review_merge_scheduler_core.py"' in workflow + + +def test_quality_workflow_runs_full_suite_and_checks_all_worktree_changes() -> None: + """Require broad regression evidence and detect staged or untracked test artifacts.""" + workflow = (ROOT / ".github/workflows/scheduler-independent-approval-quality-ci.yml").read_text( + encoding="utf-8" + ) + + assert "python -m pytest -q" in workflow + assert "python -m pytest tests/test_scheduler_independent_approval_gate.py -q" not in workflow + assert "git status --porcelain=v1 --untracked-files=all" in workflow + assert "git diff --exit-code" not in workflow + + +def test_review_required_blocks_even_with_opencode_and_independent_approval() -> None: + """GitHub REVIEW_REQUIRED must never be converted into a scheduler merge.""" + decision = inspect( + make_pr( + review_decision="REVIEW_REQUIRED", + extra_reviews=[review("cwl-noema-review")], + ) + ) + + assert decision.action == "wait" + assert "reviewDecision" in decision.reason + + +def test_github_approved_without_exact_head_independent_review_blocks() -> None: + """GitHub approval state alone cannot replace exact-head independent evidence.""" + decision = inspect(make_pr(review_decision="APPROVED")) + + assert decision.action == "wait" + assert "independent" in decision.reason.lower() + + +def test_stale_independent_approval_does_not_authorize_current_head() -> None: + """An independent approval for a predecessor commit is non-authoritative.""" + decision = inspect( + make_pr( + review_decision="APPROVED", + extra_reviews=[review("cwl-noema-review", commit="c" * 40)], + ) + ) + + assert decision.action == "wait" + assert "independent" in decision.reason.lower() + + +def test_author_self_approval_does_not_count_as_independent() -> None: + """A formal approval by the pull-request author is never independent evidence.""" + decision = inspect( + make_pr( + review_decision="APPROVED", + extra_reviews=[review("pull-request-author")], + ) + ) + + assert decision.action == "wait" + assert "independent" in decision.reason.lower() + + +def test_missing_author_identity_fails_closed() -> None: + """Do not guess reviewer independence when GitHub omits the PR author identity.""" + pr = make_pr( + review_decision="APPROVED", + extra_reviews=[review("cwl-noema-review")], + ) + pr["author"] = {"login": ""} + + decision = inspect(pr) + + assert decision.action == "wait" + assert "independent" in decision.reason.lower() + + +def test_missing_reviewer_identity_does_not_count_as_independent() -> None: + """Do not treat an anonymous formal review as independent merge authority.""" + decision = inspect( + make_pr( + review_decision="APPROVED", + extra_reviews=[review("")], + ) + ) + + assert decision.action == "wait" + assert "independent" in decision.reason.lower() + + +def test_non_approved_independent_review_does_not_authorize_merge() -> None: + """Only a formal APPROVED independent review is merge authority.""" + decision = inspect( + make_pr( + review_decision="APPROVED", + extra_reviews=[review("cwl-noema-review", state="COMMENTED")], + ) + ) + + assert decision.action == "wait" + assert "independent" in decision.reason.lower() + + +def test_exact_head_independent_approval_and_github_approval_allow_merge() -> None: + """Both policy-level and exact-head independent approvals enable the merge path.""" + decision = inspect( + make_pr( + review_decision="APPROVED", + extra_reviews=[review("cwl-noema-review")], + ) + ) + + assert decision.action == "merge"