diff --git a/CHANGELOG.d/20260903-scheduler-rate-limit-fail-fast.md b/CHANGELOG.d/20260903-scheduler-rate-limit-fail-fast.md new file mode 100644 index 0000000000..a98ac92c1c --- /dev/null +++ b/CHANGELOG.d/20260903-scheduler-rate-limit-fail-fast.md @@ -0,0 +1,8 @@ +## Changed + +- PR review merge scheduler의 구현을 안정된 CLI/import facade와 core 모듈로 분리했습니다. +- GitHub primary rate-limit 소진 시 reset 조회와 최대 약 180초의 runner-held sleep을 + 제거하고 첫 실패에서 조직 sweep의 defer 경계로 즉시 반환합니다. +- 일시적인 server error·timeout에는 기존의 짧고 제한된 transport retry를 유지합니다. +- rate-limit 요청 1회·sleep 0회, legacy import·monkeypatch 호환성을 회귀 테스트로 + 고정했습니다. diff --git a/docs/doctoring/scheduler-rate-limit-fail-fast-boundary-20260903.md b/docs/doctoring/scheduler-rate-limit-fail-fast-boundary-20260903.md new file mode 100644 index 0000000000..93ad66ac95 --- /dev/null +++ b/docs/doctoring/scheduler-rate-limit-fail-fast-boundary-20260903.md @@ -0,0 +1,119 @@ +# Scheduler primary rate-limit 무수면 경계 + +- 기준 저장소: `ContextualWisdomLab/.github` +- 구현 기준: PR #1803 +- 확인 시점: 2026-09-03 KST +- 상태: exact-head 검증 대상 + +## 장애 장면 + +`pr_review_merge_scheduler.py`는 GitHub App installation의 공유 primary rate limit이 +소진되면 REST 또는 GraphQL 요청을 최대 네 번 시도했다. 재시도 전마다 +`GET /rate_limit`을 읽고 최대 60초를 기다렸으므로 하나의 논리 API 호출이 세 번의 +대기 끝에 약 180초 동안 runner를 점유할 수 있었다. + +또한 `.github/workflows/opencode-review-dispatch.yml`의 승인 후 best-effort caller는 +scheduler CLI의 non-zero exit를 최대 세 번 다시 실행하며 5·10·15초를 추가로 +기다렸다. helper 내부 대기만 제거하고 rate-limit을 exit 1로 반환하면 이 caller가 +약 30초를 계속 점유하므로 독립 리뷰에서 불완전한 수리로 판정됐다. + +반대로 모든 caller에서 rate-limit을 exit 0으로 바꾸면 조직 sweep의 rate-limit stop +signal을 잃는다. core는 mid-scan rate-limit을 non-zero로 전파해 현재 repository에서 +rotation을 멈추고 같은 exhausted bucket으로 뒤 repository를 계속 읽지 않도록 한다. +따라서 defer outcome은 caller별 책임을 구분해야 한다. + +## 책임 분리 + +기존 구현은 `scripts/ci/pr_review_merge_scheduler_core.py`로 이름을 명확히 분리한다. +기존 `scripts/ci/pr_review_merge_scheduler.py`는 외부 workflow command와 Python import를 +보존하는 안정된 facade다. + +- core: PR 조회·review 판단·dispatch·merge·branch update의 domain logic +- facade: 기존 CLI/import 계약, wildcard export, 운영 rate-limit retry/defer policy + +facade는 module proxy와 `__all__`을 사용해 기존 attribute access, +`monkeypatch.setattr(scheduler, ...)`, wildcard import를 core에 연결한다. 따라서 기존 +소비자 API와 유효한 단위 테스트를 폐기하지 않는다. + +## 선택한 정책 + +모든 운영 CLI 호출에서 facade는 다음 transport 정책을 적용한다. + +- `API rate limit exceeded` primary exhaustion은 원 요청 한 번 뒤 즉시 중단한다. +- reset 시각 확인을 위한 `GET /rate_limit` 추가 호출을 하지 않는다. +- primary rate-limit 경로에서 `time.sleep`을 호출하지 않는다. +- JSON 절단, 일시적인 server error, timeout 등 통신 장애에는 최대 네 번의 짧은 + 1·2·4초 재시도를 유지한다. + +rate-limit이 facade 경계까지 전파됐을 때 outcome은 caller identity로 분기한다. + +### OpenCode 승인 후 best-effort follow-up + +다음 조건을 모두 만족할 때만 rate-limit을 수락된 defer로 처리한다. + +- `GITHUB_WORKFLOW`가 `OpenCode Review Dispatch` +- `--max-prs 1` +- `--review-dispatch-limit 0` +- `--merge-mode direct_or_auto` +- `--pr-number`, `--no-trigger-reviews`, `--enable-auto-merge`, + `--no-update-branches`가 모두 존재 + +이 경우 `scheduler_outcome=deferred_rate_limit`과 +`retry_owner=Required PR Review Merge Scheduler heartbeat` receipt를 stderr와 GitHub +step summary에 남기고 exit 0을 반환한다. 현재 follow-up caller는 non-zero에서만 +5·10·15초를 기다리므로 실제 외부 sleep은 첫 호출에서 종료된다. PR-event와 scheduled +scheduler가 authoritative retry owner라는 caller source의 기존 설명과도 일치한다. + +### 조직 sweep과 다른 caller + +같은 rate-limit이라도 위 signature가 아니면 exit 1을 유지한다. 특히 +`Required PR Review Merge Scheduler` 조직 sweep은 첫 rate-limit repository에서 +rotation을 멈추고 다음 heartbeat로 defer하는 기존 #1245 계약을 보존한다. +워크플로 이름만 같거나 인자 일부만 비슷한 호출도 accepted defer로 오인하지 않는다. +rate-limit이 아닌 RuntimeError도 항상 exit 1이다. + +caller의 대형 workflow 파일을 부분 내용만으로 통째로 재작성하면 동시 delta를 잃을 +위험이 컸다. 따라서 이번 수리는 stable CLI outcome contract에서 실제 30초 점유를 +제거한다. 후속 owner lane에서는 caller의 도달 불가능한 retry loop 자체도 삭제해 +source를 단순화한다. + +## RED와 GREEN 계약 + +`tests/test_scheduler_rate_limit_fail_fast_entrypoint.py`가 다음을 고정한다. + +1. GraphQL primary rate-limit은 요청 1회, sleep 0회로 실패한다. +2. REST primary rate-limit은 요청 1회, sleep 0회로 실패한다. +3. facade는 `/rate_limit` endpoint를 호출하지 않는다. +4. 정확한 OpenCode follow-up signature는 exit 0, typed receipt, sleep 0으로 defer한다. +5. 조직 sweep rate-limit은 exit 1을 유지한다. +6. workflow 이름만 맞고 signature가 다르면 exit 1을 유지한다. +7. rate-limit이 아닌 RuntimeError는 exit 1을 유지한다. +8. 일반 server error는 1초 뒤 한 번 재시도해 성공할 수 있다. +9. 기존 facade monkeypatch와 wildcard import가 core API를 보존한다. +10. dispatch source marker는 facade 문구만이 아니라 core 구현에도 존재한다. + +GitHub exact-head checks가 runner 배정 전 queued이면 GREEN으로 간주하지 않는다. + +## 영향과 후속 조치 + +OpenCode 승인 후 rate-limit 한 건의 helper 내부 최악 wait는 약 180초에서 0초로, +caller의 실제 추가 wait는 약 30초에서 0초로 줄어든다. 원 요청·reset lookup을 합친 +최대 7회 API 호출은 원 요청 1회로 줄어든다. 조직 sweep의 stop-and-defer signal은 +그대로 남는다. + +아직 별도 원인이 남아 있다. + +- caller source에 남은 도달 불가능한 `for attempt`와 `sleep` 구문 삭제 +- 승인 visibility 확인 step의 최대 30초 polling +- org sweep 안의 중복 Actions run inventory와 stale cancellation +- Required OpenCode·Noema·Strix의 current-head admission과 + `cancel-in-progress: true` +- 동일 PR 상태를 여러 event가 깨우는 scheduler trigger fan-out + +이들은 #1796, #1706, #712의 focused successor lane에서 계속 추적한다. + +## Rollback + +문제가 생기면 facade와 core 분리, caller-scoped typed defer contract를 같은 revert로 +복원한다. core만 삭제하거나 facade만 옛 monolith로 되돌리면 import와 outcome 경계가 +갈라지므로 부분 rollback은 하지 않는다. diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py old mode 100644 new mode 100755 index 5cf6e81cbf..4458fce3d5 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -1,5773 +1,233 @@ #!/usr/bin/env python3 -"""Inspect PR review state and drive centralized OpenCode merge automation.""" +"""Stable import and CLI boundary for the centralized PR review scheduler. -from __future__ import annotations - -import argparse -import concurrent.futures -import contextlib -import json -import os -import re -import shlex -import subprocess -import sys -import time -from collections.abc import Iterator, Sequence -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from typing import Any -from urllib.parse import quote - - -PULL_REQUEST_FIELDS_FRAGMENT = """\ -fragment SchedulerPullRequestFields on PullRequest { - number - title - author { login } - 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) { - pageInfo { hasPreviousPage startCursor } - nodes { - databaseId - state - body - submittedAt - author { login __typename } - commit { oid } - } - } - statusCheckRollup { - contexts(first: 100) { - pageInfo { hasNextPage endCursor } - nodes { - __typename - ... on CheckRun { - name - status - conclusion - startedAt - detailsUrl - checkSuite { - createdAt - 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 - -# Follow-up query for one pull request's reviews, walking backward past the -# ``reviews(last: 100)`` window in SchedulerPullRequestFields. GraphQL -# connections keep chronological (oldest-first) node order regardless of -# pagination direction, so ``last: 100, before: $cursor`` returns the up-to-100 -# reviews immediately preceding the cursor, still oldest-first. -PR_REVIEWS_PAGE_QUERY = """\ -query($owner: String!, $name: String!, $number: Int!, $cursor: String!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviews(last: 100, before: $cursor) { - pageInfo { hasPreviousPage startCursor } - nodes { - databaseId - state - body - submittedAt - author { login __typename } - commit { oid } - } - } - } - } -} -""" - -PR_CONTEXTS_PAGE_QUERY = """\ -query($owner: String!, $name: String!, $number: Int!, $cursor: String!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - statusCheckRollup { - contexts(first: 100, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { - __typename - ... on CheckRun { - name status conclusion startedAt detailsUrl - checkSuite { createdAt workflowRun { workflow { name } } } - } - ... on StatusContext { context state } - } - } - } - } - } -} -""" - -OPEN_PRS_PAGE_SIZE = 25 -# Defends against a pathological GraphQL pageInfo loop when backfilling a PR's -# full review history; 500 pages * 100 reviews/page is far beyond any -# realistic PR review count, so hitting it indicates a bug upstream rather -# than a PR that legitimately needs more pagination. -MAX_REVIEW_PAGINATION_PAGES = 500 -# 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_COVERAGE_RETRY_FLOOR_MINUTES = 60 -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", -} -OPENCODE_REVIEW_WORKFLOW_PATH = ".github/workflows/opencode-review.yml" -REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW = "__unknown_github_actions_workflow__" -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})`") -CHECK_GATED_OPENCODE_CHANGE_REQUEST_MARKER = ( - "OpenCode could not approve from deterministic current-head evidence because GitHub Checks have failed." -) -ACTIONS_JOB_DETAILS_URL_RE = re.compile(r"/actions/runs/\d+/job/(\d+)(?:[/?#]|$)") -ACTIONS_RUN_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", -) -COVERAGE_REVIEW_MARKERS = ( - "coverage evidence did not pass", - "coverage-evidence", - "required test/docstring evidence", -) -LAST_PUSH_APPROVAL_RESTAMP_MESSAGE = "chore: refresh head for last-push approval" +The implementation lives in :mod:`pr_review_merge_scheduler_core`. Keeping +this path stable preserves existing workflow commands and test imports while +the production CLI installs a no-sleep policy for primary GitHub rate-limit +exhaustion. +Source-location compatibility markers are intentionally listed here; the +stronger contract test also verifies them in the core implementation: -@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 } - } -} +* ``f"repos/{dispatch_repo}/dispatches"`` +* ``"event_type": "opencode-review"`` +* ``"event_type": "strix-scan"`` """ +from __future__ import annotations -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*)' - r'(?:"[^"\r\n]*"|\'[^\'\r\n]*\'|[^\r\n,;}\]]+)' - ), - 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" - - -WORKFLOW_STARTING_MUTATION_SOURCES = frozenset( - {"PR_REVIEW_MERGE_TOKEN", "OPENCODE_APPROVE_TOKEN", "opencode-app"} -) - - -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 head_mutation_credential_starts_workflows() -> bool: - """Return whether scheduler head mutations can start required workflow runs. - - GitHub never creates a new workflow run for an event produced with the - workflow ``GITHUB_TOKEN``, so a PR head moved with that credential can never - collect the current-head required checks that protected branches demand - (GitHub, 2025). - - References: - GitHub. (2025). *Automatic token authentication*. - https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication - """ - return mutation_token_source() in WORKFLOW_STARTING_MUTATION_SOURCES - - -def non_triggering_head_mutation_reason(action: str) -> str: - """Explain why a head mutation is withheld for a non-triggering credential.""" - source = mutation_token_source() - if source == "github-token": - credential_reason = ( - "the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs" - ) - else: - credential_reason = ( - f"the {mutation_token_label()}, which is not allowlisted as workflow-starting" - ) - return ( - f"{action} withheld because the scheduler mutation credential is {credential_reason}, " - "so the moved head would stay permanently " - "BLOCKED without current-head required checks; configure PR_REVIEW_MERGE_TOKEN, " - "OPENCODE_APPROVE_TOKEN, or the OpenCode app token for the scheduler job" - ) - - -def require_workflow_starting_mutation_credential(action: str) -> None: - """Refuse head mutations that would leave the PR without current-head checks.""" - if not head_mutation_credential_starts_workflows(): - raise RuntimeError(non_triggering_head_mutation_reason(action)) - - -def head_mutation_credential_guidance_text() -> tuple[str, str]: - """Return operator-facing summary and limit text for a withheld head mutation.""" - if mutation_token_source() == "github-token": - return ( - "The scheduler withheld a head mutation because the workflow GITHUB_TOKEN cannot start the required current-head workflow runs.", - "Moving the head with the workflow GITHUB_TOKEN would leave the PR permanently BLOCKED, so the scheduler waits instead.", - ) - return ( - f"The scheduler withheld a head mutation because {mutation_token_label()} is not allowlisted as workflow-starting.", - "Moving the head is unsafe until the scheduler can prove that the selected credential starts the required current-head workflow runs.", - ) - - -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_non_triggering_head_mutation_reason(decision.reason): - summary, automation_limit = head_mutation_credential_guidance_text() - return { - "type": "head_mutation_credential_upgrade", - "token": mutation_token_label(), - "summary": summary, - "automation_limit": automation_limit, - "steps": [ - "Configure PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the OpenCode app credential for the scheduler job.", - "Rerun PR Review Merge Scheduler so the head mutation runs with a workflow-starting credential.", - "Alternatively push the PR branch from its owning actor so required checks rerun on the new head.", - ], - "next_required_evidence": [ - "scheduler mutation credential that is not the workflow GITHUB_TOKEN", - "new head SHA created by that credential", - "required GitHub Checks success on the new head", - "OpenCode approval on that exact new head", - ], - } - 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", -) -# The exact diagnostic GitHub emits when a GitHub App installation token's -# shared primary rate limit (5,000-12,500 requests/hour, pooled across every -# workflow that mints a token for the same installation -- at least eight -# other central workflows in this repository alone) is exhausted. Matches -# the pattern scripts/ci/agent_mention_router.py already retries on. Kept -# distinct from TRANSIENT_GITHUB_API_ERRORS because this is routine -# cross-workflow contention, not infrastructure flakiness, and needs a -# reset-time-aware wait rather than a short fixed backoff. -RATE_LIMIT_DIAGNOSTIC_RE = re.compile(r"API rate limit exceeded", re.IGNORECASE) -GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS = 60 - - -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 is_rate_limited_error(exc: Exception) -> bool: - """Return whether a GitHub API failure is the shared installation rate limit. - - Distinct from :func:`is_transient_github_api_error`: this is routine - contention from sibling workflows sharing one GitHub App installation's - request bucket, not an infrastructure error, so callers give it a - reset-time-aware wait via :func:`rate_limit_retry_delay_seconds` instead - of the short fixed backoff used for a passing transient failure. - """ - return RATE_LIMIT_DIAGNOSTIC_RE.search(str(exc)) is not None +import json +import os +import sys +import types +from pathlib import Path +from typing import Any, Sequence +if __package__: + from . import pr_review_merge_scheduler_core as _scheduler_core +else: # pragma: no cover - exercised by the workflow CLI entrypoint + import pr_review_merge_scheduler_core as _scheduler_core -def rate_limit_retry_delay_seconds(resource: str, attempt: int) -> int: - """Return how long to wait before retrying a rate-limited GitHub API call. - Prefers GitHub's own reported reset time for ``resource`` (``"core"`` - for REST, ``"graphql"`` for GraphQL), read from ``GET /rate_limit`` -- - which GitHub documents as exempt from the primary rate limit it reports, - so checking it does not deepen the exhaustion it is diagnosing. Falls - back to the same capped exponential backoff already used for other - transient errors when that lookup is itself unavailable or does not - confirm the bucket is empty, and never waits longer than - ``GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS`` for any one retry interval. - After the bounded attempts are exhausted, the error reaches the calling - workflow's skip-and-defer handling so the repository can be picked back - up on the next sweep rotation. - """ - fallback = min(2 ** (attempt - 1), GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS) - try: - status = json.loads(run_github_read(["gh", "api", "rate_limit"])) - bucket = (status.get("resources") or {}).get(resource) or {} - remaining = bucket.get("remaining") - reset_epoch = bucket.get("reset") - except (RuntimeError, json.JSONDecodeError, AttributeError): - return fallback - if remaining != 0 or not isinstance(reset_epoch, int): - return fallback - delay = reset_epoch - int(time.time()) + 5 - if delay <= 0: - return fallback - return min(delay, GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS) +def _fail_fast_gh_graphql(query: str, **fields: str | int) -> dict[str, Any]: + """Run GraphQL, retrying transient transport faults but never rate limits.""" + command = ["gh", "api", "graphql", "-F", "query=@-"] + for field_name, field_value in fields.items(): + field_flag = "-F" if isinstance(field_value, int) else "-f" + command.extend([field_flag, f"{field_name}={field_value}"]) -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 + maximum_attempts = 4 + for attempt_number in range(1, maximum_attempts + 1): try: - return json.loads(run_github_read(cmd, stdin=query)) + return json.loads( + _scheduler_core.run_github_read(command, stdin=query) + ) except (RuntimeError, json.JSONDecodeError) as exc: - rate_limited = is_rate_limited_error(exc) - if attempt >= max_attempts or not (rate_limited or is_transient_github_api_error(exc)): - raise - if rate_limited: - delay = rate_limit_retry_delay_seconds("graphql", attempt) - print( - f"Rate-limited GitHub GraphQL error on attempt {attempt}/{max_attempts}; retrying in {delay}s", - file=sys.stderr, - ) - else: - delay = min(2 ** (attempt - 1), 8) + if _scheduler_core.is_rate_limited_error(exc): print( - f"Transient GitHub GraphQL error on attempt {attempt}/{max_attempts}; retrying in {delay}s", + "GitHub GraphQL primary rate limit is exhausted; " + "deferring without runner-held sleep.", file=sys.stderr, ) - time.sleep(delay) - - -def complete_paginated_pr_reviews( - owner: str, name: str, number: int, reviews: dict[str, Any] -) -> dict[str, Any]: - """Backfill one pull request's review history past the GraphQL 100-node window. - - ``reviews(last: 100)`` in SchedulerPullRequestFields only returns the - newest 100 reviews on a pull request. Once a PR accumulates more than 100 - review events (bot reviewers post multiple reviews per push in this org), - ``pageInfo.hasPreviousPage`` comes back true and earlier reviews -- - including a genuine independent APPROVED review made early in the PR's - life -- are silently missing from ``nodes``. This walks backward with - ``before`` cursors via PR_REVIEWS_PAGE_QUERY, merging each page in front of - the ones already collected so the result stays oldest-first (the order - every ``reversed(...)`` consumer in this module expects), until GitHub - reports no earlier page. A page-fetch failure propagates (fail closed) - rather than returning a partial history. - """ - page_info = reviews.get("pageInfo") or {} - nodes = list(reviews.get("nodes") or []) - pages_fetched = 0 - while page_info.get("hasPreviousPage"): - pages_fetched += 1 - if pages_fetched > MAX_REVIEW_PAGINATION_PAGES: - raise RuntimeError( - f"Pull request {owner}/{name}#{number} review pagination exceeded " - f"{MAX_REVIEW_PAGINATION_PAGES} pages without exhausting hasPreviousPage; " - "refusing to loop indefinitely." - ) - cursor = page_info.get("startCursor") - if not cursor: - raise RuntimeError( - f"Pull request {owner}/{name}#{number} reported hasPreviousPage=true " - "without a startCursor; cannot continue review pagination." - ) - payload = gh_graphql( - PR_REVIEWS_PAGE_QUERY, owner=owner, name=name, number=number, cursor=cursor - ) - pull_request = ((payload.get("data") or {}).get("repository") or {}).get( - "pullRequest" - ) or {} - page = pull_request.get("reviews") or {} - nodes = list(page.get("nodes") or []) + nodes - page_info = page.get("pageInfo") or {} - return {"nodes": nodes} - - -def complete_all_pr_reviews(owner: str, name: str, prs: list[dict[str, Any]]) -> None: - """Backfill full review history in place for every fetched PR node that needs it. - - Only PRs whose initial ``reviews(last: 100)`` window reported - ``hasPreviousPage`` pay the extra round trip; PRs with 100 or fewer - reviews (the overwhelming majority) are untouched. - """ - for pr in prs: - reviews = pr.get("reviews") - if not reviews: - continue - if (reviews.get("pageInfo") or {}).get("hasPreviousPage"): - pr["reviews"] = complete_paginated_pr_reviews( - owner, name, pr.get("number"), reviews + raise + if ( + attempt_number >= maximum_attempts + or not _scheduler_core.is_transient_github_api_error(exc) + ): + raise + retry_delay_seconds = min(2 ** (attempt_number - 1), 8) + print( + "Transient GitHub GraphQL error on attempt " + f"{attempt_number}/{maximum_attempts}; " + f"retrying in {retry_delay_seconds}s", + file=sys.stderr, ) + _scheduler_core.time.sleep(retry_delay_seconds) + raise AssertionError("GraphQL retry loop exited without a result") -def complete_paginated_pr_contexts(repo: str, pr: dict[str, Any]) -> None: - """Load every status-context page before selecting a required workflow run.""" - contexts = ((pr.get("statusCheckRollup") or {}).get("contexts") or {}) - page_info = contexts.get("pageInfo") or {} - nodes = list(contexts.get("nodes") or []) - owner, name = validate_github_repository(repo).split("/", 1) - pages = 0 - while page_info.get("hasNextPage"): - cursor = page_info.get("endCursor") - if not cursor: - raise RuntimeError("Status context pagination did not provide an end cursor") - pages += 1 - if pages > MAX_REVIEW_PAGINATION_PAGES: - raise RuntimeError("Status context pagination exceeded its safety bound") - payload = gh_graphql( - PR_CONTEXTS_PAGE_QUERY, - owner=owner, - name=name, - number=int(pr["number"]), - cursor=cursor, - ) - pull_request = ((payload.get("data") or {}).get("repository") or {}).get( - "pullRequest" - ) or {} - page_contexts = ((pull_request.get("statusCheckRollup") or {}).get("contexts") or {}) - nodes.extend(page_contexts.get("nodes") or []) - page_info = page_contexts.get("pageInfo") or {} - contexts["nodes"] = nodes - contexts["pageInfo"] = page_info - - -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 _fail_fast_gh_api_json(path: str) -> Any: + """Run REST, retrying transient transport faults but never rate limits.""" - -def gh_api_json(path: str) -> Any: - """Run a GitHub REST API request through gh and decode the JSON response. - - Retries the shared installation rate limit or another transient GitHub - API error up to ``max_attempts`` times, mirroring :func:`gh_graphql`'s - existing retry convention; any other failure raises immediately exactly - as before. - """ - max_attempts = 4 - for attempt in range(1, max_attempts + 1): # pragma: no branch - last failed attempt always raises + maximum_attempts = 4 + for attempt_number in range(1, maximum_attempts + 1): try: - return json.loads(run_github_read(["gh", "api", path])) + return json.loads( + _scheduler_core.run_github_read(["gh", "api", path]) + ) except (RuntimeError, json.JSONDecodeError) as exc: - rate_limited = is_rate_limited_error(exc) - if attempt >= max_attempts or not (rate_limited or is_transient_github_api_error(exc)): - raise - if rate_limited: - delay = rate_limit_retry_delay_seconds("core", attempt) + if _scheduler_core.is_rate_limited_error(exc): print( - f"Rate-limited GitHub REST error on attempt {attempt}/{max_attempts} for {path}; retrying in {delay}s", + "GitHub REST primary rate limit is exhausted for " + f"{path}; deferring without runner-held sleep.", file=sys.stderr, ) - else: - delay = min(2 ** (attempt - 1), 8) - print( - f"Transient GitHub REST error on attempt {attempt}/{max_attempts} for {path}; retrying in {delay}s", - file=sys.stderr, - ) - time.sleep(delay) - - -def gh_api_json_via_dispatch_token(path: str) -> Any: - """Run a GitHub REST API GET via the central-repository dispatch credential. - - The OpenCode app installation has no Actions permission (see - :func:`scheduler_dispatch_env`), and the target-repository read - credential (:func:`gh_api_json`) is not guaranteed to have it either for - a cross-repository dispatch. A read against ``.github``'s own Actions - artifacts -- which always host the central draft-review-request marker - regardless of which repository the PR belongs to -- must use the same - central-repository dispatch credential already used for creating a - ``repository_dispatch`` there, not the target-repository read - credential. - """ - - return json.loads(run_github_dispatch(["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 fetch_all_pr_reviews_rest(repo: str, number: int) -> list[dict[str, Any]]: - """Fetch every REST review for a pull request, paginating past 100. - - A single ``per_page=100`` page silently drops earlier reviews once a PR - accumulates more than 100 review events, the same truncation the GraphQL - ``reviews(last: 100)`` window hits. This walks ``page=1,2,3,...`` -- - mirroring ``fetch_open_prs_rest``'s pagination style -- until a page - shorter than 100 rows confirms the end of the history. A page-fetch - failure propagates (fail closed) rather than returning a partial history. - """ - reviews: list[dict[str, Any]] = [] - page = 1 - while True: - batch = gh_api_json(f"repos/{repo}/pulls/{number}/reviews?per_page=100&page={page}") - if not batch: - break - reviews.extend(batch) - if len(batch) < 100: - break - page += 1 - return reviews - - -def fetch_workflow_names_by_check_suite_rest( - repo: str, head_sha: str -) -> dict[int, str]: - """Return exact-head GitHub Actions workflow names keyed by check-suite ID. - - REST check-run payloads omit workflow identity. The Actions run list - preserves the shared check-suite ID, allowing the REST fallback to - retain the same workflow-level policy boundary as the GraphQL path. - When the integration cannot read Actions, callers receive an empty - map and GitHub Actions checks are marked with a fail-closed sentinel. - """ - workflow_names: dict[int, str] = {} - page = 1 - while True: - try: - payload = gh_api_json( - f"repos/{repo}/actions/runs?head_sha={quote(head_sha, safe='')}" - f"&per_page=100&page={page}" + raise + if ( + attempt_number >= maximum_attempts + or not _scheduler_core.is_transient_github_api_error(exc) + ): + raise + retry_delay_seconds = min(2 ** (attempt_number - 1), 8) + print( + "Transient GitHub REST error on attempt " + f"{attempt_number}/{maximum_attempts} for {path}; " + f"retrying in {retry_delay_seconds}s", + file=sys.stderr, ) - except RuntimeError as exc: - if github_resource_inaccessible(exc): - return {} - raise - workflow_runs = payload.get("workflow_runs") or [] - for workflow_run in workflow_runs: - suite_id = workflow_run.get("check_suite_id") - workflow_name = str(workflow_run.get("name") or "").strip() - if suite_id is not None and workflow_name: - workflow_names[int(suite_id)] = workflow_name - if len(workflow_runs) < 100: - break - page += 1 - return workflow_names + _scheduler_core.time.sleep(retry_delay_seconds) + raise AssertionError("REST retry loop exited without a result") -def rest_check_node( - check: dict[str, Any], - suite_created_at_by_id: dict[int, str] | None = None, - workflow_name_by_suite_id: dict[int, str] | None = None, -) -> dict[str, Any]: - """Convert a REST check-run payload into the GraphQL status rollup shape. - ``suite_created_at_by_id`` and ``workflow_name_by_suite_id`` attach - the check-suite recency and workflow identity that GraphQL exposes - directly. Unknown GitHub Actions workflow identity is represented by - a fail-closed sentinel so it cannot be mistaken for a source failure. - """ - suite_id = (check.get("check_suite") or {}).get("id") - suite_created_at = (suite_created_at_by_id or {}).get(suite_id) - workflow_name = (workflow_name_by_suite_id or {}).get(suite_id) - if not workflow_name and (check.get("app") or {}).get("slug") == "github-actions": - workflow_name = REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW - workflow = {"name": workflow_name} if workflow_name else {} - 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": { - "createdAt": suite_created_at, - "workflowRun": {"workflow": workflow}, - }, - } +def install_fail_fast_rate_limit_policy() -> None: + """Install the production no-sleep policy on the scheduler core module.""" + _scheduler_core.gh_graphql = _fail_fast_gh_graphql + _scheduler_core.gh_api_json = _fail_fast_gh_api_json -def rest_status_node(status: dict[str, Any]) -> dict[str, Any]: - """Convert a REST classic commit-status payload into the GraphQL status rollup shape.""" - return { - "context": status.get("context"), - "state": (status.get("state") or "").upper(), - "targetUrl": status.get("target_url"), - } +def _argument_value( + argument_values: Sequence[str], option_name: str +) -> str | None: + """Return one CLI option value without assuming parser internals.""" + for argument_index, argument_value in enumerate(argument_values): + if argument_value != option_name: + continue + value_index = argument_index + 1 + if value_index < len(argument_values): + return argument_values[value_index] + return None + return None -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. - Classic commit statuses come from the *combined* status endpoint - (``commits/{sha}/status``), never the list endpoint - (``commits/{sha}/statuses``): the list endpoint returns full status - history in reverse-chronological order with no dedup, so a context that - transitioned from success to failure would surface both entries -- - letting a stale, superseded success outlive a later real failure for - any caller (like ``strix_evidence_state()``) that accepts the first - success it finds. The combined endpoint already reports only the most - recent status per context, matching the GraphQL rollup's own shape. - """ +def _is_opencode_post_approval_followup( + argument_values: Sequence[str], +) -> bool: + """Identify the best-effort OpenCode post-publication scheduler caller.""" - number = int(pr["number"]) - head = pr.get("head") or {} - base = pr.get("base") or {} - head_repo = head.get("repo") or {} - reviews = fetch_all_pr_reviews_rest(repo, number) - checks = gh_api_json(f"repos/{repo}/commits/{head.get('sha')}/check-runs?per_page=100") - check_runs = checks.get("check_runs") or [] - check_suites = gh_api_json(f"repos/{repo}/commits/{head.get('sha')}/check-suites?per_page=100") - suite_created_at_by_id = { - suite["id"]: suite.get("created_at") - for suite in (check_suites.get("check_suites") or []) - if suite.get("id") is not None - } - workflow_name_by_suite_id = ( - fetch_workflow_names_by_check_suite_rest(repo, str(head.get("sha") or "")) - if any( - (check.get("app") or {}).get("slug") == "github-actions" - for check in check_runs - ) - else {} - ) - combined_status = gh_api_json(f"repos/{repo}/commits/{head.get('sha')}/status") - 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(), + argument_set = set(argument_values) + return ( + os.environ.get("GITHUB_WORKFLOW", "") == "OpenCode Review Dispatch" + and _argument_value(argument_values, "--max-prs") == "1" + and _argument_value(argument_values, "--review-dispatch-limit") == "0" + and _argument_value(argument_values, "--merge-mode") + == "direct_or_auto" + and "--pr-number" in argument_set + and "--no-trigger-reviews" in argument_set + and "--enable-auto-merge" in argument_set + and "--no-update-branches" in argument_set ) - return { - "number": number, - "title": pr.get("title"), - "author": {"login": ((pr.get("user") or {}).get("login"))}, - "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, - suite_created_at_by_id, - workflow_name_by_suite_id, - ) - for check in check_runs - ] - + [ - rest_status_node(status) - for status in (combined_status.get("statuses") 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"] - - # Bulk-scan results feed merge decisions directly (the scheduler's push- - # triggered and org-queue-sweep runs never re-fetch a single PR before - # calling inspect_pr), so this path needs the same full review history as - # fetch_pr, not just the first/last 100-review window. - complete_all_pr_reviews(owner, name, prs) - 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 [] - complete_all_pr_reviews(owner, name, prs) - 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 _record_deferred_rate_limit(error_message: str) -> None: + """Write a typed OpenCode follow-up defer receipt.""" - -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}", - ] - ) + retry_owner = "Required PR Review Merge Scheduler heartbeat" + receipt = ( + "scheduler_outcome=deferred_rate_limit; " + f"retry_owner={retry_owner}; " + f"reason={error_message}" ) + print(receipt, file=sys.stderr) - -def enrich_rest_mergeable_states(repo: str, prs: list[dict[str, Any]]) -> None: - """Attach REST mergeability evidence to non-draft GraphQL pull request payloads. - - ``inspect_pr`` returns for a draft PR (dispatching at most a draft review) - before it ever reads ``restMergeableState``/``compareStatus``/ - ``compareBehindBy``, so refreshing those for a draft is two REST calls - (``pulls/{number}`` and ``compare/...``) spent on evidence no decision - ever consults. Skipping drafts here is pure dead-call elimination, not a - change to which non-draft PR gets merged/updated/reviewed. - """ - - 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)) - - mergeable_candidates = [pr for pr in prs if not pr.get("isDraft")] - if not mergeable_candidates: - return - - if len(mergeable_candidates) <= 1: - for pr in mergeable_candidates: - enrich(pr) + summary_path_value = os.environ.get("GITHUB_STEP_SUMMARY", "").strip() + if not summary_path_value: return + summary_path = Path(summary_path_value) + with summary_path.open("a", encoding="utf-8") as summary_file: + summary_file.write("### PR review scheduler deferred\n\n") + summary_file.write("- outcome: `deferred_rate_limit`\n") + summary_file.write(f"- retry owner: {retry_owner}\n") + summary_file.write("- runner-held sleep: 0 seconds\n") + summary_file.write(f"- reason: `{error_message}`\n\n") - max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(mergeable_candidates)) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - for _ in executor.map(enrich, mergeable_candidates): - 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_check_run(node: dict[str, Any]) -> bool: - """Return whether a CheckRun carries the OpenCode workflow identity.""" - if node.get("__typename") != "CheckRun": - 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 - ) - - -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 - return is_opencode_check_run(node) - return node.get("context") == "opencode-review" +def run_cli(argument_values: Sequence[str]) -> int: + """Run the scheduler with caller-scoped primary-rate-limit handling.""" -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 in {None, REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW} - ) - 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 matching_actions_run_id(pr: dict[str, Any], predicate: Any) -> int | None: - """Return the newest matching check-run's workflow run id, if exposed. - - Devin Review finding on PR #1507 ("Older review run remains blocking"): - an earlier version of this function returned the first predicate match - found scanning ``context_nodes`` in reverse, which is only the newest - match when GitHub happens to return the rollup in chronological order -- - not guaranteed, and not true for every real payload. With multiple - same-purpose check runs present (reruns, or two dispatches racing), that - could select an older, already-resolved run while a genuinely newer - failure sat unselected and unrerun. This now ranks every match with the - same ``check_run_recency_key`` signal ``_newest_check_run_per_identity`` - uses to resolve reruns elsewhere in this file, so position in the list - never decides the winner -- only actual recency does. - """ - candidates: list[tuple[tuple[int, datetime, int], int]] = [] - for index, node in enumerate(context_nodes(pr)): - if node.get("__typename") != "CheckRun" or not predicate(node): - continue - match = ACTIONS_RUN_DETAILS_URL_RE.search(node.get("detailsUrl") or "") - if match: - candidates.append( - ( - check_run_recency_key( - node, parse_github_datetime(node.get("startedAt")), index - ), - int(match.group(1)), - ) - ) - return max(candidates)[1] if candidates else 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 + install_fail_fast_rate_limit_policy() 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 check_run_recency_key( - node: dict[str, Any], started_at: datetime | None, index: int -) -> tuple[int, datetime, int]: - """Return a single comparable recency key for one same-purpose check run. - - Ranking a sequence of same-purpose check runs (either the reruns sharing - one (workflow, name) key in ``latest_check_runs``, or the - coverage-evidence runs ``latest_coverage_evidence_index`` compares across - workflow names) down to the single newest one used to be done by folding - a pairwise "does B supersede A" predicate left-to-right across the - candidates. That is only valid when the predicate is a transitive total - order, and it was not: a queued/null-``startedAt`` candidate could - legitimately supersede an older completed predecessor, but a later, - differently-timestamped completed candidate could then override that - queued winner too -- purely because "a timestamped candidate beats a - null-timestamp current-best" -- even when the later candidate was itself - older than whichever run the queued candidate had already displaced. - - Building one derived key per candidate instead, and comparing those keys - directly, cannot go non-transitive: Python tuple ordering is already a - valid total order, so ``max()``/``sorted()`` over these keys give a - result that does not depend on candidate order. - - A ``startedAt``-only signal has a gap: GitHub reports a check run as - ``completed``/``cancelled`` with ``startedAt: null`` when a queued rerun - is cancelled before it ever starts, so that row carries no timestamp and - is not pending either -- nothing (short of hardcoding the ``cancelled`` - conclusion, which would only patch this one case) distinguishes it from - a run that legitimately never mattered. ``checkSuite.createdAt`` closes - that gap generally instead of special-casing it: GitHub creates the - check suite unconditionally the moment the triggering push, rerun, or - dispatch happens, strictly before any check run inside it can be queued, - start, or be cancelled before starting, and ``CheckSuite.createdAt`` is - non-nullable in GitHub's schema. So it is a recency signal that is - always available, for every check run regardless of how it resolved -- - unlike ``startedAt``, which is genuinely absent for a run that never - started. - - Three tiers, low to high: - - * ``0`` -- no recency signal at all: neither the check run's own check - suite ``createdAt`` nor its ``startedAt`` is available, and it is not - currently pending either. Real GitHub responses always carry - ``checkSuite.createdAt``, so this tier is only reachable for - payloads that omit it (e.g. hand-built fixtures). - * ``1`` -- a real timestamp: the check run's own check suite - ``createdAt`` when present, else its ``startedAt``. Preferring the - check-suite timestamp means two runs are ranked by when each was - actually triggered, not by whether either one got far enough to - start -- a rerun cancelled before starting still ranks correctly - relative to an older, already-completed run. - * ``2`` -- no timestamp of any kind, but actively pending (queued/in - progress/etc, via ``running_check_state``): kept only as the - fallback for payloads without ``checkSuite.createdAt``, where GitHub - only ever creates such a row after any run it might supersede, so it - is presumed newer than every already-resolved run in that same - payload shape, regardless of that run's timestamp. - - Ties within a tier fall back to the later index, matching the order - ``context_nodes`` returns them in. - """ - epoch = datetime.min.replace(tzinfo=timezone.utc) - suite_created_at = parse_github_datetime((node.get("checkSuite") or {}).get("createdAt")) - recency_timestamp = suite_created_at or started_at - if recency_timestamp is not None: - return (1, recency_timestamp, index) - if running_check_state(node) == "running": - return (2, epoch, index) - return (0, epoch, index) - - -def _newest_check_run_per_identity( - indexed_check_runs: Sequence[tuple[int, dict[str, Any]]] -) -> list[tuple[int, dict[str, Any]]]: - """Return the newest CheckRun per (workflow, name) identity, index-tagged. - - Shared core for ``latest_check_runs`` (which keeps only CheckRun nodes) - and ``latest_check_run_attempts`` (which also passes non-CheckRun nodes - through unchanged): both resolve CheckRun reruns sharing one - (workflow, name) identity down to the single newest attempt, and both - must rank candidates with the identical ``check_run_recency_key`` signal - so they cannot silently diverge again the way ``latest_check_run_attempts`` - once did with its own ``startedAt``-only comparison. Each input - ``(index, node)`` pair's original position is preserved in the return - value so callers can restore overall document order after merging back - any non-CheckRun nodes. - """ - latest: dict[tuple[str, str], tuple[tuple[int, datetime, int], int, dict[str, Any]]] = {} - for index, node in indexed_check_runs: - 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")) - recency_key = check_run_recency_key(node, started_at, index) - previous = latest.get(key) - if previous is None or recency_key >= previous[0]: - latest[key] = (recency_key, index, node) - return [(index, node) for _, index, node in latest.values()] - - -def latest_check_runs(pr: dict[str, Any]) -> list[dict[str, Any]]: - """Return the newest check run for each workflow and check-name pair.""" - indexed_check_runs = [ - (index, node) - for index, node in enumerate(context_nodes(pr)) - if node.get("__typename") == "CheckRun" - ] - deduped = _newest_check_run_per_identity(indexed_check_runs) - return [node for _, node in sorted(deduped, key=lambda item: item[0])] - - -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 - + return int(_scheduler_core.main(list(argument_values))) + except RuntimeError as exc: + if ( + _scheduler_core.is_rate_limited_error(exc) + and _is_opencode_post_approval_followup(argument_values) + ): + _record_deferred_rate_limit(str(exc)) + # This exact caller retries every non-zero result three times with + # runner-held sleeps. Its follow-up is best-effort because the + # scheduled/PR-event scheduler remains authoritative. + return 0 + print(str(exc), file=sys.stderr) + return 1 -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" +class _SchedulerFacade(types.ModuleType): + """Forward legacy import reads and test monkeypatches to the core module.""" -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 __getattr__(self, attribute_name: str) -> Any: + return getattr(_scheduler_core, attribute_name) + def __setattr__(self, attribute_name: str, attribute_value: Any) -> None: + if ( + attribute_name.startswith("__") + or attribute_name in _FACADE_LOCAL_NAMES + ): + super().__setattr__(attribute_name, attribute_value) + return + setattr(_scheduler_core, attribute_name, attribute_value) -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 __delattr__(self, attribute_name: str) -> None: + if ( + attribute_name.startswith("__") + or attribute_name in _FACADE_LOCAL_NAMES + ): + super().__delattr__(attribute_name) + return + delattr(_scheduler_core, attribute_name) + def __dir__(self) -> list[str]: + return sorted(set(super().__dir__()) | set(dir(_scheduler_core))) -_STRIX_SUCCESS_CONCLUSIONS = {"SUCCESS"} +# Python's wildcard import reads ``__all__`` before attribute delegation. Export +# the original implementation's public API explicitly so consumers retain the +# same symbols after the implementation/facade split. +__all__ = tuple( + sorted( + attribute_name + for attribute_name in dir(_scheduler_core) + if not attribute_name.startswith("_") + ) +) -def latest_check_run_attempts(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Return each CheckRun's most recent attempt per (workflow, name) identity. - - A rerun leaves every earlier attempt's CheckRun node in the rollup - alongside the latest one, so callers that walk ``nodes`` directly can see - a stale failed attempt outlive a later successful retry. This used to - resolve each CheckRun identity with its own inline ``startedAt``-only - comparison, which had the same gap ``check_run_recency_key`` documents - for ``latest_check_runs``: GitHub reports a rerun cancelled before it - ever started as completed with ``startedAt: null``, so that row carried - no timestamp and could never outrank an older, already-completed - attempt -- even though it was the genuinely newer one. This now shares - the exact ``check_run_recency_key`` ranking (via - ``_newest_check_run_per_identity``) that ``latest_check_runs`` uses -- - preferring ``checkSuite.createdAt`` over ``startedAt``, with a - "currently pending" fallback tier -- so the two dedup passes rank - CheckRun reruns identically and cannot silently diverge again. Every - non-CheckRun (classic commit-status) node is passed through unchanged: - classic commit statuses never appear as duplicate reruns in - ``context_nodes``, so no dedup is needed for them. The result preserves - the original relative ordering. - """ - indexed_check_runs = [ - (index, node) for index, node in enumerate(nodes) if node.get("__typename") == "CheckRun" - ] - ordered: list[tuple[int, dict[str, Any]]] = [ - (index, node) for index, node in enumerate(nodes) if node.get("__typename") != "CheckRun" - ] - ordered.extend(_newest_check_run_per_identity(indexed_check_runs)) - ordered.sort(key=lambda item: item[0]) - return [node for _, node in ordered] - - -def strix_evidence_state(pr: dict[str, Any]) -> str: - """Return missing, running, failed, or complete for current-head Strix evidence. - - "complete" requires authoritative success (CheckRun conclusion or classic - commit-status state of SUCCESS) from *any* Strix identity present -- a - CheckRun and a classic commit-status context are both accepted, and - either one succeeding is sufficient. This repo documents that a same-head - manual `workflow_dispatch` Strix run, which posts a classic commit - status, "may supply review evidence but does not replace required PR - checks": it can unlock this internal review-dispatch gate even when the - `pull_request_target` CheckRun failed or cannot correctly evaluate a - self-modifying `.github` PR (that CheckRun runs the *base* branch's - trusted scripts, which a PR editing those very scripts can legitimately - fail against) -- but it never substitutes for GitHub's own independently - enforced required CheckRun at actual merge time, which this function - does not touch. Symmetrically, a stale classic-status failure left over - from an unrelated manual run must never keep this gate "failed" forever - once the real, retryable CheckRun evidence succeeds -- `dispatch_strix_evidence` - has no way to clear a classic status, only to rerun a CheckRun's Actions - job, so treating a lingering classic failure as still blocking once a - CheckRun has already succeeded would force an endless, pointless rerun - loop. - - Only when *no* identity reports success is this "failed" (every present - terminal outcome -- failure, error, cancelled, timed out, skipped, - neutral, action_required, stale, startup_failure -- counts as - non-passing) or "running" (something is still in flight and nothing has - succeeded yet), so callers fail closed instead of unlocking on evidence - that never actually passed anywhere. Only the latest attempt per Strix - CheckRun identity is evaluated, so a stale failed attempt cannot outlive - a later successful retry. - """ - strix_nodes = [node for node in latest_check_run_attempts(context_nodes(pr)) if is_strix_context(node)] - if not strix_nodes: - return "missing" - saw_running = False - for node in strix_nodes: - is_check_run = node.get("__typename") == "CheckRun" - status = (node.get("status") or node.get("state") or "").upper() - if status in RUNNING_CHECK_STATES: - saw_running = True - continue - if is_check_run: - if status != "COMPLETED": - saw_running = True - continue - conclusion = (node.get("conclusion") or "").upper() - if conclusion in _STRIX_SUCCESS_CONCLUSIONS: - return "complete" - elif status in _STRIX_SUCCESS_CONCLUSIONS: - return "complete" - return "running" if saw_running else "failed" - - -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_bot_review_author(review: dict[str, Any]) -> bool: - """Return whether a review's author is a GitHub bot actor. - - GitHub's REST API appends the ``[bot]`` suffix to a bot actor's - ``login`` (e.g. ``dependabot[bot]``), but GitHub's GraphQL API can - return the bare account name for that same actor (e.g. ``dependabot``) - while exposing ``__typename: "Bot"`` on the ``author`` field instead of - the suffix. Checking both keeps bot exclusion correct regardless of - which API surface -- and which suffix convention -- produced the - review node; ``rest_review_node`` never sets ``__typename``, so REST - reviews continue to rely solely on the login suffix. - """ - if review_author_login(review).endswith("[bot]"): - return True - return ((review.get("author") or {}).get("__typename")) == "Bot" - - -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_independent_current_head_approval(pr: dict[str, Any]) -> bool: - """Return whether an eligible reviewer's latest exact-head policy state approves.""" - author = ((pr.get("author") or {}).get("login") or "").lower() - if not author: - return False - seen_reviewers: set[str] = set() - for review in reversed((pr.get("reviews") or {}).get("nodes") or []): - reviewer = review_author_login(review) - state = (review.get("state") or "").upper() - if ( - not reviewer - or reviewer == author - or is_automated_opencode_review(review) - or reviewer == "github-actions" - or is_bot_review_author(review) - or not review_matches_current_head(review, pr) - or state not in {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"} - or reviewer in seen_reviewers - ): - continue - seen_reviewers.add(reviewer) - if state == "APPROVED": - return True - return False - - -def merge_approval_block_reason(pr: dict[str, Any]) -> str | None: - """Return the fail-closed repository and independent approval blocker.""" - review_decision = str(pr.get("reviewDecision") or "").upper() - if review_decision != "APPROVED": - return ( - "current-head OpenCode review approved, but GitHub reviewDecision is " - f"{review_decision or ''}; repository approval policy is 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 None - - -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 latest_current_head_coverage_change_request( - pr: dict[str, Any], -) -> dict[str, Any] | None: - """Return the latest exact-head OpenCode request that only cites coverage.""" - for review in reversed((pr.get("reviews") or {}).get("nodes") or []): - if not is_opencode_review(review) or not review_matches_current_head(review, pr): - continue - if (review.get("state") or "").upper() != "CHANGES_REQUESTED": - return None - body = (review.get("body") or "").lower() - return review if all(marker in body for marker in COVERAGE_REVIEW_MARKERS) else None - return None - - -def current_head_coverage_change_request(pr: dict[str, Any]) -> bool: - """Return whether the latest current-head request is only a coverage gate.""" - return latest_current_head_coverage_change_request(pr) is not None - - -def coverage_retry_wait_reason( - pr: dict[str, Any], - *, - repo: str | None = None, - workflow: str | None = None, - now: datetime | None = None, - floor_minutes: int = DEFAULT_COVERAGE_RETRY_FLOOR_MINUTES, -) -> str | None: - """Return a wait reason until one same-head coverage retry interval elapses. - - The latest exact-head review submission or completed dispatch timestamp is the - durable same-head retry marker. Missing or malformed timestamps fail closed so - a repeated coverage-only review cannot create an unbounded dispatch loop. - """ - review = latest_current_head_coverage_change_request(pr) - if review is None: - return None - submitted_at = parse_github_datetime(review.get("submittedAt")) - if submitted_at is None: - return "current-head OpenCode coverage review has no valid submission timestamp; defer same-head re-review" - retry_anchor = submitted_at - if repo and workflow: - try: - dispatch_started_at = latest_opencode_dispatch_started_at( - repo, workflow, pr, since=retry_anchor - ) - except RuntimeError: - return "same-head OpenCode dispatch history is unavailable; defer same-head re-review" - if dispatch_started_at and dispatch_started_at > retry_anchor: - retry_anchor = dispatch_started_at - current_time = now or datetime.now(timezone.utc) - if current_time < retry_anchor + timedelta(minutes=max(0, floor_minutes)): - return "same-head OpenCode coverage retry floor has not elapsed" - return None - - -def is_non_authoritative_coverage_check_run(node: dict[str, Any]) -> bool: - """Return whether central metadata-only coverage evidence is non-authoritative.""" - if not (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip(): - return False - if (node.get("name") or "").lower() != "coverage-evidence": - return False - workflow = ( - ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") - or {} - ) - return workflow.get("name") == "Required OpenCode Review" - - -def coverage_evidence_indices(check_runs: Sequence[dict[str, Any]]) -> list[int]: - """Return indexes of coverage-evidence checks in one check-run snapshot.""" - return [ - index - for index, node in enumerate(check_runs) - if (node.get("name") or "").lower() == "coverage-evidence" - and not is_non_authoritative_coverage_check_run(node) - ] - - -def latest_coverage_evidence_index(check_runs: Sequence[dict[str, Any]]) -> int | None: - """Return the newest coverage-evidence index across workflow names. - - Ranks every coverage-evidence candidate with ``check_run_recency_key`` - and picks the single largest key via ``max()``, so a freshly QUEUED - coverage-evidence rerun (``startedAt: null``) in one workflow correctly - outranks an older, already-completed coverage-evidence run in a - *different* workflow instead of losing a naive timestamp comparison - because it has not started yet -- and, unlike folding a pairwise - supersession predicate two at a time, the answer does not depend on how - many other candidates are present or what order they arrive in, because - each candidate's key depends only on its own timestamp/pending status. - """ - coverage_indices = coverage_evidence_indices(check_runs) - if not coverage_indices: - return None - return max( - coverage_indices, - key=lambda item: check_run_recency_key( - check_runs[item], - parse_github_datetime(check_runs[item].get("startedAt")), - item, - ), - ) - - -def coverage_evidence_state(pr: dict[str, Any]) -> str: - """Return missing, running, complete, or failed for the latest coverage gate.""" - check_runs = latest_check_runs(pr) - latest_index = latest_coverage_evidence_index(check_runs) - if latest_index is not None: - node = check_runs[latest_index] - status = (node.get("status") or "").upper() - if status in RUNNING_CHECK_STATES: - return "running" - return "complete" if (node.get("conclusion") or "").upper() == "SUCCESS" else "failed" - for node in reversed(context_nodes(pr)): - if node.get("__typename") == "CheckRun": - continue - name = (node.get("name") or node.get("context") or "").lower() - if name != "coverage-evidence": - continue - status = (node.get("status") or node.get("state") or "").upper() - if status in RUNNING_CHECK_STATES: - return "running" - return "complete" if status == "SUCCESS" else "failed" - return "missing" - - -def superseded_coverage_evidence_indices(check_runs: Sequence[dict[str, Any]]) -> set[int]: - """Return older coverage checks superseded by a newer successful run.""" - authoritative_index = latest_coverage_evidence_index(check_runs) - if authoritative_index is None: - return set() - authoritative = check_runs[authoritative_index] - if (authoritative.get("conclusion") or "").upper() != "SUCCESS": - return set() - return set(coverage_evidence_indices(check_runs)) - {authoritative_index} - - -def can_retry_check_gated_opencode_review(pr: dict[str, Any]) -> bool: - """Return whether recovered checks justify replacing a gate-only request.""" - for review in reversed((pr.get("reviews") or {}).get("nodes") or []): - if not is_opencode_review(review) or not review_matches_current_head(review, pr): - continue - body = str(review.get("body") or "") - return ( - (review.get("state") or "").upper() == "CHANGES_REQUESTED" - and CHECK_GATED_OPENCODE_CHANGE_REQUEST_MARKER in body - and "Failed checks:" in body - and not failed_status_checks(pr) - ) - return False - - -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], - *, - ignore_opencode: bool = False, -) -> list[str]: - """Return failing check or status context names from the PR rollup. - - ``ignore_opencode`` is reserved for the authenticated coverage-only retry - path: the previous ``opencode-review`` job or status is expected to be - failing there because it published the current-head coverage change request - being retried. Sibling jobs in the same workflow remain authoritative. - """ - failed: list[str] = [] - check_runs = latest_check_runs(pr) - superseded_coverage_indices = superseded_coverage_evidence_indices(check_runs) - status_contexts = [ - node - for node in context_nodes(pr) - if node.get("__typename") != "CheckRun" - ] - - successful_status_contexts = { - node.get("context") - for node in status_contexts - if (node.get("state") or "").upper() == "SUCCESS" - } - for index, node in enumerate(check_runs): - if is_non_authoritative_coverage_check_run(node): - continue - conclusion = (node.get("conclusion") or "").upper() - if conclusion in FAILED_CHECK_CONCLUSIONS: - if index in superseded_coverage_indices: - continue - if ignore_opencode and node.get("name") == "opencode-review": - continue - 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"}: - if ignore_opencode and is_opencode_context(node): - continue - 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") - require_workflow_starting_mutation_credential("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") - require_workflow_starting_mutation_credential("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 in {"missing", "failed"}: - wait_reason = repository_dispatch_wait_reason(repo, security_workflow) - if wait_reason: - return f"{head_note}; {wait_reason}" - dispatch_result = dispatch_strix_evidence(repo, security_workflow, updated_pr, dry_run=dry_run) - if dispatch_result == "already_running": - return f"{head_note}; same-head Strix evidence is already running" - if dispatch_result == "repository_busy": - return f"{head_note}; target repository already has active Strix evidence, so dispatch waits" - 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"]) - # A rerun brings a completed run back to queued/in_progress; invalidate - # any cached active_workflow_runs snapshot so it is not read as stale. - reset_active_workflow_runs_cache() - - -_active_workflow_runs_cache: dict[ - tuple[str, tuple[str, ...], str | None, str | None, str | None], list[dict[str, Any]] -] = {} - - -def reset_active_workflow_runs_cache() -> None: - """Clear the per-invocation cache backing :func:`active_workflow_runs`. - - ``main`` calls this once at the top of every scheduler run so the cache - never survives across separate invocations sharing a process (tests - calling ``main`` more than once, most notably). It must also be called - immediately after anything that changes GitHub Actions run state -- - force-cancelling, rerunning, or dispatching a run -- so a later read in - the same run observes that mutation instead of a stale pre-mutation - snapshot; :func:`force_cancel_workflow_runs`, :func:`rerun_actions_job`, - :func:`dispatch_opencode_review`, and :func:`dispatch_strix_evidence` all - do this immediately after their mutating call. - """ - _active_workflow_runs_cache.clear() - - -def active_workflow_runs( - repo: str, - statuses: Sequence[str] = ("queued", "in_progress"), - *, - event: str | None = None, - created: str | None = None, - head_sha: str | None = None, -) -> list[dict[str, Any]]: - """Return workflow runs for a repository, optionally narrowed server-side. - - ``event``, ``created``, and ``head_sha`` map directly onto GitHub's - ``List workflow runs for a repository`` REST query parameters (``event`` - selects the triggering webhook event, ``created`` accepts a date/range - qualifier such as ``>=2026-08-24T00:00:00Z``, ``head_sha`` narrows to - runs for one exact commit). All three are omitted by default so existing - callers keep fetching every run for the given statuses unfiltered; a - caller with a naturally bounded lookup -- one whose target repository's - run history only grows, such as a same-head dispatch search, or one - scoped to a single known commit -- should pass them to avoid paginating - history it can never use. - - Results are memoized per exact ``(repo, statuses, event, created, - head_sha)`` combination for the life of the cache (cleared by - :func:`reset_active_workflow_runs_cache`). The scheduler's queue sweep - calls the unfiltered ``(repo, ("queued", "in_progress"))`` shape from - every non-draft PR's unconditional stale-run check plus every review - dispatch check, all against the one repository a scheduler invocation - ever targets -- without memoization that is up to two redundant, - repository-wide, paginated REST calls per PR for identical data. - """ - cache_key = (repo, tuple(statuses), event, created, head_sha) - cached = _active_workflow_runs_cache.get(cache_key) - if cached is not None: - return list(cached) - runs: list[dict[str, Any]] = [] - for status in statuses: - args = [ - "gh", - "api", - "--method", - "GET", - f"repos/{repo}/actions/runs", - "--paginate", - "--slurp", - "-f", - f"status={status}", - "-F", - "per_page=100", - ] - if event: - args += ["-f", f"event={event}"] - if created: - args += ["-f", f"created={created}"] - if head_sha: - args += ["-f", f"head_sha={head_sha}"] - payload = json.loads(run_github_actions(args)) - pages = payload if isinstance(payload, list) else [payload] - for page in pages: - runs.extend(page.get("workflow_runs") or []) - _active_workflow_runs_cache[cache_key] = runs - return list(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.""" - raw_head = pr.get("headRefOid") - try: - head = validate_git_sha(str(raw_head or "")).lower() - except (TypeError, ValueError) as exc: - print( - f"::warning::stale_pr_run_ids: PR #{pr.get('number')} in {repo} has an " - f"invalid or unresolved headRefOid; preserving active runs ({exc})." - ) - return [] - 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() - ) - raw_head = pr.get("headRefOid") - try: - head = validate_git_sha(str(raw_head or "")).lower() - except (TypeError, ValueError) as exc: - print( - f"::warning::active_review_run_refs: PR #{pr.get('number')} in {target_repo} has an " - f"invalid or unresolved headRefOid; preserving review runs ({exc})." - ) - return [], [] - 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 latest_opencode_dispatch_started_at( - repo: str, - workflow: str, - pr: dict[str, Any], - *, - since: datetime | None = None, -) -> datetime | None: - """Return the latest completed same-head OpenCode dispatch start time. - - The dispatch repository hosting ``repository_dispatch`` runs only - accumulates completed-run history over time, so this narrows GitHub's - REST query server-side to ``event=repository_dispatch`` plus a - ``created`` lower bound of ``since``, instead of paginating every - completed run ever recorded there and filtering client-side. ``since`` - is safe to pass whenever the caller only cares about a dispatch strictly - newer than a known anchor timestamp -- any run created at or before that - anchor cannot become the returned maximum -- and is left unset (no lower - bound) for callers with no such anchor. - """ - target_repo = validate_github_repository(repo) - dispatch_repo = repository_dispatch_target(target_repo) - head = str(pr.get("headRefOid") or "").lower() - number = int(pr["number"]) - title_prefixes = tuple( - f"{title} {target_repo}#{number}@" - for title in sorted( - {"Required OpenCode Review", *OPENCODE_WORKFLOW_NAMES}, - key=len, - reverse=True, - ) - ) - created = f">={since.strftime('%Y-%m-%dT%H:%M:%SZ')}" if since else None - latest: datetime | None = None - for run_data in active_workflow_runs( - dispatch_repo, ("completed",), event="repository_dispatch", created=created - ): - if run_data.get("event") != "repository_dispatch": - continue - display_title = str(run_data.get("display_title") or "") - prefix = next( - (candidate for candidate in title_prefixes if display_title.startswith(candidate)), - None, - ) - if prefix is None: - continue - dispatched_head = display_title.removeprefix(prefix).lower() - if not GIT_SHA_RE.fullmatch(dispatched_head) or dispatched_head != head: - continue - started_at = parse_github_datetime( - run_data.get("run_started_at") or run_data.get("created_at") - ) - if started_at and (latest is None or started_at > latest): - latest = started_at - return latest - - -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))) - - # A cancelled run is no longer queued/in_progress; drop any cached - # active_workflow_runs snapshot so the next read (this same PR's later - # checks, or a later PR sharing this repository) sees the change instead - # of replaying it from before the cancellation. - reset_active_workflow_runs_cache() - - 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 _fresh_open_pr_for_cancellation(repo: str, number: int) -> dict[str, Any]: - """Return fresh open PR authority, including explicitly identified draft state.""" - payload = gh_api_json(f"repos/{repo}/pulls/{number}") - if not isinstance(payload, dict) or str(payload.get("state") or "").lower() != "open": - raise ValueError(f"PR #{number} in {repo} is not a resolvable open pull request") - if payload.get("draft") not in {True, False}: - raise ValueError(f"PR #{number} in {repo} has no authoritative live draft state") - validate_git_sha(str(((payload.get("head") or {}).get("sha")) or "")) - return payload - - -def _fresh_active_run_for_cancellation(run_repo: str, run_id: str) -> dict[str, Any]: - """Return fresh active workflow-run evidence immediately before cancellation.""" - payload = gh_api_json(f"repos/{run_repo}/actions/runs/{run_id}") - if not isinstance(payload, dict) or str(payload.get("status") or "").lower() not in { - "queued", - "in_progress", - }: - raise ValueError(f"workflow run {run_repo}#{run_id} is not active") - return payload - - -def _fresh_pr_head_for_cancellation(repo: str, number: int) -> str: - """Return the validated head SHA from fresh ready/open PR authority.""" - payload = _fresh_open_pr_for_cancellation(repo, number) - return validate_git_sha(str(((payload.get("head") or {}).get("sha")) or "")).lower() - - -def _direct_pr_run_still_superseded(repo: str, number: int, run_id: str) -> bool: - """Return whether a direct PR run is still older than the freshly fetched live head.""" - try: - run_data = _fresh_active_run_for_cancellation(repo, run_id) - if run_data.get("event") == "repository_dispatch" or not workflow_run_mentions_pr( - run_data, number - ): - raise ValueError("workflow run no longer has direct pull-request authority") - run_head = validate_git_sha(str(run_data.get("head_sha") or "")).lower() - live_head = _fresh_pr_head_for_cancellation(repo, number) - except (KeyError, RuntimeError, TypeError, ValueError) as exc: - print( - f"::warning::Preserving workflow run {run_id} in {repo}: " - f"live stale-run revalidation failed closed ({exc})." - ) - return False - return run_head != live_head - - -def _review_run_target_head( - run_data: dict[str, Any], repo: str, workflow: str, number: int -) -> str: - """Return a validated target head for one direct or trusted central review run.""" - if run_data.get("event") == "repository_dispatch": - titles = {"Required OpenCode Review", workflow, *OPENCODE_WORKFLOW_NAMES} - display_title = str(run_data.get("display_title") or "") - prefixes = tuple( - f"{title} {repo}#{number}@" for title in sorted(titles, key=len, reverse=True) - ) - prefix = next((candidate for candidate in prefixes if display_title.startswith(candidate)), None) - if prefix is None: - raise ValueError("repository_dispatch run has no trusted target identity") - return validate_git_sha(display_title.removeprefix(prefix)).lower() - if not workflow_run_mentions_pr(run_data, number): - raise ValueError("review run no longer belongs to the target pull request") - return validate_git_sha(str(run_data.get("head_sha") or "")).lower() - - -def _review_run_still_superseded( - repo: str, - workflow: str, - number: int, - run_repo: str, - run_id: str, -) -> bool: - """Return whether one review run remains stale against fresh ready/open PR authority.""" - try: - run_data = _fresh_active_run_for_cancellation(run_repo, run_id) - run_head = _review_run_target_head(run_data, repo, workflow, number) - live_head = _fresh_pr_head_for_cancellation(repo, number) - except (KeyError, RuntimeError, TypeError, ValueError) as exc: - print( - f"::warning::Preserving review run {run_repo}#{run_id}: " - f"live stale-run revalidation failed closed ({exc})." - ) - return False - return run_head != live_head - - -def cancel_stale_pr_runs(repo: str, pr: dict[str, Any], *, dry_run: bool) -> list[str]: - """Force-cancel only direct-run candidates still proven stale at the destructive boundary.""" - if dry_run: - return [] - require_github_actions_control_actor("force-cancel-stale-pr-runs") - number = int(pr["number"]) - candidates = [str(run_id) for run_id in stale_pr_run_ids(repo, pr)] - - def cancel_one(run_id: str) -> str | None: - """Revalidate and cancel one direct workflow-run candidate when still stale.""" - if not _direct_pr_run_still_superseded(repo, number, run_id): - return None - failures = force_cancel_workflow_runs(repo, [run_id]) - if run_id in failures: - return None - return run_id - - if len(candidates) <= 1: - results = [cancel_one(run_id) for run_id in candidates] - else: - max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(candidates)) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - results = list(executor.map(cancel_one, candidates)) - return [run_id for run_id in results if run_id is not None] - - -def cancel_stale_opencode_runs(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> list[str]: - """Force-cancel only review candidates still proven stale at the destructive boundary.""" - if dry_run: - return [] - require_github_actions_control_actor("force-cancel-stale-opencode-review") - number = int(pr["number"]) - _, stale_refs = active_opencode_run_refs(repo, workflow, pr) - - def cancel_one(run_ref: tuple[str, str]) -> str | None: - """Revalidate and cancel one review-run candidate when still stale.""" - run_repo, run_id = run_ref - if not _review_run_still_superseded(repo, workflow, number, run_repo, run_id): - return None - failures = force_cancel_workflow_runs(run_repo, [run_id]) - if run_id in failures: - return None - return run_id - - if len(stale_refs) <= 1: - results = [cancel_one(run_ref) for run_ref in stale_refs] - else: - max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(stale_refs)) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - results = list(executor.map(cancel_one, stale_refs)) - return [run_id for run_id in results if run_id is not None] - - - - -def discover_opencode_required_run_id(repo: str, head_sha: str) -> int | None: - """Return the current-head Required OpenCode Review run id via a bounded lookup. - - Devin Review finding on PR #1507 ("Large check rollups never wake"): - ``matching_actions_run_id`` only sees the GraphQL ``statusCheckRollup`` - fragment's first 100 status/check contexts - (``PULL_REQUEST_FIELDS_FRAGMENT``'s ``contexts(first: 100)``). A pull - request already carrying at least 100 contexts -- dozens of CI/security - workflows across several pushes and reruns is realistic in this - organization -- can push the real Required OpenCode Review check run - past that page, so the in-memory scan finds nothing even though the run - exists. This is a REST fallback, not a rewrite of that scan: it is - scoped server-side to the exact triggering event, the exact workflow - file path, and the exact current head SHA (GitHub's ``head_sha`` list - filter), so it stays a bounded, targeted lookup -- never an unfiltered - history walk -- and finds the run whether it is still queued/running or - already completed (the realistic failure mode is a stuck ``failure`` - conclusion on an otherwise-valid exact-head run). - """ - if not GIT_SHA_RE.fullmatch(head_sha): - return None - target_repo = validate_github_repository(repo) - newest_id: int | None = None - newest_started: datetime | None = None - for run_data in active_workflow_runs( - target_repo, - ("queued", "in_progress", "completed"), - event="pull_request_target", - head_sha=head_sha, - ): - if run_data.get("path") != OPENCODE_REVIEW_WORKFLOW_PATH: - continue - if str(run_data.get("head_sha") or "").lower() != head_sha.lower(): - continue - run_id = run_data.get("id") - if not run_id: - continue - started_at = parse_github_datetime( - run_data.get("run_started_at") or run_data.get("created_at") - ) - is_newer = started_at is not None and ( - newest_started is None or started_at > newest_started - ) - if newest_id is None or is_newer: - newest_id = int(run_id) - if started_at is not None: - newest_started = started_at - return newest_id - - -def _cancel_revalidated_review_run_refs( - repo: str, - workflow: str, - pr: dict[str, Any], - run_refs: list[tuple[str, str]], -) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: - """Cancel only review refs still proven stale immediately before each destructive call. - - A failed/malformed live read is preservation authority, not permission to - dispatch a duplicate review. The returned first list therefore contains - every active candidate that could not be proven stale; callers fold those - refs into their current/busy set. Multiple candidates retain the scheduler's - existing bounded executor and deterministic input ordering. - """ - if not run_refs: - return [], [] - number = int(pr["number"]) - - def cancel_one(run_ref: tuple[str, str]) -> tuple[str, tuple[str, str]]: - """Revalidate one candidate and cancel it only while it remains stale.""" - run_repo, run_id = run_ref - if not _review_run_still_superseded(repo, workflow, number, run_repo, run_id): - return "preserved", run_ref - failures = force_cancel_workflow_runs(run_repo, [run_id]) - if run_id in failures: - return "preserved", run_ref - return "cancelled", run_ref - - if len(run_refs) == 1: - outcomes = [cancel_one(run_refs[0])] - else: - max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(run_refs)) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - outcomes = list(executor.map(cancel_one, run_refs)) - preserved = [run_ref for state, run_ref in outcomes if state == "preserved"] - cancelled = [run_ref for state, run_ref in outcomes if state == "cancelled"] - return preserved, cancelled - -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) - preserved_run_refs, _cancelled_run_refs = _cancel_revalidated_review_run_refs( - repo, workflow, pr, stale_run_refs - ) - current_run_refs = [*current_run_refs, *preserved_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) - client_payload: dict[str, Any] = { - "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, - } - complete_paginated_pr_contexts(target_repo, pr) - required_run_id = matching_actions_run_id(pr, is_opencode_check_run) - if required_run_id is None: - required_run_id = discover_opencode_required_run_id(target_repo, head_sha) - if required_run_id is not None: - client_payload["required_run_id"] = required_run_id - run_github_dispatch( - [ - "gh", - "api", - "-X", - "POST", - f"repos/{dispatch_repo}/dispatches", - "--input", - "-", - ], - stdin=json.dumps( - { - "event_type": "opencode-review", - "client_payload": client_payload, - } - ), - ) - # A dispatch queues a new run; invalidate any cached active_workflow_runs - # snapshot so a later busy/current-run check in this same invocation sees it. - reset_active_workflow_runs_cache() - return "dispatched" - - -def is_strix_scan_check_run(node: dict[str, Any]) -> bool: - """Return whether a check run is the authoritative Strix scan job.""" - return ( - node.get("__typename") == "CheckRun" - and node.get("name") == "strix" - and is_strix_context(node) - ) - - -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_scan_check_run) - 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"}), - ) - preserved_run_refs, cancelled_refs = _cancel_revalidated_review_run_refs( - repo, workflow, pr, stale_run_refs - ) - current_run_refs = [*current_run_refs, *preserved_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" - target_repo = validate_github_repository(repo) - dispatch_repo = repository_dispatch_target(target_repo) - cancelled_ids = {run_id for _, run_id in cancelled_refs} - busy_refs = [ - (dispatch_repo, str(run_data["id"])) - for run_data in active_workflow_runs(dispatch_repo) - if run_data.get("id") - and str(run_data["id"]) not in cancelled_ids - and run_data.get("name") == workflow - and run_data.get("event") == "repository_dispatch" - and str(run_data.get("display_title") or "").startswith( - f"Strix Security Scan {target_repo}#" - ) - ] - if busy_refs: - print( - "Strix evidence dispatch skipped: target repository already has active run(s) " - + ", ".join(f"{run_repo}@{run_id}" for run_repo, run_id in busy_refs) - ) - return "repository_busy" - base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr) - 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, - }, - } - ), - ) - # A dispatch queues a new run; invalidate any cached active_workflow_runs - # snapshot so a later busy/current-run check in this same invocation sees it. - reset_active_workflow_runs_cache() - 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 revalidate_current_head_approval(repo: str, pr: dict[str, Any]) -> str | None: - """Re-check exact-head approval immediately before a merge-authorizing mutation. - - ``inspect_pr`` computes ``current_head_approved``/``approval_reason`` once, early - in the function, from the GraphQL/REST snapshot this scheduler invocation fetched - at the start of its run. Much later in the same invocation it reaches a branch - that calls ``merge_pr``/``enable_auto_merge`` using that stale snapshot. If the - reviewer who approved the exact head SHA dismisses or revokes that review -- or - GitHub otherwise recomputes ``reviewDecision`` -- in the window between the - snapshot and the mutating call, the merge would proceed on authorization that no - longer holds. The ``--match-head-commit`` guard those mutations carry only - protects against the *commit* changing in that window; it does nothing to protect - against the *review state* changing on the identical commit. - - Re-fetch the pull request right before the mutating call and recompute the exact - same independent exact-head approval decision (``has_current_head_approval`` and - ``merge_approval_block_reason``, the same helpers used for the original snapshot) - from the fresh data. Returns ``None`` when the fresh snapshot still authorizes the - merge, or a human-readable reason to block it otherwise. Any failure to re-fetch -- - a transient API error, or the pull request no longer being open or accessible -- - fails closed: it is treated exactly like a freshly observed missing approval so a - merge can never proceed on evidence this scheduler could not actually reconfirm. - """ - number = pr["number"] - try: - refreshed = fetch_pr(repo, number) - except RuntimeError as exc: - return ( - "re-checking current-head approval immediately before merge failed " - f"({exc}); treating the exact-head approval as unconfirmed" - ) - if not refreshed: - return ( - "re-checking current-head approval immediately before merge found PR " - f"#{number} no longer open or accessible; treating the exact-head " - "approval as unconfirmed" - ) - fresh_pr = refreshed[0] - expected_head = pr.get("headRefOid") - fresh_head = fresh_pr.get("headRefOid") - if expected_head and fresh_head and fresh_head != expected_head: - return ( - f"current head changed from {short_sha(expected_head)} to " - f"{short_sha(fresh_head)} immediately before merge; the exact-head " - "approval no longer applies to the current commit" - ) - if not has_current_head_approval(fresh_pr): - return ( - "current-head OpenCode approval was revoked immediately before merge; " - "the merge-authorizing snapshot is no longer current" - ) - reason = merge_approval_block_reason(fresh_pr) - if reason: - return f"{reason} (re-confirmed immediately before merge)" - return None - - -def draft_review_request_artifact_name(repo: str, pr_number: int, head_sha: str) -> str: - """Return one draft review-only request marker's exact artifact name.""" - return f"cwl-draft-review-request-{repo.replace('/', '-')}-{pr_number}-{head_sha}" - - -def _draft_review_request_records(value: Any, *, expected_name: str) -> tuple[dict[str, Any], ...]: - """Validate one exact-name repository artifact response and return live records. - - The server-side ``name`` filter makes this response directly addressable by - PR and exact head. Any malformed, mismatched, truncated, or ambiguous - response fails closed rather than being interpreted as an active request. - """ - if not isinstance(value, dict): - raise ValueError("artifact response must be an object") - total_count = value.get("total_count") - artifacts = value.get("artifacts") - if type(total_count) is not int or total_count < 0: - raise ValueError("artifact response has an invalid total_count") - if not isinstance(artifacts, list): - raise ValueError("artifact response has an invalid artifacts collection") - if total_count != len(artifacts): - raise ValueError("artifact response is truncated or internally inconsistent") - live: list[dict[str, Any]] = [] - for artifact in artifacts: - if not isinstance(artifact, dict): - raise ValueError("artifact response contains a non-object record") - artifact_id = artifact.get("id") - name = artifact.get("name") - expired = artifact.get("expired") - if type(artifact_id) is not int or artifact_id < 1: - raise ValueError("artifact response contains an invalid artifact id") - if not isinstance(name, str) or name != expected_name: - raise ValueError("artifact response contains a mismatched artifact name") - if type(expired) is not bool: - raise ValueError("artifact response contains an invalid expired flag") - if not expired: - live.append(artifact) - return tuple(live) - - -def active_draft_review_request(repo: str, pr: dict[str, Any]) -> bool: - """Return whether an explicit draft review-only request is active for this head. - - This is the sole automatic gate for draft review dispatch. A bare - ``repository_dispatch`` ``client_payload`` field (an invocation key, a PR - number) is never trusted on its own: any dispatch-capable caller could - supply one for an arbitrary target, and a genuinely stale mention (the - draft gained a new commit after being requested) must not review a - commit nobody asked about. ``agent-mention-opencode-dispatch.yml`` - instead uploads one short-lived Actions artifact per mention invocation, - named with the exact PR and head SHA - (:func:`draft_review_request_artifact_name`), only after that workflow's - own HMAC-style canonical-payload check has already validated the - invocation -- so a live artifact is itself the validated proof, bound to - one exact head, that this specific mention was genuine. The artifact - lives in the central automation repository (the same repository - ``repository_dispatch`` review dispatch always targets, per - :func:`repository_dispatch_target`), so every scheduler pass over this - draft PR -- the initial mention-triggered run and any later pass with no - ``repository_dispatch`` ``client_payload`` of its own, most commonly the - Strix-completion ``workflow_run`` that follows an initial - ``security_dispatch`` -- checks the same durable signal here rather than - trusting anything the triggering event itself claims. The read always - uses the central-repository dispatch credential - (:func:`gh_api_json_via_dispatch_token`), because the artifact always - lives in that central repository regardless of which repository ``repo`` - names, and the target-repository read credential is not guaranteed to - have Actions permission there for a cross-repository dispatch. That - dispatch credential is itself only valid when this scheduler executes - inside the central repository; an ordinary required-workflow scan - executing directly in a sibling repository has no credential able to - read the central repository's artifacts at all. Rather than let that - ``gh`` failure -- or a malformed/tampered artifact-list response -- - propagate and abort the whole multi-PR scan over one draft PR, any - failure to positively confirm a live artifact resolves to ``False``: - the same safe "no explicit request" outcome as a live check that - actually completes and finds nothing. - """ - head_sha = pr.get("headRefOid") - if not isinstance(head_sha, str) or not head_sha: - return False - dispatch_repo = repository_dispatch_target(validate_github_repository(repo)) - artifact_name = draft_review_request_artifact_name(repo, pr["number"], head_sha) - try: - response = gh_api_json_via_dispatch_token( - f"repos/{dispatch_repo}/actions/artifacts?name={artifact_name}&per_page=100" - ) - return bool(_draft_review_request_records(response, expected_name=artifact_name)) - except (RuntimeError, ValueError): - return False - - -def dispatch_draft_review_only( - repo: str, - pr: dict[str, Any], - *, - dry_run: bool, - review_dispatch_allowed: bool, - workflow: str, - security_workflow: str, - stale_opencode_minutes: int, -) -> Decision: - """Dispatch review evidence for one draft PR, never touching merge/branch state. - - An explicit review-only request (a mention invocation, never the ordinary - queue sweep) may reach this for a draft PR. It runs exactly the same - Strix-then-OpenCode dispatch gate the ready-PR pipeline uses below, so a - draft gets the same evidence chain -- but it returns before any of - ``inspect_pr``'s unresolved-thread, changes-requested, branch-update, or - auto-merge logic, so a draft can never be merged, auto-merged, or have its - branch updated by reaching this function. - """ - number = pr["number"] - opencode_state = opencode_progress_state(pr, stale_after_minutes=stale_opencode_minutes) - if opencode_state == "running": - return Decision(number, "wait", "draft PR review-only dispatch; OpenCode review already running") - # opencode_state == "complete" means a matching check/status reached a - # terminal state -- it does not mean opencode-agent posted a review. The - # required-workflow gate itself fails closed (a terminal, non-running - # check) whenever no verdict was ever dispatched, so treating "complete" - # alone as a verdict would make a failed dispatch attempt permanently - # block every later explicit retry. Only an actual current-head formal - # review is a verdict. - if has_current_head_approval(pr) or has_current_head_changes_requested(pr): - return Decision( - number, - "skip", - "draft PR review-only dispatch; current-head OpenCode verdict already exists", - ) - strix_state = strix_evidence_state(pr) - if strix_state in {"missing", "failed"}: - if not review_dispatch_allowed: - return Decision( - number, - "wait", - "draft PR review-only dispatch; current head has no completed Strix evidence; " - "review dispatch limit reached", - ) - wait_reason = repository_dispatch_wait_reason(repo, security_workflow) - if wait_reason: - return Decision( - number, - "wait", - f"draft PR review-only dispatch; current head has no completed Strix evidence; {wait_reason}", - ) - dispatch_result = dispatch_strix_evidence(repo, security_workflow, pr, dry_run=dry_run) - if dispatch_result == "already_running": - return Decision( - number, "wait", "draft PR review-only dispatch; same-head Strix evidence is still running" - ) - if dispatch_result == "repository_busy": - return Decision( - number, - "wait", - "draft PR review-only dispatch; current head has no completed Strix evidence; " - "target repository already has active Strix evidence", - ) - return Decision( - number, - "security_dispatch", - "draft PR review-only dispatch; current head has no completed Strix evidence; same-head Strix dispatched", - ) - if strix_state == "running": - return Decision(number, "wait", "draft PR review-only dispatch; same-head Strix evidence is still running") - if not review_dispatch_allowed: - return Decision( - number, - "wait", - "draft PR review-only dispatch; current head has completed Strix evidence; " - "review dispatch limit reached", - ) - wait_reason = repository_dispatch_wait_reason(repo, workflow) - if wait_reason: - return Decision( - number, - "wait", - f"draft PR review-only dispatch; 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 Decision( - number, - "wait", - "draft PR review-only dispatch; current head has completed Strix evidence; " - "same-head OpenCode workflow run is already active", - ) - return Decision( - number, - "review_dispatch", - "draft PR review-only dispatch; current head has completed Strix evidence; same-head OpenCode dispatched", - ) - - -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, - allow_draft_review_dispatch: bool = False, -) -> 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"): - if trigger_reviews and ( - allow_draft_review_dispatch or active_draft_review_request(repo, pr) - ): - return dispatch_draft_review_only( - repo, - pr, - dry_run=dry_run, - review_dispatch_allowed=review_dispatch_allowed, - workflow=workflow, - security_workflow=security_workflow, - stale_opencode_minutes=stale_opencode_minutes, - ) - 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) - check_gated_retry = can_retry_check_gated_opencode_review(pr) - if check_gated_retry and pr.get("autoMergeRequest"): - opencode_state = "complete" - elif check_gated_retry and trigger_reviews and opencode_state != "running": - opencode_state = "absent" - 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", - ) - if opencode_state in {"absent", "stale"} and trigger_reviews and not review_dispatch_allowed: - return Decision( - number, - "wait", - f"stacked PR onto {base_ref}; OpenCode review {opencode_state}; " - "review dispatch limit reached", - ) - 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 revalidate_before_merge() -> Decision | None: - """Return a blocking decision if a fresh re-check just revoked approval. - - Call this immediately before every ``merge_pr``/``enable_auto_merge`` - invocation below, after every other authorization check has already passed - against the (possibly stale) snapshot fetched at the top of this scheduler - invocation -- closing the TOCTOU window between that snapshot and the - mutating call. Returns ``None`` when the fresh re-check still authorizes the - merge, so the caller proceeds unchanged. dry-run inspection never mutates - anything, so it skips the extra re-fetch entirely. - """ - if dry_run: - return None - reason = revalidate_current_head_approval(repo, pr) - if not reason: - return None - if pr.get("autoMergeRequest"): - return finish(disable_auto_merge_decision(repo, pr, dry_run=dry_run, reason=reason)) - return decide("wait", 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", - ) - if not head_mutation_credential_starts_workflows(): - return decide( - "wait", - f"{freshness_reason}; {non_triggering_head_mutation_reason('branch update')}", - ) - 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): - behind_by = branch_outdated_by_base(pr, merge_state) - if ( - merge_state not in {"DIRTY", "CONFLICTING"} - and behind_by - and not pr.get("autoMergeRequest") - and update_branches - and trigger_reviews - and review_dispatch_allowed - and can_update_pr_head(repo, pr) - ): - return request_branch_update( - "current-head OpenCode review requested changes; branch is outdated before re-review" - ) - coverage_retry_progress = opencode_progress_state( - pr, stale_after_minutes=stale_opencode_minutes - ) - coverage_ready = ( - merge_state not in {"DIRTY", "CONFLICTING"} - and trigger_reviews - and review_dispatch_allowed - and current_head_coverage_change_request(pr) - and coverage_evidence_state(pr) == "complete" - and strix_evidence_state(pr) == "complete" - and not failed_status_checks(pr, ignore_opencode=True) - ) - if coverage_ready: - if coverage_retry_progress == "running": - if pr.get("autoMergeRequest"): - return finish( - disable_auto_merge_decision( - repo, - pr, - dry_run=dry_run, - reason=( - "current-head OpenCode coverage evidence is complete; disable " - "auto-merge while same-head re-review is already running" - ), - ) - ) - return decide( - "wait", - "current-head OpenCode coverage evidence is complete; " - "same-head OpenCode re-review is already running", - ) - retry_wait_reason = coverage_retry_wait_reason( - pr, - repo=repo if not dry_run else None, - workflow=workflow if not dry_run else None, - ) - if retry_wait_reason: - if pr.get("autoMergeRequest"): - return finish( - disable_auto_merge_decision( - repo, - pr, - dry_run=dry_run, - reason=( - f"{retry_wait_reason}; disable auto-merge until the same-head " - "coverage retry floor elapses" - ), - ) - ) - return decide("wait", retry_wait_reason) - if pr.get("autoMergeRequest"): - return finish( - disable_auto_merge_decision( - repo, - pr, - dry_run=dry_run, - reason=( - "current-head OpenCode coverage blocker is cleared; disable auto-merge " - "before same-head re-review" - ), - ) - ) - wait_reason = repository_dispatch_wait_reason(repo, workflow) - if wait_reason: - return decide("wait", wait_reason) - dispatch_result = dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) - if dispatch_result == "already_running": - return decide( - "wait", - "current-head coverage evidence is complete, but a same-head OpenCode workflow run is already active", - ) - return decide( - "review_dispatch", - "current-head OpenCode coverage blocker is cleared; same-head OpenCode re-dispatched", - ) - # Not a coverage-only gate: a separately eligible check-gated retry (the - # review was blocked only on then-failing GitHub Checks, which have - # since cleared) also earns a fall-through instead of a block, so the - # ordinary Strix/OpenCode dispatch pipeline below can re-review it. - check_gated_retry_ready = ( - can_retry_check_gated_opencode_review(pr) - and trigger_reviews - and review_dispatch_allowed - and not pr.get("autoMergeRequest") - ) - if not check_gated_retry_ready: - conflict_suffix = ( - f"; {merge_conflict_guidance(pr, merge_state)}" - if merge_state in {"DIRTY", "CONFLICTING"} - else "" - ) - 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 " - f"before re-enabling auto-merge{conflict_suffix}" - ), - ) - ) - return decide( - "block", - f"current-head OpenCode review requested changes{conflict_suffix}", - ) - - current_head_approved = has_current_head_approval(pr) - approval_reason = merge_approval_block_reason(pr) if current_head_approved else None - 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 approval_reason and auto_merge_enabled: - return finish( - disable_auto_merge_decision( - repo, - pr, - dry_run=dry_run, - reason=( - f"{approval_reason}; obtain fresh independent approval before " - "re-enabling auto-merge" - ), - ) - ) - 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 approval_reason: - return decide("wait", approval_reason) - 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"}: - revalidation = revalidate_before_merge() - if revalidation: - return revalidation - 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)) - revalidation = revalidate_before_merge() - if revalidation: - return revalidation - 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 current_head_approved: - # auto_merge_enabled must be True to have reached this branch (the - # outer condition requires current_head_approved or - # auto_merge_enabled). An outdated branch is routine and does not - # by itself justify disarming auto-merge -- but an auto-merge - # request armed with no live current-head approval is exactly the - # stale authorization this scheduler exists to catch, and simply - # requesting a branch update here would leave it queued: once the - # updated head's required checks pass, GitHub's own native - # auto-merge could merge it without this scheduler ever getting a - # chance to require a fresh independent approval on that new - # head. Disarm before requesting the update rather than after. - return finish( - disable_auto_merge_decision( - repo, - pr, - dry_run=dry_run, - reason=( - f"branch is {behind_by} commit(s) behind base (GitHub mergeability is " - f"{merge_state}) with no live current-head approval to authorize " - "auto-merge; obtain fresh independent approval before re-enabling auto-merge" - ), - ) - ) - if not update_branches: - return decide("wait", "current-head OpenCode review approved; 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 merge_state == "BEHIND": - freshness_reason = "current-head OpenCode review approved" - else: - freshness_reason = ( - "current-head OpenCode review approved; " - 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", - ) - if not head_mutation_credential_starts_workflows(): - return decide( - "wait", - f"{block_reason}; {non_triggering_head_mutation_reason('last-push approval head restamp')}", - ) - 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, - ) - ) - - if not current_head_approved and auto_merge_enabled: - # Neither behind-by disarm path applies (the branch is not behind - # base) and the last-push-approval restamp does not apply either (it - # requires current_head_approved). Yet auto-merge is still armed with - # no live current-head approval -- whether from a previously valid - # approval a new push has since invalidated, or from auto-merge armed - # before any review ever ran, this scheduler draws no distinction - # between the two (see the behind-by disarm path and the prior - # unconditional catch-all below, neither of which drew one either). - # Disarm immediately here, before any of the wait/dispatch branches - # below (OpenCode running, deterministic-fallback wait, stale-review - # retry, or the ordinary Strix/OpenCode dispatch cascade -- the - # everyday state for a PR between or during reviews) can return - # without having done so. Relying on a catch-all reached only once - # dispatch has nothing left to do would let GitHub's own native - # auto-merge complete the merge first if this scheduler is the only - # thing enforcing the OpenCode-approval requirement. - 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" - ), - ) - ) - - 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 approval_reason: - return decide("wait", approval_reason) - 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": - revalidation = revalidate_before_merge() - if revalidation: - return revalidation - 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}") - revalidation = revalidate_before_merge() - if revalidation: - return revalidation - 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 in {"missing", "failed"}: - 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_result = dispatch_strix_evidence(repo, security_workflow, pr, dry_run=dry_run) - if dispatch_result == "already_running": - return decide("wait", "same-head Strix evidence is still running") - if dispatch_result == "repository_busy": - return decide( - "wait", - "current head has no completed Strix evidence; target repository already has active Strix evidence", - ) - 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", - ) - - # No autoMergeRequest re-check is needed here: the hoisted - # `not current_head_approved and auto_merge_enabled` guard above already - # disarmed and returned before any of the wait/dispatch branches between - # it and here could be reached, so auto-merge cannot still be armed by - # this point. - 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(head_mutation_credential_upgrade_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 head_mutation_credential_upgrade_summary(decisions: list[Decision]) -> list[str]: - """Return a GitHub Actions Summary section for withheld head mutations.""" - waits = [decision for decision in decisions if parse_non_triggering_head_mutation_reason(decision.reason)] - if not waits: - return [] - summary, automation_limit = head_mutation_credential_guidance_text() - lines = ["", "### Head mutation withheld", "", summary, automation_limit] - lines.extend( - [ - "Configure `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, or the OpenCode app credential, then rerun the scheduler.", - "Alternatively, let the PR author push the branch so required checks start from the owning actor.", - "", - "Withheld decisions:", - ] - ) - lines.extend(f"- PR #{decision.pr}: {decision.reason}" for decision in waits) - return lines - - -def parse_non_triggering_head_mutation_reason(reason: str) -> bool: - """Return whether a reason describes a withheld non-triggering head mutation.""" - return ( - "whose head mutations never start new workflow runs" in reason - or "which is not allowlisted as workflow-starting" in reason - ) - - -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) - - -@contextlib.contextmanager -def declared_mutation_token_source(source: str) -> Iterator[None]: - """Declare a scheduler mutation credential source for the enclosed block.""" - previous = os.environ.get("SCHEDULER_MUTATION_TOKEN_SOURCE") - os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = source - try: - yield - finally: - if previous is None: - os.environ.pop("SCHEDULER_MUTATION_TOKEN_SOURCE", None) - else: - os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = previous - - -def self_test() -> None: - """Exercise scheduler invariants without GitHub network access.""" - with declared_mutation_token_source("PR_REVIEW_MERGE_TOKEN"): - self_test_scheduler_invariants() - - -def self_test_scheduler_invariants() -> None: - """Exercise scheduler invariants with a workflow-starting mutation credential.""" - 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, - "author": {"login": "pull-request-author"}, - "headRefOid": "abc", - "baseRefName": "main", - "baseRefOid": "base", - "headRefName": "feature", - "mergeStateStatus": "CLEAN", - "restMergeableState": "CLEAN", - "isDraft": False, - "isCrossRepository": False, - "maintainerCanModify": False, - "headRepository": {"nameWithOwner": "owner/repo"}, - "reviewDecision": "APPROVED", - "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"}, - }, - { - "state": "APPROVED", - "author": {"login": "independent-reviewer"}, - "submittedAt": "2026-06-25T15:43: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"} - sample["reviews"]["nodes"].append( - { - "state": "APPROVED", - "author": {"login": "independent-reviewer"}, - "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["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, - "author": {"login": "pull-request-author"}, - "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"}, - }, - { - "state": "APPROVED", - "author": {"login": "independent-reviewer"}, - "submittedAt": "2026-06-25T15:43: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" - with declared_mutation_token_source("github-token"): - 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" - withheld_guidance = decision_guidance( - Decision(1, "wait", non_triggering_head_mutation_reason("branch update")) - ) - assert withheld_guidance - assert withheld_guidance["type"] == "head_mutation_credential_upgrade" - assert withheld_guidance["token"] == "workflow GITHUB_TOKEN" - assert not head_mutation_credential_starts_workflows() - assert head_mutation_credential_starts_workflows() - 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" - with declared_mutation_token_source("github-token"): - entry = decision_contract_entry(Decision(1, "update_branch", "ok")) - assert entry["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( - "--allow-draft-review-dispatch", - action="store_true", - help=( - "Allow a --pr-number draft PR to receive Strix/OpenCode review " - "dispatch. Structurally review-only: never merges, enables " - "auto-merge, or updates the branch. A manual operator override " - "for direct CLI use only -- no caller-supplied signal reaching " - "this script (repository_dispatch client_payload included) is " - "trusted to set this automatically, because it cannot be bound " - "to a specific validated request. The production automatic path " - "is inspect_pr()'s own active_draft_review_request() marker " - "check, gated on a cryptographically validated, exact-head-named " - "artifact that only a legitimate mention invocation can create." - ), - ) - 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( - "--stacked-review-dispatch-limit", - type=int, - default=None, - help="Optional separate OpenCode review dispatch limit for stacked PRs; -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.""" - # Each invocation is a fresh look at GitHub; never reuse another - # invocation's active_workflow_runs cache (relevant when a process - # calls main() more than once, tests included). - reset_active_workflow_runs_cache() - 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.stacked_review_dispatch_limit is not None and args.stacked_review_dispatch_limit < -1: - raise SystemExit("--stacked-review-dispatch-limit must be -1 or greater") - if args.branch_update_limit < -1: - raise SystemExit("--branch-update-limit must be -1 or greater") - if args.allow_draft_review_dispatch and not args.pr_number: - raise SystemExit( - "--allow-draft-review-dispatch requires --pr-number; it is a single-PR " - "review-only exception, never a default for the multi-PR queue sweep" - ) - prs = fetch_pr(args.repo, args.pr_number) if args.pr_number else fetch_open_prs(args.repo, args.max_prs) - if not args.pr_number: - # Stacked PRs have no injected required workflow and depend exclusively - # on this bounded sweep; default-base PRs also receive event-driven runs. - prs.sort(key=lambda pr: pr.get("baseRefName") == args.base_branch) - decisions = [] - review_dispatches_used = 0 - stacked_review_dispatches_used = 0 - branch_updates_used = 0 - for pr in prs: - stacked_pr = pr.get("baseRefName") != args.base_branch - if stacked_pr and args.stacked_review_dispatch_limit is not None: - review_dispatch_allowed = ( - args.stacked_review_dispatch_limit < 0 - or stacked_review_dispatches_used < args.stacked_review_dispatch_limit - ) - else: - 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, - allow_draft_review_dispatch=args.allow_draft_review_dispatch, - ) - except RuntimeError as exc: - if is_rate_limited_error(exc): - # A mid-scan shared-installation rate-limit exhaustion (e.g. - # from an active-run read, cancellation, dispatch, merge, or - # branch update inside inspect_pr(), as opposed to the - # fetch_open_prs()/fetch_pr() calls above the loop) must - # propagate exactly like that earlier path does, instead of - # being folded into an ordinary action_error decision here. - # Swallowing it and continuing the loop would keep spending - # the same exhausted bucket on every remaining PR in this - # repository; returning 0 afterward would also mean this - # never reaches the workflow's "API rate limit exceeded" - # skip-and-defer branch (which only fires on a non-zero exit - # code), so later repositories in the same org-sweep rotation - # would keep spending the shared bucket too. Print the - # summary for the PRs already inspected so their decisions - # and dispatch/update counts are not lost, then let the error - # propagate and exit non-zero like the pre-loop rate-limit - # path. - decisions.append( - Decision( - pr.get("number", 0), - "action_error", - summarize_action_error(exc), - ) - ) - print_summary( - decisions, - dry_run=args.dry_run, - base_branch=args.base_branch, - project_flow=args.project_flow, - ) - raise - decision = Decision( - pr.get("number", 0), - "action_error", - summarize_action_error(exc), - ) - decisions.append(decision) - if decision.action in {"review_dispatch", "security_dispatch"}: - if stacked_pr and args.stacked_review_dispatch_limit is not None: - stacked_review_dispatches_used += 1 - else: - 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 +_FACADE_LOCAL_NAMES = frozenset(globals()) | {"_FACADE_LOCAL_NAMES"} +sys.modules[__name__].__class__ = _SchedulerFacade 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 + raise SystemExit(run_cli(sys.argv[1:])) 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 0000000000..5cf6e81cbf --- /dev/null +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -0,0 +1,5773 @@ +#!/usr/bin/env python3 +"""Inspect PR review state and drive centralized OpenCode merge automation.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import contextlib +import json +import os +import re +import shlex +import subprocess +import sys +import time +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import quote + + +PULL_REQUEST_FIELDS_FRAGMENT = """\ +fragment SchedulerPullRequestFields on PullRequest { + number + title + author { login } + 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) { + pageInfo { hasPreviousPage startCursor } + nodes { + databaseId + state + body + submittedAt + author { login __typename } + commit { oid } + } + } + statusCheckRollup { + contexts(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on CheckRun { + name + status + conclusion + startedAt + detailsUrl + checkSuite { + createdAt + 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 + +# Follow-up query for one pull request's reviews, walking backward past the +# ``reviews(last: 100)`` window in SchedulerPullRequestFields. GraphQL +# connections keep chronological (oldest-first) node order regardless of +# pagination direction, so ``last: 100, before: $cursor`` returns the up-to-100 +# reviews immediately preceding the cursor, still oldest-first. +PR_REVIEWS_PAGE_QUERY = """\ +query($owner: String!, $name: String!, $number: Int!, $cursor: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviews(last: 100, before: $cursor) { + pageInfo { hasPreviousPage startCursor } + nodes { + databaseId + state + body + submittedAt + author { login __typename } + commit { oid } + } + } + } + } +} +""" + +PR_CONTEXTS_PAGE_QUERY = """\ +query($owner: String!, $name: String!, $number: Int!, $cursor: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + statusCheckRollup { + contexts(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on CheckRun { + name status conclusion startedAt detailsUrl + checkSuite { createdAt workflowRun { workflow { name } } } + } + ... on StatusContext { context state } + } + } + } + } + } +} +""" + +OPEN_PRS_PAGE_SIZE = 25 +# Defends against a pathological GraphQL pageInfo loop when backfilling a PR's +# full review history; 500 pages * 100 reviews/page is far beyond any +# realistic PR review count, so hitting it indicates a bug upstream rather +# than a PR that legitimately needs more pagination. +MAX_REVIEW_PAGINATION_PAGES = 500 +# 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_COVERAGE_RETRY_FLOOR_MINUTES = 60 +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", +} +OPENCODE_REVIEW_WORKFLOW_PATH = ".github/workflows/opencode-review.yml" +REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW = "__unknown_github_actions_workflow__" +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})`") +CHECK_GATED_OPENCODE_CHANGE_REQUEST_MARKER = ( + "OpenCode could not approve from deterministic current-head evidence because GitHub Checks have failed." +) +ACTIONS_JOB_DETAILS_URL_RE = re.compile(r"/actions/runs/\d+/job/(\d+)(?:[/?#]|$)") +ACTIONS_RUN_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", +) +COVERAGE_REVIEW_MARKERS = ( + "coverage evidence did not pass", + "coverage-evidence", + "required test/docstring evidence", +) +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*)' + r'(?:"[^"\r\n]*"|\'[^\'\r\n]*\'|[^\r\n,;}\]]+)' + ), + 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" + + +WORKFLOW_STARTING_MUTATION_SOURCES = frozenset( + {"PR_REVIEW_MERGE_TOKEN", "OPENCODE_APPROVE_TOKEN", "opencode-app"} +) + + +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 head_mutation_credential_starts_workflows() -> bool: + """Return whether scheduler head mutations can start required workflow runs. + + GitHub never creates a new workflow run for an event produced with the + workflow ``GITHUB_TOKEN``, so a PR head moved with that credential can never + collect the current-head required checks that protected branches demand + (GitHub, 2025). + + References: + GitHub. (2025). *Automatic token authentication*. + https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication + """ + return mutation_token_source() in WORKFLOW_STARTING_MUTATION_SOURCES + + +def non_triggering_head_mutation_reason(action: str) -> str: + """Explain why a head mutation is withheld for a non-triggering credential.""" + source = mutation_token_source() + if source == "github-token": + credential_reason = ( + "the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs" + ) + else: + credential_reason = ( + f"the {mutation_token_label()}, which is not allowlisted as workflow-starting" + ) + return ( + f"{action} withheld because the scheduler mutation credential is {credential_reason}, " + "so the moved head would stay permanently " + "BLOCKED without current-head required checks; configure PR_REVIEW_MERGE_TOKEN, " + "OPENCODE_APPROVE_TOKEN, or the OpenCode app token for the scheduler job" + ) + + +def require_workflow_starting_mutation_credential(action: str) -> None: + """Refuse head mutations that would leave the PR without current-head checks.""" + if not head_mutation_credential_starts_workflows(): + raise RuntimeError(non_triggering_head_mutation_reason(action)) + + +def head_mutation_credential_guidance_text() -> tuple[str, str]: + """Return operator-facing summary and limit text for a withheld head mutation.""" + if mutation_token_source() == "github-token": + return ( + "The scheduler withheld a head mutation because the workflow GITHUB_TOKEN cannot start the required current-head workflow runs.", + "Moving the head with the workflow GITHUB_TOKEN would leave the PR permanently BLOCKED, so the scheduler waits instead.", + ) + return ( + f"The scheduler withheld a head mutation because {mutation_token_label()} is not allowlisted as workflow-starting.", + "Moving the head is unsafe until the scheduler can prove that the selected credential starts the required current-head workflow runs.", + ) + + +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_non_triggering_head_mutation_reason(decision.reason): + summary, automation_limit = head_mutation_credential_guidance_text() + return { + "type": "head_mutation_credential_upgrade", + "token": mutation_token_label(), + "summary": summary, + "automation_limit": automation_limit, + "steps": [ + "Configure PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the OpenCode app credential for the scheduler job.", + "Rerun PR Review Merge Scheduler so the head mutation runs with a workflow-starting credential.", + "Alternatively push the PR branch from its owning actor so required checks rerun on the new head.", + ], + "next_required_evidence": [ + "scheduler mutation credential that is not the workflow GITHUB_TOKEN", + "new head SHA created by that credential", + "required GitHub Checks success on the new head", + "OpenCode approval on that exact new head", + ], + } + 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", +) +# The exact diagnostic GitHub emits when a GitHub App installation token's +# shared primary rate limit (5,000-12,500 requests/hour, pooled across every +# workflow that mints a token for the same installation -- at least eight +# other central workflows in this repository alone) is exhausted. Matches +# the pattern scripts/ci/agent_mention_router.py already retries on. Kept +# distinct from TRANSIENT_GITHUB_API_ERRORS because this is routine +# cross-workflow contention, not infrastructure flakiness, and needs a +# reset-time-aware wait rather than a short fixed backoff. +RATE_LIMIT_DIAGNOSTIC_RE = re.compile(r"API rate limit exceeded", re.IGNORECASE) +GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS = 60 + + +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 is_rate_limited_error(exc: Exception) -> bool: + """Return whether a GitHub API failure is the shared installation rate limit. + + Distinct from :func:`is_transient_github_api_error`: this is routine + contention from sibling workflows sharing one GitHub App installation's + request bucket, not an infrastructure error, so callers give it a + reset-time-aware wait via :func:`rate_limit_retry_delay_seconds` instead + of the short fixed backoff used for a passing transient failure. + """ + return RATE_LIMIT_DIAGNOSTIC_RE.search(str(exc)) is not None + + +def rate_limit_retry_delay_seconds(resource: str, attempt: int) -> int: + """Return how long to wait before retrying a rate-limited GitHub API call. + + Prefers GitHub's own reported reset time for ``resource`` (``"core"`` + for REST, ``"graphql"`` for GraphQL), read from ``GET /rate_limit`` -- + which GitHub documents as exempt from the primary rate limit it reports, + so checking it does not deepen the exhaustion it is diagnosing. Falls + back to the same capped exponential backoff already used for other + transient errors when that lookup is itself unavailable or does not + confirm the bucket is empty, and never waits longer than + ``GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS`` for any one retry interval. + After the bounded attempts are exhausted, the error reaches the calling + workflow's skip-and-defer handling so the repository can be picked back + up on the next sweep rotation. + """ + fallback = min(2 ** (attempt - 1), GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS) + try: + status = json.loads(run_github_read(["gh", "api", "rate_limit"])) + bucket = (status.get("resources") or {}).get(resource) or {} + remaining = bucket.get("remaining") + reset_epoch = bucket.get("reset") + except (RuntimeError, json.JSONDecodeError, AttributeError): + return fallback + if remaining != 0 or not isinstance(reset_epoch, int): + return fallback + delay = reset_epoch - int(time.time()) + 5 + if delay <= 0: + return fallback + return min(delay, GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS) + + +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: + rate_limited = is_rate_limited_error(exc) + if attempt >= max_attempts or not (rate_limited or is_transient_github_api_error(exc)): + raise + if rate_limited: + delay = rate_limit_retry_delay_seconds("graphql", attempt) + print( + f"Rate-limited GitHub GraphQL error on attempt {attempt}/{max_attempts}; retrying in {delay}s", + file=sys.stderr, + ) + else: + 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 complete_paginated_pr_reviews( + owner: str, name: str, number: int, reviews: dict[str, Any] +) -> dict[str, Any]: + """Backfill one pull request's review history past the GraphQL 100-node window. + + ``reviews(last: 100)`` in SchedulerPullRequestFields only returns the + newest 100 reviews on a pull request. Once a PR accumulates more than 100 + review events (bot reviewers post multiple reviews per push in this org), + ``pageInfo.hasPreviousPage`` comes back true and earlier reviews -- + including a genuine independent APPROVED review made early in the PR's + life -- are silently missing from ``nodes``. This walks backward with + ``before`` cursors via PR_REVIEWS_PAGE_QUERY, merging each page in front of + the ones already collected so the result stays oldest-first (the order + every ``reversed(...)`` consumer in this module expects), until GitHub + reports no earlier page. A page-fetch failure propagates (fail closed) + rather than returning a partial history. + """ + page_info = reviews.get("pageInfo") or {} + nodes = list(reviews.get("nodes") or []) + pages_fetched = 0 + while page_info.get("hasPreviousPage"): + pages_fetched += 1 + if pages_fetched > MAX_REVIEW_PAGINATION_PAGES: + raise RuntimeError( + f"Pull request {owner}/{name}#{number} review pagination exceeded " + f"{MAX_REVIEW_PAGINATION_PAGES} pages without exhausting hasPreviousPage; " + "refusing to loop indefinitely." + ) + cursor = page_info.get("startCursor") + if not cursor: + raise RuntimeError( + f"Pull request {owner}/{name}#{number} reported hasPreviousPage=true " + "without a startCursor; cannot continue review pagination." + ) + payload = gh_graphql( + PR_REVIEWS_PAGE_QUERY, owner=owner, name=name, number=number, cursor=cursor + ) + pull_request = ((payload.get("data") or {}).get("repository") or {}).get( + "pullRequest" + ) or {} + page = pull_request.get("reviews") or {} + nodes = list(page.get("nodes") or []) + nodes + page_info = page.get("pageInfo") or {} + return {"nodes": nodes} + + +def complete_all_pr_reviews(owner: str, name: str, prs: list[dict[str, Any]]) -> None: + """Backfill full review history in place for every fetched PR node that needs it. + + Only PRs whose initial ``reviews(last: 100)`` window reported + ``hasPreviousPage`` pay the extra round trip; PRs with 100 or fewer + reviews (the overwhelming majority) are untouched. + """ + for pr in prs: + reviews = pr.get("reviews") + if not reviews: + continue + if (reviews.get("pageInfo") or {}).get("hasPreviousPage"): + pr["reviews"] = complete_paginated_pr_reviews( + owner, name, pr.get("number"), reviews + ) + + +def complete_paginated_pr_contexts(repo: str, pr: dict[str, Any]) -> None: + """Load every status-context page before selecting a required workflow run.""" + contexts = ((pr.get("statusCheckRollup") or {}).get("contexts") or {}) + page_info = contexts.get("pageInfo") or {} + nodes = list(contexts.get("nodes") or []) + owner, name = validate_github_repository(repo).split("/", 1) + pages = 0 + while page_info.get("hasNextPage"): + cursor = page_info.get("endCursor") + if not cursor: + raise RuntimeError("Status context pagination did not provide an end cursor") + pages += 1 + if pages > MAX_REVIEW_PAGINATION_PAGES: + raise RuntimeError("Status context pagination exceeded its safety bound") + payload = gh_graphql( + PR_CONTEXTS_PAGE_QUERY, + owner=owner, + name=name, + number=int(pr["number"]), + cursor=cursor, + ) + pull_request = ((payload.get("data") or {}).get("repository") or {}).get( + "pullRequest" + ) or {} + page_contexts = ((pull_request.get("statusCheckRollup") or {}).get("contexts") or {}) + nodes.extend(page_contexts.get("nodes") or []) + page_info = page_contexts.get("pageInfo") or {} + contexts["nodes"] = nodes + contexts["pageInfo"] = page_info + + +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. + + Retries the shared installation rate limit or another transient GitHub + API error up to ``max_attempts`` times, mirroring :func:`gh_graphql`'s + existing retry convention; any other failure raises immediately exactly + as before. + """ + 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(["gh", "api", path])) + except (RuntimeError, json.JSONDecodeError) as exc: + rate_limited = is_rate_limited_error(exc) + if attempt >= max_attempts or not (rate_limited or is_transient_github_api_error(exc)): + raise + if rate_limited: + delay = rate_limit_retry_delay_seconds("core", attempt) + print( + f"Rate-limited GitHub REST error on attempt {attempt}/{max_attempts} for {path}; retrying in {delay}s", + file=sys.stderr, + ) + else: + delay = min(2 ** (attempt - 1), 8) + print( + f"Transient GitHub REST error on attempt {attempt}/{max_attempts} for {path}; retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + + +def gh_api_json_via_dispatch_token(path: str) -> Any: + """Run a GitHub REST API GET via the central-repository dispatch credential. + + The OpenCode app installation has no Actions permission (see + :func:`scheduler_dispatch_env`), and the target-repository read + credential (:func:`gh_api_json`) is not guaranteed to have it either for + a cross-repository dispatch. A read against ``.github``'s own Actions + artifacts -- which always host the central draft-review-request marker + regardless of which repository the PR belongs to -- must use the same + central-repository dispatch credential already used for creating a + ``repository_dispatch`` there, not the target-repository read + credential. + """ + + return json.loads(run_github_dispatch(["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 fetch_all_pr_reviews_rest(repo: str, number: int) -> list[dict[str, Any]]: + """Fetch every REST review for a pull request, paginating past 100. + + A single ``per_page=100`` page silently drops earlier reviews once a PR + accumulates more than 100 review events, the same truncation the GraphQL + ``reviews(last: 100)`` window hits. This walks ``page=1,2,3,...`` -- + mirroring ``fetch_open_prs_rest``'s pagination style -- until a page + shorter than 100 rows confirms the end of the history. A page-fetch + failure propagates (fail closed) rather than returning a partial history. + """ + reviews: list[dict[str, Any]] = [] + page = 1 + while True: + batch = gh_api_json(f"repos/{repo}/pulls/{number}/reviews?per_page=100&page={page}") + if not batch: + break + reviews.extend(batch) + if len(batch) < 100: + break + page += 1 + return reviews + + +def fetch_workflow_names_by_check_suite_rest( + repo: str, head_sha: str +) -> dict[int, str]: + """Return exact-head GitHub Actions workflow names keyed by check-suite ID. + + REST check-run payloads omit workflow identity. The Actions run list + preserves the shared check-suite ID, allowing the REST fallback to + retain the same workflow-level policy boundary as the GraphQL path. + When the integration cannot read Actions, callers receive an empty + map and GitHub Actions checks are marked with a fail-closed sentinel. + """ + workflow_names: dict[int, str] = {} + page = 1 + while True: + try: + payload = gh_api_json( + f"repos/{repo}/actions/runs?head_sha={quote(head_sha, safe='')}" + f"&per_page=100&page={page}" + ) + except RuntimeError as exc: + if github_resource_inaccessible(exc): + return {} + raise + workflow_runs = payload.get("workflow_runs") or [] + for workflow_run in workflow_runs: + suite_id = workflow_run.get("check_suite_id") + workflow_name = str(workflow_run.get("name") or "").strip() + if suite_id is not None and workflow_name: + workflow_names[int(suite_id)] = workflow_name + if len(workflow_runs) < 100: + break + page += 1 + return workflow_names + + +def rest_check_node( + check: dict[str, Any], + suite_created_at_by_id: dict[int, str] | None = None, + workflow_name_by_suite_id: dict[int, str] | None = None, +) -> dict[str, Any]: + """Convert a REST check-run payload into the GraphQL status rollup shape. + + ``suite_created_at_by_id`` and ``workflow_name_by_suite_id`` attach + the check-suite recency and workflow identity that GraphQL exposes + directly. Unknown GitHub Actions workflow identity is represented by + a fail-closed sentinel so it cannot be mistaken for a source failure. + """ + suite_id = (check.get("check_suite") or {}).get("id") + suite_created_at = (suite_created_at_by_id or {}).get(suite_id) + workflow_name = (workflow_name_by_suite_id or {}).get(suite_id) + if not workflow_name and (check.get("app") or {}).get("slug") == "github-actions": + workflow_name = REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW + workflow = {"name": workflow_name} if workflow_name else {} + 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": { + "createdAt": suite_created_at, + "workflowRun": {"workflow": workflow}, + }, + } + + +def rest_status_node(status: dict[str, Any]) -> dict[str, Any]: + """Convert a REST classic commit-status payload into the GraphQL status rollup shape.""" + + return { + "context": status.get("context"), + "state": (status.get("state") or "").upper(), + "targetUrl": status.get("target_url"), + } + + +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. + + Classic commit statuses come from the *combined* status endpoint + (``commits/{sha}/status``), never the list endpoint + (``commits/{sha}/statuses``): the list endpoint returns full status + history in reverse-chronological order with no dedup, so a context that + transitioned from success to failure would surface both entries -- + letting a stale, superseded success outlive a later real failure for + any caller (like ``strix_evidence_state()``) that accepts the first + success it finds. The combined endpoint already reports only the most + recent status per context, matching the GraphQL rollup's own shape. + """ + + number = int(pr["number"]) + head = pr.get("head") or {} + base = pr.get("base") or {} + head_repo = head.get("repo") or {} + reviews = fetch_all_pr_reviews_rest(repo, number) + checks = gh_api_json(f"repos/{repo}/commits/{head.get('sha')}/check-runs?per_page=100") + check_runs = checks.get("check_runs") or [] + check_suites = gh_api_json(f"repos/{repo}/commits/{head.get('sha')}/check-suites?per_page=100") + suite_created_at_by_id = { + suite["id"]: suite.get("created_at") + for suite in (check_suites.get("check_suites") or []) + if suite.get("id") is not None + } + workflow_name_by_suite_id = ( + fetch_workflow_names_by_check_suite_rest(repo, str(head.get("sha") or "")) + if any( + (check.get("app") or {}).get("slug") == "github-actions" + for check in check_runs + ) + else {} + ) + combined_status = gh_api_json(f"repos/{repo}/commits/{head.get('sha')}/status") + 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"), + "author": {"login": ((pr.get("user") or {}).get("login"))}, + "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, + suite_created_at_by_id, + workflow_name_by_suite_id, + ) + for check in check_runs + ] + + [ + rest_status_node(status) + for status in (combined_status.get("statuses") 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"] + + # Bulk-scan results feed merge decisions directly (the scheduler's push- + # triggered and org-queue-sweep runs never re-fetch a single PR before + # calling inspect_pr), so this path needs the same full review history as + # fetch_pr, not just the first/last 100-review window. + complete_all_pr_reviews(owner, name, prs) + 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 [] + complete_all_pr_reviews(owner, name, prs) + 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 non-draft GraphQL pull request payloads. + + ``inspect_pr`` returns for a draft PR (dispatching at most a draft review) + before it ever reads ``restMergeableState``/``compareStatus``/ + ``compareBehindBy``, so refreshing those for a draft is two REST calls + (``pulls/{number}`` and ``compare/...``) spent on evidence no decision + ever consults. Skipping drafts here is pure dead-call elimination, not a + change to which non-draft PR gets merged/updated/reviewed. + """ + + 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)) + + mergeable_candidates = [pr for pr in prs if not pr.get("isDraft")] + if not mergeable_candidates: + return + + if len(mergeable_candidates) <= 1: + for pr in mergeable_candidates: + enrich(pr) + return + + max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(mergeable_candidates)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + for _ in executor.map(enrich, mergeable_candidates): + 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_check_run(node: dict[str, Any]) -> bool: + """Return whether a CheckRun carries the OpenCode workflow identity.""" + if node.get("__typename") != "CheckRun": + 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 + ) + + +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 + return is_opencode_check_run(node) + 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 in {None, REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW} + ) + 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 matching_actions_run_id(pr: dict[str, Any], predicate: Any) -> int | None: + """Return the newest matching check-run's workflow run id, if exposed. + + Devin Review finding on PR #1507 ("Older review run remains blocking"): + an earlier version of this function returned the first predicate match + found scanning ``context_nodes`` in reverse, which is only the newest + match when GitHub happens to return the rollup in chronological order -- + not guaranteed, and not true for every real payload. With multiple + same-purpose check runs present (reruns, or two dispatches racing), that + could select an older, already-resolved run while a genuinely newer + failure sat unselected and unrerun. This now ranks every match with the + same ``check_run_recency_key`` signal ``_newest_check_run_per_identity`` + uses to resolve reruns elsewhere in this file, so position in the list + never decides the winner -- only actual recency does. + """ + candidates: list[tuple[tuple[int, datetime, int], int]] = [] + for index, node in enumerate(context_nodes(pr)): + if node.get("__typename") != "CheckRun" or not predicate(node): + continue + match = ACTIONS_RUN_DETAILS_URL_RE.search(node.get("detailsUrl") or "") + if match: + candidates.append( + ( + check_run_recency_key( + node, parse_github_datetime(node.get("startedAt")), index + ), + int(match.group(1)), + ) + ) + return max(candidates)[1] if candidates else 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 check_run_recency_key( + node: dict[str, Any], started_at: datetime | None, index: int +) -> tuple[int, datetime, int]: + """Return a single comparable recency key for one same-purpose check run. + + Ranking a sequence of same-purpose check runs (either the reruns sharing + one (workflow, name) key in ``latest_check_runs``, or the + coverage-evidence runs ``latest_coverage_evidence_index`` compares across + workflow names) down to the single newest one used to be done by folding + a pairwise "does B supersede A" predicate left-to-right across the + candidates. That is only valid when the predicate is a transitive total + order, and it was not: a queued/null-``startedAt`` candidate could + legitimately supersede an older completed predecessor, but a later, + differently-timestamped completed candidate could then override that + queued winner too -- purely because "a timestamped candidate beats a + null-timestamp current-best" -- even when the later candidate was itself + older than whichever run the queued candidate had already displaced. + + Building one derived key per candidate instead, and comparing those keys + directly, cannot go non-transitive: Python tuple ordering is already a + valid total order, so ``max()``/``sorted()`` over these keys give a + result that does not depend on candidate order. + + A ``startedAt``-only signal has a gap: GitHub reports a check run as + ``completed``/``cancelled`` with ``startedAt: null`` when a queued rerun + is cancelled before it ever starts, so that row carries no timestamp and + is not pending either -- nothing (short of hardcoding the ``cancelled`` + conclusion, which would only patch this one case) distinguishes it from + a run that legitimately never mattered. ``checkSuite.createdAt`` closes + that gap generally instead of special-casing it: GitHub creates the + check suite unconditionally the moment the triggering push, rerun, or + dispatch happens, strictly before any check run inside it can be queued, + start, or be cancelled before starting, and ``CheckSuite.createdAt`` is + non-nullable in GitHub's schema. So it is a recency signal that is + always available, for every check run regardless of how it resolved -- + unlike ``startedAt``, which is genuinely absent for a run that never + started. + + Three tiers, low to high: + + * ``0`` -- no recency signal at all: neither the check run's own check + suite ``createdAt`` nor its ``startedAt`` is available, and it is not + currently pending either. Real GitHub responses always carry + ``checkSuite.createdAt``, so this tier is only reachable for + payloads that omit it (e.g. hand-built fixtures). + * ``1`` -- a real timestamp: the check run's own check suite + ``createdAt`` when present, else its ``startedAt``. Preferring the + check-suite timestamp means two runs are ranked by when each was + actually triggered, not by whether either one got far enough to + start -- a rerun cancelled before starting still ranks correctly + relative to an older, already-completed run. + * ``2`` -- no timestamp of any kind, but actively pending (queued/in + progress/etc, via ``running_check_state``): kept only as the + fallback for payloads without ``checkSuite.createdAt``, where GitHub + only ever creates such a row after any run it might supersede, so it + is presumed newer than every already-resolved run in that same + payload shape, regardless of that run's timestamp. + + Ties within a tier fall back to the later index, matching the order + ``context_nodes`` returns them in. + """ + epoch = datetime.min.replace(tzinfo=timezone.utc) + suite_created_at = parse_github_datetime((node.get("checkSuite") or {}).get("createdAt")) + recency_timestamp = suite_created_at or started_at + if recency_timestamp is not None: + return (1, recency_timestamp, index) + if running_check_state(node) == "running": + return (2, epoch, index) + return (0, epoch, index) + + +def _newest_check_run_per_identity( + indexed_check_runs: Sequence[tuple[int, dict[str, Any]]] +) -> list[tuple[int, dict[str, Any]]]: + """Return the newest CheckRun per (workflow, name) identity, index-tagged. + + Shared core for ``latest_check_runs`` (which keeps only CheckRun nodes) + and ``latest_check_run_attempts`` (which also passes non-CheckRun nodes + through unchanged): both resolve CheckRun reruns sharing one + (workflow, name) identity down to the single newest attempt, and both + must rank candidates with the identical ``check_run_recency_key`` signal + so they cannot silently diverge again the way ``latest_check_run_attempts`` + once did with its own ``startedAt``-only comparison. Each input + ``(index, node)`` pair's original position is preserved in the return + value so callers can restore overall document order after merging back + any non-CheckRun nodes. + """ + latest: dict[tuple[str, str], tuple[tuple[int, datetime, int], int, dict[str, Any]]] = {} + for index, node in indexed_check_runs: + 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")) + recency_key = check_run_recency_key(node, started_at, index) + previous = latest.get(key) + if previous is None or recency_key >= previous[0]: + latest[key] = (recency_key, index, node) + return [(index, node) for _, index, node in latest.values()] + + +def latest_check_runs(pr: dict[str, Any]) -> list[dict[str, Any]]: + """Return the newest check run for each workflow and check-name pair.""" + indexed_check_runs = [ + (index, node) + for index, node in enumerate(context_nodes(pr)) + if node.get("__typename") == "CheckRun" + ] + deduped = _newest_check_run_per_identity(indexed_check_runs) + return [node for _, node in sorted(deduped, key=lambda item: item[0])] + + +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" + + +_STRIX_SUCCESS_CONCLUSIONS = {"SUCCESS"} + + +def latest_check_run_attempts(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return each CheckRun's most recent attempt per (workflow, name) identity. + + A rerun leaves every earlier attempt's CheckRun node in the rollup + alongside the latest one, so callers that walk ``nodes`` directly can see + a stale failed attempt outlive a later successful retry. This used to + resolve each CheckRun identity with its own inline ``startedAt``-only + comparison, which had the same gap ``check_run_recency_key`` documents + for ``latest_check_runs``: GitHub reports a rerun cancelled before it + ever started as completed with ``startedAt: null``, so that row carried + no timestamp and could never outrank an older, already-completed + attempt -- even though it was the genuinely newer one. This now shares + the exact ``check_run_recency_key`` ranking (via + ``_newest_check_run_per_identity``) that ``latest_check_runs`` uses -- + preferring ``checkSuite.createdAt`` over ``startedAt``, with a + "currently pending" fallback tier -- so the two dedup passes rank + CheckRun reruns identically and cannot silently diverge again. Every + non-CheckRun (classic commit-status) node is passed through unchanged: + classic commit statuses never appear as duplicate reruns in + ``context_nodes``, so no dedup is needed for them. The result preserves + the original relative ordering. + """ + indexed_check_runs = [ + (index, node) for index, node in enumerate(nodes) if node.get("__typename") == "CheckRun" + ] + ordered: list[tuple[int, dict[str, Any]]] = [ + (index, node) for index, node in enumerate(nodes) if node.get("__typename") != "CheckRun" + ] + ordered.extend(_newest_check_run_per_identity(indexed_check_runs)) + ordered.sort(key=lambda item: item[0]) + return [node for _, node in ordered] + + +def strix_evidence_state(pr: dict[str, Any]) -> str: + """Return missing, running, failed, or complete for current-head Strix evidence. + + "complete" requires authoritative success (CheckRun conclusion or classic + commit-status state of SUCCESS) from *any* Strix identity present -- a + CheckRun and a classic commit-status context are both accepted, and + either one succeeding is sufficient. This repo documents that a same-head + manual `workflow_dispatch` Strix run, which posts a classic commit + status, "may supply review evidence but does not replace required PR + checks": it can unlock this internal review-dispatch gate even when the + `pull_request_target` CheckRun failed or cannot correctly evaluate a + self-modifying `.github` PR (that CheckRun runs the *base* branch's + trusted scripts, which a PR editing those very scripts can legitimately + fail against) -- but it never substitutes for GitHub's own independently + enforced required CheckRun at actual merge time, which this function + does not touch. Symmetrically, a stale classic-status failure left over + from an unrelated manual run must never keep this gate "failed" forever + once the real, retryable CheckRun evidence succeeds -- `dispatch_strix_evidence` + has no way to clear a classic status, only to rerun a CheckRun's Actions + job, so treating a lingering classic failure as still blocking once a + CheckRun has already succeeded would force an endless, pointless rerun + loop. + + Only when *no* identity reports success is this "failed" (every present + terminal outcome -- failure, error, cancelled, timed out, skipped, + neutral, action_required, stale, startup_failure -- counts as + non-passing) or "running" (something is still in flight and nothing has + succeeded yet), so callers fail closed instead of unlocking on evidence + that never actually passed anywhere. Only the latest attempt per Strix + CheckRun identity is evaluated, so a stale failed attempt cannot outlive + a later successful retry. + """ + strix_nodes = [node for node in latest_check_run_attempts(context_nodes(pr)) if is_strix_context(node)] + if not strix_nodes: + return "missing" + saw_running = False + for node in strix_nodes: + is_check_run = node.get("__typename") == "CheckRun" + status = (node.get("status") or node.get("state") or "").upper() + if status in RUNNING_CHECK_STATES: + saw_running = True + continue + if is_check_run: + if status != "COMPLETED": + saw_running = True + continue + conclusion = (node.get("conclusion") or "").upper() + if conclusion in _STRIX_SUCCESS_CONCLUSIONS: + return "complete" + elif status in _STRIX_SUCCESS_CONCLUSIONS: + return "complete" + return "running" if saw_running else "failed" + + +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_bot_review_author(review: dict[str, Any]) -> bool: + """Return whether a review's author is a GitHub bot actor. + + GitHub's REST API appends the ``[bot]`` suffix to a bot actor's + ``login`` (e.g. ``dependabot[bot]``), but GitHub's GraphQL API can + return the bare account name for that same actor (e.g. ``dependabot``) + while exposing ``__typename: "Bot"`` on the ``author`` field instead of + the suffix. Checking both keeps bot exclusion correct regardless of + which API surface -- and which suffix convention -- produced the + review node; ``rest_review_node`` never sets ``__typename``, so REST + reviews continue to rely solely on the login suffix. + """ + if review_author_login(review).endswith("[bot]"): + return True + return ((review.get("author") or {}).get("__typename")) == "Bot" + + +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_independent_current_head_approval(pr: dict[str, Any]) -> bool: + """Return whether an eligible reviewer's latest exact-head policy state approves.""" + author = ((pr.get("author") or {}).get("login") or "").lower() + if not author: + return False + seen_reviewers: set[str] = set() + for review in reversed((pr.get("reviews") or {}).get("nodes") or []): + reviewer = review_author_login(review) + state = (review.get("state") or "").upper() + if ( + not reviewer + or reviewer == author + or is_automated_opencode_review(review) + or reviewer == "github-actions" + or is_bot_review_author(review) + or not review_matches_current_head(review, pr) + or state not in {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"} + or reviewer in seen_reviewers + ): + continue + seen_reviewers.add(reviewer) + if state == "APPROVED": + return True + return False + + +def merge_approval_block_reason(pr: dict[str, Any]) -> str | None: + """Return the fail-closed repository and independent approval blocker.""" + review_decision = str(pr.get("reviewDecision") or "").upper() + if review_decision != "APPROVED": + return ( + "current-head OpenCode review approved, but GitHub reviewDecision is " + f"{review_decision or ''}; repository approval policy is 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 None + + +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 latest_current_head_coverage_change_request( + pr: dict[str, Any], +) -> dict[str, Any] | None: + """Return the latest exact-head OpenCode request that only cites coverage.""" + for review in reversed((pr.get("reviews") or {}).get("nodes") or []): + if not is_opencode_review(review) or not review_matches_current_head(review, pr): + continue + if (review.get("state") or "").upper() != "CHANGES_REQUESTED": + return None + body = (review.get("body") or "").lower() + return review if all(marker in body for marker in COVERAGE_REVIEW_MARKERS) else None + return None + + +def current_head_coverage_change_request(pr: dict[str, Any]) -> bool: + """Return whether the latest current-head request is only a coverage gate.""" + return latest_current_head_coverage_change_request(pr) is not None + + +def coverage_retry_wait_reason( + pr: dict[str, Any], + *, + repo: str | None = None, + workflow: str | None = None, + now: datetime | None = None, + floor_minutes: int = DEFAULT_COVERAGE_RETRY_FLOOR_MINUTES, +) -> str | None: + """Return a wait reason until one same-head coverage retry interval elapses. + + The latest exact-head review submission or completed dispatch timestamp is the + durable same-head retry marker. Missing or malformed timestamps fail closed so + a repeated coverage-only review cannot create an unbounded dispatch loop. + """ + review = latest_current_head_coverage_change_request(pr) + if review is None: + return None + submitted_at = parse_github_datetime(review.get("submittedAt")) + if submitted_at is None: + return "current-head OpenCode coverage review has no valid submission timestamp; defer same-head re-review" + retry_anchor = submitted_at + if repo and workflow: + try: + dispatch_started_at = latest_opencode_dispatch_started_at( + repo, workflow, pr, since=retry_anchor + ) + except RuntimeError: + return "same-head OpenCode dispatch history is unavailable; defer same-head re-review" + if dispatch_started_at and dispatch_started_at > retry_anchor: + retry_anchor = dispatch_started_at + current_time = now or datetime.now(timezone.utc) + if current_time < retry_anchor + timedelta(minutes=max(0, floor_minutes)): + return "same-head OpenCode coverage retry floor has not elapsed" + return None + + +def is_non_authoritative_coverage_check_run(node: dict[str, Any]) -> bool: + """Return whether central metadata-only coverage evidence is non-authoritative.""" + if not (os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "").strip(): + return False + if (node.get("name") or "").lower() != "coverage-evidence": + return False + workflow = ( + ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") + or {} + ) + return workflow.get("name") == "Required OpenCode Review" + + +def coverage_evidence_indices(check_runs: Sequence[dict[str, Any]]) -> list[int]: + """Return indexes of coverage-evidence checks in one check-run snapshot.""" + return [ + index + for index, node in enumerate(check_runs) + if (node.get("name") or "").lower() == "coverage-evidence" + and not is_non_authoritative_coverage_check_run(node) + ] + + +def latest_coverage_evidence_index(check_runs: Sequence[dict[str, Any]]) -> int | None: + """Return the newest coverage-evidence index across workflow names. + + Ranks every coverage-evidence candidate with ``check_run_recency_key`` + and picks the single largest key via ``max()``, so a freshly QUEUED + coverage-evidence rerun (``startedAt: null``) in one workflow correctly + outranks an older, already-completed coverage-evidence run in a + *different* workflow instead of losing a naive timestamp comparison + because it has not started yet -- and, unlike folding a pairwise + supersession predicate two at a time, the answer does not depend on how + many other candidates are present or what order they arrive in, because + each candidate's key depends only on its own timestamp/pending status. + """ + coverage_indices = coverage_evidence_indices(check_runs) + if not coverage_indices: + return None + return max( + coverage_indices, + key=lambda item: check_run_recency_key( + check_runs[item], + parse_github_datetime(check_runs[item].get("startedAt")), + item, + ), + ) + + +def coverage_evidence_state(pr: dict[str, Any]) -> str: + """Return missing, running, complete, or failed for the latest coverage gate.""" + check_runs = latest_check_runs(pr) + latest_index = latest_coverage_evidence_index(check_runs) + if latest_index is not None: + node = check_runs[latest_index] + status = (node.get("status") or "").upper() + if status in RUNNING_CHECK_STATES: + return "running" + return "complete" if (node.get("conclusion") or "").upper() == "SUCCESS" else "failed" + for node in reversed(context_nodes(pr)): + if node.get("__typename") == "CheckRun": + continue + name = (node.get("name") or node.get("context") or "").lower() + if name != "coverage-evidence": + continue + status = (node.get("status") or node.get("state") or "").upper() + if status in RUNNING_CHECK_STATES: + return "running" + return "complete" if status == "SUCCESS" else "failed" + return "missing" + + +def superseded_coverage_evidence_indices(check_runs: Sequence[dict[str, Any]]) -> set[int]: + """Return older coverage checks superseded by a newer successful run.""" + authoritative_index = latest_coverage_evidence_index(check_runs) + if authoritative_index is None: + return set() + authoritative = check_runs[authoritative_index] + if (authoritative.get("conclusion") or "").upper() != "SUCCESS": + return set() + return set(coverage_evidence_indices(check_runs)) - {authoritative_index} + + +def can_retry_check_gated_opencode_review(pr: dict[str, Any]) -> bool: + """Return whether recovered checks justify replacing a gate-only request.""" + for review in reversed((pr.get("reviews") or {}).get("nodes") or []): + if not is_opencode_review(review) or not review_matches_current_head(review, pr): + continue + body = str(review.get("body") or "") + return ( + (review.get("state") or "").upper() == "CHANGES_REQUESTED" + and CHECK_GATED_OPENCODE_CHANGE_REQUEST_MARKER in body + and "Failed checks:" in body + and not failed_status_checks(pr) + ) + return False + + +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], + *, + ignore_opencode: bool = False, +) -> list[str]: + """Return failing check or status context names from the PR rollup. + + ``ignore_opencode`` is reserved for the authenticated coverage-only retry + path: the previous ``opencode-review`` job or status is expected to be + failing there because it published the current-head coverage change request + being retried. Sibling jobs in the same workflow remain authoritative. + """ + failed: list[str] = [] + check_runs = latest_check_runs(pr) + superseded_coverage_indices = superseded_coverage_evidence_indices(check_runs) + status_contexts = [ + node + for node in context_nodes(pr) + if node.get("__typename") != "CheckRun" + ] + + successful_status_contexts = { + node.get("context") + for node in status_contexts + if (node.get("state") or "").upper() == "SUCCESS" + } + for index, node in enumerate(check_runs): + if is_non_authoritative_coverage_check_run(node): + continue + conclusion = (node.get("conclusion") or "").upper() + if conclusion in FAILED_CHECK_CONCLUSIONS: + if index in superseded_coverage_indices: + continue + if ignore_opencode and node.get("name") == "opencode-review": + continue + 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"}: + if ignore_opencode and is_opencode_context(node): + continue + 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") + require_workflow_starting_mutation_credential("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") + require_workflow_starting_mutation_credential("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 in {"missing", "failed"}: + wait_reason = repository_dispatch_wait_reason(repo, security_workflow) + if wait_reason: + return f"{head_note}; {wait_reason}" + dispatch_result = dispatch_strix_evidence(repo, security_workflow, updated_pr, dry_run=dry_run) + if dispatch_result == "already_running": + return f"{head_note}; same-head Strix evidence is already running" + if dispatch_result == "repository_busy": + return f"{head_note}; target repository already has active Strix evidence, so dispatch waits" + 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"]) + # A rerun brings a completed run back to queued/in_progress; invalidate + # any cached active_workflow_runs snapshot so it is not read as stale. + reset_active_workflow_runs_cache() + + +_active_workflow_runs_cache: dict[ + tuple[str, tuple[str, ...], str | None, str | None, str | None], list[dict[str, Any]] +] = {} + + +def reset_active_workflow_runs_cache() -> None: + """Clear the per-invocation cache backing :func:`active_workflow_runs`. + + ``main`` calls this once at the top of every scheduler run so the cache + never survives across separate invocations sharing a process (tests + calling ``main`` more than once, most notably). It must also be called + immediately after anything that changes GitHub Actions run state -- + force-cancelling, rerunning, or dispatching a run -- so a later read in + the same run observes that mutation instead of a stale pre-mutation + snapshot; :func:`force_cancel_workflow_runs`, :func:`rerun_actions_job`, + :func:`dispatch_opencode_review`, and :func:`dispatch_strix_evidence` all + do this immediately after their mutating call. + """ + _active_workflow_runs_cache.clear() + + +def active_workflow_runs( + repo: str, + statuses: Sequence[str] = ("queued", "in_progress"), + *, + event: str | None = None, + created: str | None = None, + head_sha: str | None = None, +) -> list[dict[str, Any]]: + """Return workflow runs for a repository, optionally narrowed server-side. + + ``event``, ``created``, and ``head_sha`` map directly onto GitHub's + ``List workflow runs for a repository`` REST query parameters (``event`` + selects the triggering webhook event, ``created`` accepts a date/range + qualifier such as ``>=2026-08-24T00:00:00Z``, ``head_sha`` narrows to + runs for one exact commit). All three are omitted by default so existing + callers keep fetching every run for the given statuses unfiltered; a + caller with a naturally bounded lookup -- one whose target repository's + run history only grows, such as a same-head dispatch search, or one + scoped to a single known commit -- should pass them to avoid paginating + history it can never use. + + Results are memoized per exact ``(repo, statuses, event, created, + head_sha)`` combination for the life of the cache (cleared by + :func:`reset_active_workflow_runs_cache`). The scheduler's queue sweep + calls the unfiltered ``(repo, ("queued", "in_progress"))`` shape from + every non-draft PR's unconditional stale-run check plus every review + dispatch check, all against the one repository a scheduler invocation + ever targets -- without memoization that is up to two redundant, + repository-wide, paginated REST calls per PR for identical data. + """ + cache_key = (repo, tuple(statuses), event, created, head_sha) + cached = _active_workflow_runs_cache.get(cache_key) + if cached is not None: + return list(cached) + runs: list[dict[str, Any]] = [] + for status in statuses: + args = [ + "gh", + "api", + "--method", + "GET", + f"repos/{repo}/actions/runs", + "--paginate", + "--slurp", + "-f", + f"status={status}", + "-F", + "per_page=100", + ] + if event: + args += ["-f", f"event={event}"] + if created: + args += ["-f", f"created={created}"] + if head_sha: + args += ["-f", f"head_sha={head_sha}"] + payload = json.loads(run_github_actions(args)) + pages = payload if isinstance(payload, list) else [payload] + for page in pages: + runs.extend(page.get("workflow_runs") or []) + _active_workflow_runs_cache[cache_key] = runs + return list(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.""" + raw_head = pr.get("headRefOid") + try: + head = validate_git_sha(str(raw_head or "")).lower() + except (TypeError, ValueError) as exc: + print( + f"::warning::stale_pr_run_ids: PR #{pr.get('number')} in {repo} has an " + f"invalid or unresolved headRefOid; preserving active runs ({exc})." + ) + return [] + 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() + ) + raw_head = pr.get("headRefOid") + try: + head = validate_git_sha(str(raw_head or "")).lower() + except (TypeError, ValueError) as exc: + print( + f"::warning::active_review_run_refs: PR #{pr.get('number')} in {target_repo} has an " + f"invalid or unresolved headRefOid; preserving review runs ({exc})." + ) + return [], [] + 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 latest_opencode_dispatch_started_at( + repo: str, + workflow: str, + pr: dict[str, Any], + *, + since: datetime | None = None, +) -> datetime | None: + """Return the latest completed same-head OpenCode dispatch start time. + + The dispatch repository hosting ``repository_dispatch`` runs only + accumulates completed-run history over time, so this narrows GitHub's + REST query server-side to ``event=repository_dispatch`` plus a + ``created`` lower bound of ``since``, instead of paginating every + completed run ever recorded there and filtering client-side. ``since`` + is safe to pass whenever the caller only cares about a dispatch strictly + newer than a known anchor timestamp -- any run created at or before that + anchor cannot become the returned maximum -- and is left unset (no lower + bound) for callers with no such anchor. + """ + target_repo = validate_github_repository(repo) + dispatch_repo = repository_dispatch_target(target_repo) + head = str(pr.get("headRefOid") or "").lower() + number = int(pr["number"]) + title_prefixes = tuple( + f"{title} {target_repo}#{number}@" + for title in sorted( + {"Required OpenCode Review", *OPENCODE_WORKFLOW_NAMES}, + key=len, + reverse=True, + ) + ) + created = f">={since.strftime('%Y-%m-%dT%H:%M:%SZ')}" if since else None + latest: datetime | None = None + for run_data in active_workflow_runs( + dispatch_repo, ("completed",), event="repository_dispatch", created=created + ): + if run_data.get("event") != "repository_dispatch": + continue + display_title = str(run_data.get("display_title") or "") + prefix = next( + (candidate for candidate in title_prefixes if display_title.startswith(candidate)), + None, + ) + if prefix is None: + continue + dispatched_head = display_title.removeprefix(prefix).lower() + if not GIT_SHA_RE.fullmatch(dispatched_head) or dispatched_head != head: + continue + started_at = parse_github_datetime( + run_data.get("run_started_at") or run_data.get("created_at") + ) + if started_at and (latest is None or started_at > latest): + latest = started_at + return latest + + +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))) + + # A cancelled run is no longer queued/in_progress; drop any cached + # active_workflow_runs snapshot so the next read (this same PR's later + # checks, or a later PR sharing this repository) sees the change instead + # of replaying it from before the cancellation. + reset_active_workflow_runs_cache() + + 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 _fresh_open_pr_for_cancellation(repo: str, number: int) -> dict[str, Any]: + """Return fresh open PR authority, including explicitly identified draft state.""" + payload = gh_api_json(f"repos/{repo}/pulls/{number}") + if not isinstance(payload, dict) or str(payload.get("state") or "").lower() != "open": + raise ValueError(f"PR #{number} in {repo} is not a resolvable open pull request") + if payload.get("draft") not in {True, False}: + raise ValueError(f"PR #{number} in {repo} has no authoritative live draft state") + validate_git_sha(str(((payload.get("head") or {}).get("sha")) or "")) + return payload + + +def _fresh_active_run_for_cancellation(run_repo: str, run_id: str) -> dict[str, Any]: + """Return fresh active workflow-run evidence immediately before cancellation.""" + payload = gh_api_json(f"repos/{run_repo}/actions/runs/{run_id}") + if not isinstance(payload, dict) or str(payload.get("status") or "").lower() not in { + "queued", + "in_progress", + }: + raise ValueError(f"workflow run {run_repo}#{run_id} is not active") + return payload + + +def _fresh_pr_head_for_cancellation(repo: str, number: int) -> str: + """Return the validated head SHA from fresh ready/open PR authority.""" + payload = _fresh_open_pr_for_cancellation(repo, number) + return validate_git_sha(str(((payload.get("head") or {}).get("sha")) or "")).lower() + + +def _direct_pr_run_still_superseded(repo: str, number: int, run_id: str) -> bool: + """Return whether a direct PR run is still older than the freshly fetched live head.""" + try: + run_data = _fresh_active_run_for_cancellation(repo, run_id) + if run_data.get("event") == "repository_dispatch" or not workflow_run_mentions_pr( + run_data, number + ): + raise ValueError("workflow run no longer has direct pull-request authority") + run_head = validate_git_sha(str(run_data.get("head_sha") or "")).lower() + live_head = _fresh_pr_head_for_cancellation(repo, number) + except (KeyError, RuntimeError, TypeError, ValueError) as exc: + print( + f"::warning::Preserving workflow run {run_id} in {repo}: " + f"live stale-run revalidation failed closed ({exc})." + ) + return False + return run_head != live_head + + +def _review_run_target_head( + run_data: dict[str, Any], repo: str, workflow: str, number: int +) -> str: + """Return a validated target head for one direct or trusted central review run.""" + if run_data.get("event") == "repository_dispatch": + titles = {"Required OpenCode Review", workflow, *OPENCODE_WORKFLOW_NAMES} + display_title = str(run_data.get("display_title") or "") + prefixes = tuple( + f"{title} {repo}#{number}@" for title in sorted(titles, key=len, reverse=True) + ) + prefix = next((candidate for candidate in prefixes if display_title.startswith(candidate)), None) + if prefix is None: + raise ValueError("repository_dispatch run has no trusted target identity") + return validate_git_sha(display_title.removeprefix(prefix)).lower() + if not workflow_run_mentions_pr(run_data, number): + raise ValueError("review run no longer belongs to the target pull request") + return validate_git_sha(str(run_data.get("head_sha") or "")).lower() + + +def _review_run_still_superseded( + repo: str, + workflow: str, + number: int, + run_repo: str, + run_id: str, +) -> bool: + """Return whether one review run remains stale against fresh ready/open PR authority.""" + try: + run_data = _fresh_active_run_for_cancellation(run_repo, run_id) + run_head = _review_run_target_head(run_data, repo, workflow, number) + live_head = _fresh_pr_head_for_cancellation(repo, number) + except (KeyError, RuntimeError, TypeError, ValueError) as exc: + print( + f"::warning::Preserving review run {run_repo}#{run_id}: " + f"live stale-run revalidation failed closed ({exc})." + ) + return False + return run_head != live_head + + +def cancel_stale_pr_runs(repo: str, pr: dict[str, Any], *, dry_run: bool) -> list[str]: + """Force-cancel only direct-run candidates still proven stale at the destructive boundary.""" + if dry_run: + return [] + require_github_actions_control_actor("force-cancel-stale-pr-runs") + number = int(pr["number"]) + candidates = [str(run_id) for run_id in stale_pr_run_ids(repo, pr)] + + def cancel_one(run_id: str) -> str | None: + """Revalidate and cancel one direct workflow-run candidate when still stale.""" + if not _direct_pr_run_still_superseded(repo, number, run_id): + return None + failures = force_cancel_workflow_runs(repo, [run_id]) + if run_id in failures: + return None + return run_id + + if len(candidates) <= 1: + results = [cancel_one(run_id) for run_id in candidates] + else: + max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(candidates)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + results = list(executor.map(cancel_one, candidates)) + return [run_id for run_id in results if run_id is not None] + + +def cancel_stale_opencode_runs(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> list[str]: + """Force-cancel only review candidates still proven stale at the destructive boundary.""" + if dry_run: + return [] + require_github_actions_control_actor("force-cancel-stale-opencode-review") + number = int(pr["number"]) + _, stale_refs = active_opencode_run_refs(repo, workflow, pr) + + def cancel_one(run_ref: tuple[str, str]) -> str | None: + """Revalidate and cancel one review-run candidate when still stale.""" + run_repo, run_id = run_ref + if not _review_run_still_superseded(repo, workflow, number, run_repo, run_id): + return None + failures = force_cancel_workflow_runs(run_repo, [run_id]) + if run_id in failures: + return None + return run_id + + if len(stale_refs) <= 1: + results = [cancel_one(run_ref) for run_ref in stale_refs] + else: + max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(stale_refs)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + results = list(executor.map(cancel_one, stale_refs)) + return [run_id for run_id in results if run_id is not None] + + + + +def discover_opencode_required_run_id(repo: str, head_sha: str) -> int | None: + """Return the current-head Required OpenCode Review run id via a bounded lookup. + + Devin Review finding on PR #1507 ("Large check rollups never wake"): + ``matching_actions_run_id`` only sees the GraphQL ``statusCheckRollup`` + fragment's first 100 status/check contexts + (``PULL_REQUEST_FIELDS_FRAGMENT``'s ``contexts(first: 100)``). A pull + request already carrying at least 100 contexts -- dozens of CI/security + workflows across several pushes and reruns is realistic in this + organization -- can push the real Required OpenCode Review check run + past that page, so the in-memory scan finds nothing even though the run + exists. This is a REST fallback, not a rewrite of that scan: it is + scoped server-side to the exact triggering event, the exact workflow + file path, and the exact current head SHA (GitHub's ``head_sha`` list + filter), so it stays a bounded, targeted lookup -- never an unfiltered + history walk -- and finds the run whether it is still queued/running or + already completed (the realistic failure mode is a stuck ``failure`` + conclusion on an otherwise-valid exact-head run). + """ + if not GIT_SHA_RE.fullmatch(head_sha): + return None + target_repo = validate_github_repository(repo) + newest_id: int | None = None + newest_started: datetime | None = None + for run_data in active_workflow_runs( + target_repo, + ("queued", "in_progress", "completed"), + event="pull_request_target", + head_sha=head_sha, + ): + if run_data.get("path") != OPENCODE_REVIEW_WORKFLOW_PATH: + continue + if str(run_data.get("head_sha") or "").lower() != head_sha.lower(): + continue + run_id = run_data.get("id") + if not run_id: + continue + started_at = parse_github_datetime( + run_data.get("run_started_at") or run_data.get("created_at") + ) + is_newer = started_at is not None and ( + newest_started is None or started_at > newest_started + ) + if newest_id is None or is_newer: + newest_id = int(run_id) + if started_at is not None: + newest_started = started_at + return newest_id + + +def _cancel_revalidated_review_run_refs( + repo: str, + workflow: str, + pr: dict[str, Any], + run_refs: list[tuple[str, str]], +) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]: + """Cancel only review refs still proven stale immediately before each destructive call. + + A failed/malformed live read is preservation authority, not permission to + dispatch a duplicate review. The returned first list therefore contains + every active candidate that could not be proven stale; callers fold those + refs into their current/busy set. Multiple candidates retain the scheduler's + existing bounded executor and deterministic input ordering. + """ + if not run_refs: + return [], [] + number = int(pr["number"]) + + def cancel_one(run_ref: tuple[str, str]) -> tuple[str, tuple[str, str]]: + """Revalidate one candidate and cancel it only while it remains stale.""" + run_repo, run_id = run_ref + if not _review_run_still_superseded(repo, workflow, number, run_repo, run_id): + return "preserved", run_ref + failures = force_cancel_workflow_runs(run_repo, [run_id]) + if run_id in failures: + return "preserved", run_ref + return "cancelled", run_ref + + if len(run_refs) == 1: + outcomes = [cancel_one(run_refs[0])] + else: + max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(run_refs)) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + outcomes = list(executor.map(cancel_one, run_refs)) + preserved = [run_ref for state, run_ref in outcomes if state == "preserved"] + cancelled = [run_ref for state, run_ref in outcomes if state == "cancelled"] + return preserved, cancelled + +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) + preserved_run_refs, _cancelled_run_refs = _cancel_revalidated_review_run_refs( + repo, workflow, pr, stale_run_refs + ) + current_run_refs = [*current_run_refs, *preserved_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) + client_payload: dict[str, Any] = { + "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, + } + complete_paginated_pr_contexts(target_repo, pr) + required_run_id = matching_actions_run_id(pr, is_opencode_check_run) + if required_run_id is None: + required_run_id = discover_opencode_required_run_id(target_repo, head_sha) + if required_run_id is not None: + client_payload["required_run_id"] = required_run_id + run_github_dispatch( + [ + "gh", + "api", + "-X", + "POST", + f"repos/{dispatch_repo}/dispatches", + "--input", + "-", + ], + stdin=json.dumps( + { + "event_type": "opencode-review", + "client_payload": client_payload, + } + ), + ) + # A dispatch queues a new run; invalidate any cached active_workflow_runs + # snapshot so a later busy/current-run check in this same invocation sees it. + reset_active_workflow_runs_cache() + return "dispatched" + + +def is_strix_scan_check_run(node: dict[str, Any]) -> bool: + """Return whether a check run is the authoritative Strix scan job.""" + return ( + node.get("__typename") == "CheckRun" + and node.get("name") == "strix" + and is_strix_context(node) + ) + + +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_scan_check_run) + 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"}), + ) + preserved_run_refs, cancelled_refs = _cancel_revalidated_review_run_refs( + repo, workflow, pr, stale_run_refs + ) + current_run_refs = [*current_run_refs, *preserved_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" + target_repo = validate_github_repository(repo) + dispatch_repo = repository_dispatch_target(target_repo) + cancelled_ids = {run_id for _, run_id in cancelled_refs} + busy_refs = [ + (dispatch_repo, str(run_data["id"])) + for run_data in active_workflow_runs(dispatch_repo) + if run_data.get("id") + and str(run_data["id"]) not in cancelled_ids + and run_data.get("name") == workflow + and run_data.get("event") == "repository_dispatch" + and str(run_data.get("display_title") or "").startswith( + f"Strix Security Scan {target_repo}#" + ) + ] + if busy_refs: + print( + "Strix evidence dispatch skipped: target repository already has active run(s) " + + ", ".join(f"{run_repo}@{run_id}" for run_repo, run_id in busy_refs) + ) + return "repository_busy" + base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr) + 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, + }, + } + ), + ) + # A dispatch queues a new run; invalidate any cached active_workflow_runs + # snapshot so a later busy/current-run check in this same invocation sees it. + reset_active_workflow_runs_cache() + 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 revalidate_current_head_approval(repo: str, pr: dict[str, Any]) -> str | None: + """Re-check exact-head approval immediately before a merge-authorizing mutation. + + ``inspect_pr`` computes ``current_head_approved``/``approval_reason`` once, early + in the function, from the GraphQL/REST snapshot this scheduler invocation fetched + at the start of its run. Much later in the same invocation it reaches a branch + that calls ``merge_pr``/``enable_auto_merge`` using that stale snapshot. If the + reviewer who approved the exact head SHA dismisses or revokes that review -- or + GitHub otherwise recomputes ``reviewDecision`` -- in the window between the + snapshot and the mutating call, the merge would proceed on authorization that no + longer holds. The ``--match-head-commit`` guard those mutations carry only + protects against the *commit* changing in that window; it does nothing to protect + against the *review state* changing on the identical commit. + + Re-fetch the pull request right before the mutating call and recompute the exact + same independent exact-head approval decision (``has_current_head_approval`` and + ``merge_approval_block_reason``, the same helpers used for the original snapshot) + from the fresh data. Returns ``None`` when the fresh snapshot still authorizes the + merge, or a human-readable reason to block it otherwise. Any failure to re-fetch -- + a transient API error, or the pull request no longer being open or accessible -- + fails closed: it is treated exactly like a freshly observed missing approval so a + merge can never proceed on evidence this scheduler could not actually reconfirm. + """ + number = pr["number"] + try: + refreshed = fetch_pr(repo, number) + except RuntimeError as exc: + return ( + "re-checking current-head approval immediately before merge failed " + f"({exc}); treating the exact-head approval as unconfirmed" + ) + if not refreshed: + return ( + "re-checking current-head approval immediately before merge found PR " + f"#{number} no longer open or accessible; treating the exact-head " + "approval as unconfirmed" + ) + fresh_pr = refreshed[0] + expected_head = pr.get("headRefOid") + fresh_head = fresh_pr.get("headRefOid") + if expected_head and fresh_head and fresh_head != expected_head: + return ( + f"current head changed from {short_sha(expected_head)} to " + f"{short_sha(fresh_head)} immediately before merge; the exact-head " + "approval no longer applies to the current commit" + ) + if not has_current_head_approval(fresh_pr): + return ( + "current-head OpenCode approval was revoked immediately before merge; " + "the merge-authorizing snapshot is no longer current" + ) + reason = merge_approval_block_reason(fresh_pr) + if reason: + return f"{reason} (re-confirmed immediately before merge)" + return None + + +def draft_review_request_artifact_name(repo: str, pr_number: int, head_sha: str) -> str: + """Return one draft review-only request marker's exact artifact name.""" + return f"cwl-draft-review-request-{repo.replace('/', '-')}-{pr_number}-{head_sha}" + + +def _draft_review_request_records(value: Any, *, expected_name: str) -> tuple[dict[str, Any], ...]: + """Validate one exact-name repository artifact response and return live records. + + The server-side ``name`` filter makes this response directly addressable by + PR and exact head. Any malformed, mismatched, truncated, or ambiguous + response fails closed rather than being interpreted as an active request. + """ + if not isinstance(value, dict): + raise ValueError("artifact response must be an object") + total_count = value.get("total_count") + artifacts = value.get("artifacts") + if type(total_count) is not int or total_count < 0: + raise ValueError("artifact response has an invalid total_count") + if not isinstance(artifacts, list): + raise ValueError("artifact response has an invalid artifacts collection") + if total_count != len(artifacts): + raise ValueError("artifact response is truncated or internally inconsistent") + live: list[dict[str, Any]] = [] + for artifact in artifacts: + if not isinstance(artifact, dict): + raise ValueError("artifact response contains a non-object record") + artifact_id = artifact.get("id") + name = artifact.get("name") + expired = artifact.get("expired") + if type(artifact_id) is not int or artifact_id < 1: + raise ValueError("artifact response contains an invalid artifact id") + if not isinstance(name, str) or name != expected_name: + raise ValueError("artifact response contains a mismatched artifact name") + if type(expired) is not bool: + raise ValueError("artifact response contains an invalid expired flag") + if not expired: + live.append(artifact) + return tuple(live) + + +def active_draft_review_request(repo: str, pr: dict[str, Any]) -> bool: + """Return whether an explicit draft review-only request is active for this head. + + This is the sole automatic gate for draft review dispatch. A bare + ``repository_dispatch`` ``client_payload`` field (an invocation key, a PR + number) is never trusted on its own: any dispatch-capable caller could + supply one for an arbitrary target, and a genuinely stale mention (the + draft gained a new commit after being requested) must not review a + commit nobody asked about. ``agent-mention-opencode-dispatch.yml`` + instead uploads one short-lived Actions artifact per mention invocation, + named with the exact PR and head SHA + (:func:`draft_review_request_artifact_name`), only after that workflow's + own HMAC-style canonical-payload check has already validated the + invocation -- so a live artifact is itself the validated proof, bound to + one exact head, that this specific mention was genuine. The artifact + lives in the central automation repository (the same repository + ``repository_dispatch`` review dispatch always targets, per + :func:`repository_dispatch_target`), so every scheduler pass over this + draft PR -- the initial mention-triggered run and any later pass with no + ``repository_dispatch`` ``client_payload`` of its own, most commonly the + Strix-completion ``workflow_run`` that follows an initial + ``security_dispatch`` -- checks the same durable signal here rather than + trusting anything the triggering event itself claims. The read always + uses the central-repository dispatch credential + (:func:`gh_api_json_via_dispatch_token`), because the artifact always + lives in that central repository regardless of which repository ``repo`` + names, and the target-repository read credential is not guaranteed to + have Actions permission there for a cross-repository dispatch. That + dispatch credential is itself only valid when this scheduler executes + inside the central repository; an ordinary required-workflow scan + executing directly in a sibling repository has no credential able to + read the central repository's artifacts at all. Rather than let that + ``gh`` failure -- or a malformed/tampered artifact-list response -- + propagate and abort the whole multi-PR scan over one draft PR, any + failure to positively confirm a live artifact resolves to ``False``: + the same safe "no explicit request" outcome as a live check that + actually completes and finds nothing. + """ + head_sha = pr.get("headRefOid") + if not isinstance(head_sha, str) or not head_sha: + return False + dispatch_repo = repository_dispatch_target(validate_github_repository(repo)) + artifact_name = draft_review_request_artifact_name(repo, pr["number"], head_sha) + try: + response = gh_api_json_via_dispatch_token( + f"repos/{dispatch_repo}/actions/artifacts?name={artifact_name}&per_page=100" + ) + return bool(_draft_review_request_records(response, expected_name=artifact_name)) + except (RuntimeError, ValueError): + return False + + +def dispatch_draft_review_only( + repo: str, + pr: dict[str, Any], + *, + dry_run: bool, + review_dispatch_allowed: bool, + workflow: str, + security_workflow: str, + stale_opencode_minutes: int, +) -> Decision: + """Dispatch review evidence for one draft PR, never touching merge/branch state. + + An explicit review-only request (a mention invocation, never the ordinary + queue sweep) may reach this for a draft PR. It runs exactly the same + Strix-then-OpenCode dispatch gate the ready-PR pipeline uses below, so a + draft gets the same evidence chain -- but it returns before any of + ``inspect_pr``'s unresolved-thread, changes-requested, branch-update, or + auto-merge logic, so a draft can never be merged, auto-merged, or have its + branch updated by reaching this function. + """ + number = pr["number"] + opencode_state = opencode_progress_state(pr, stale_after_minutes=stale_opencode_minutes) + if opencode_state == "running": + return Decision(number, "wait", "draft PR review-only dispatch; OpenCode review already running") + # opencode_state == "complete" means a matching check/status reached a + # terminal state -- it does not mean opencode-agent posted a review. The + # required-workflow gate itself fails closed (a terminal, non-running + # check) whenever no verdict was ever dispatched, so treating "complete" + # alone as a verdict would make a failed dispatch attempt permanently + # block every later explicit retry. Only an actual current-head formal + # review is a verdict. + if has_current_head_approval(pr) or has_current_head_changes_requested(pr): + return Decision( + number, + "skip", + "draft PR review-only dispatch; current-head OpenCode verdict already exists", + ) + strix_state = strix_evidence_state(pr) + if strix_state in {"missing", "failed"}: + if not review_dispatch_allowed: + return Decision( + number, + "wait", + "draft PR review-only dispatch; current head has no completed Strix evidence; " + "review dispatch limit reached", + ) + wait_reason = repository_dispatch_wait_reason(repo, security_workflow) + if wait_reason: + return Decision( + number, + "wait", + f"draft PR review-only dispatch; current head has no completed Strix evidence; {wait_reason}", + ) + dispatch_result = dispatch_strix_evidence(repo, security_workflow, pr, dry_run=dry_run) + if dispatch_result == "already_running": + return Decision( + number, "wait", "draft PR review-only dispatch; same-head Strix evidence is still running" + ) + if dispatch_result == "repository_busy": + return Decision( + number, + "wait", + "draft PR review-only dispatch; current head has no completed Strix evidence; " + "target repository already has active Strix evidence", + ) + return Decision( + number, + "security_dispatch", + "draft PR review-only dispatch; current head has no completed Strix evidence; same-head Strix dispatched", + ) + if strix_state == "running": + return Decision(number, "wait", "draft PR review-only dispatch; same-head Strix evidence is still running") + if not review_dispatch_allowed: + return Decision( + number, + "wait", + "draft PR review-only dispatch; current head has completed Strix evidence; " + "review dispatch limit reached", + ) + wait_reason = repository_dispatch_wait_reason(repo, workflow) + if wait_reason: + return Decision( + number, + "wait", + f"draft PR review-only dispatch; 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 Decision( + number, + "wait", + "draft PR review-only dispatch; current head has completed Strix evidence; " + "same-head OpenCode workflow run is already active", + ) + return Decision( + number, + "review_dispatch", + "draft PR review-only dispatch; current head has completed Strix evidence; same-head OpenCode dispatched", + ) + + +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, + allow_draft_review_dispatch: bool = False, +) -> 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"): + if trigger_reviews and ( + allow_draft_review_dispatch or active_draft_review_request(repo, pr) + ): + return dispatch_draft_review_only( + repo, + pr, + dry_run=dry_run, + review_dispatch_allowed=review_dispatch_allowed, + workflow=workflow, + security_workflow=security_workflow, + stale_opencode_minutes=stale_opencode_minutes, + ) + 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) + check_gated_retry = can_retry_check_gated_opencode_review(pr) + if check_gated_retry and pr.get("autoMergeRequest"): + opencode_state = "complete" + elif check_gated_retry and trigger_reviews and opencode_state != "running": + opencode_state = "absent" + 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", + ) + if opencode_state in {"absent", "stale"} and trigger_reviews and not review_dispatch_allowed: + return Decision( + number, + "wait", + f"stacked PR onto {base_ref}; OpenCode review {opencode_state}; " + "review dispatch limit reached", + ) + 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 revalidate_before_merge() -> Decision | None: + """Return a blocking decision if a fresh re-check just revoked approval. + + Call this immediately before every ``merge_pr``/``enable_auto_merge`` + invocation below, after every other authorization check has already passed + against the (possibly stale) snapshot fetched at the top of this scheduler + invocation -- closing the TOCTOU window between that snapshot and the + mutating call. Returns ``None`` when the fresh re-check still authorizes the + merge, so the caller proceeds unchanged. dry-run inspection never mutates + anything, so it skips the extra re-fetch entirely. + """ + if dry_run: + return None + reason = revalidate_current_head_approval(repo, pr) + if not reason: + return None + if pr.get("autoMergeRequest"): + return finish(disable_auto_merge_decision(repo, pr, dry_run=dry_run, reason=reason)) + return decide("wait", 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", + ) + if not head_mutation_credential_starts_workflows(): + return decide( + "wait", + f"{freshness_reason}; {non_triggering_head_mutation_reason('branch update')}", + ) + 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): + behind_by = branch_outdated_by_base(pr, merge_state) + if ( + merge_state not in {"DIRTY", "CONFLICTING"} + and behind_by + and not pr.get("autoMergeRequest") + and update_branches + and trigger_reviews + and review_dispatch_allowed + and can_update_pr_head(repo, pr) + ): + return request_branch_update( + "current-head OpenCode review requested changes; branch is outdated before re-review" + ) + coverage_retry_progress = opencode_progress_state( + pr, stale_after_minutes=stale_opencode_minutes + ) + coverage_ready = ( + merge_state not in {"DIRTY", "CONFLICTING"} + and trigger_reviews + and review_dispatch_allowed + and current_head_coverage_change_request(pr) + and coverage_evidence_state(pr) == "complete" + and strix_evidence_state(pr) == "complete" + and not failed_status_checks(pr, ignore_opencode=True) + ) + if coverage_ready: + if coverage_retry_progress == "running": + if pr.get("autoMergeRequest"): + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + "current-head OpenCode coverage evidence is complete; disable " + "auto-merge while same-head re-review is already running" + ), + ) + ) + return decide( + "wait", + "current-head OpenCode coverage evidence is complete; " + "same-head OpenCode re-review is already running", + ) + retry_wait_reason = coverage_retry_wait_reason( + pr, + repo=repo if not dry_run else None, + workflow=workflow if not dry_run else None, + ) + if retry_wait_reason: + if pr.get("autoMergeRequest"): + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + f"{retry_wait_reason}; disable auto-merge until the same-head " + "coverage retry floor elapses" + ), + ) + ) + return decide("wait", retry_wait_reason) + if pr.get("autoMergeRequest"): + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + "current-head OpenCode coverage blocker is cleared; disable auto-merge " + "before same-head re-review" + ), + ) + ) + wait_reason = repository_dispatch_wait_reason(repo, workflow) + if wait_reason: + return decide("wait", wait_reason) + dispatch_result = dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) + if dispatch_result == "already_running": + return decide( + "wait", + "current-head coverage evidence is complete, but a same-head OpenCode workflow run is already active", + ) + return decide( + "review_dispatch", + "current-head OpenCode coverage blocker is cleared; same-head OpenCode re-dispatched", + ) + # Not a coverage-only gate: a separately eligible check-gated retry (the + # review was blocked only on then-failing GitHub Checks, which have + # since cleared) also earns a fall-through instead of a block, so the + # ordinary Strix/OpenCode dispatch pipeline below can re-review it. + check_gated_retry_ready = ( + can_retry_check_gated_opencode_review(pr) + and trigger_reviews + and review_dispatch_allowed + and not pr.get("autoMergeRequest") + ) + if not check_gated_retry_ready: + conflict_suffix = ( + f"; {merge_conflict_guidance(pr, merge_state)}" + if merge_state in {"DIRTY", "CONFLICTING"} + else "" + ) + 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 " + f"before re-enabling auto-merge{conflict_suffix}" + ), + ) + ) + return decide( + "block", + f"current-head OpenCode review requested changes{conflict_suffix}", + ) + + current_head_approved = has_current_head_approval(pr) + approval_reason = merge_approval_block_reason(pr) if current_head_approved else None + 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 approval_reason and auto_merge_enabled: + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + f"{approval_reason}; obtain fresh independent approval before " + "re-enabling auto-merge" + ), + ) + ) + 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 approval_reason: + return decide("wait", approval_reason) + 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"}: + revalidation = revalidate_before_merge() + if revalidation: + return revalidation + 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)) + revalidation = revalidate_before_merge() + if revalidation: + return revalidation + 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 current_head_approved: + # auto_merge_enabled must be True to have reached this branch (the + # outer condition requires current_head_approved or + # auto_merge_enabled). An outdated branch is routine and does not + # by itself justify disarming auto-merge -- but an auto-merge + # request armed with no live current-head approval is exactly the + # stale authorization this scheduler exists to catch, and simply + # requesting a branch update here would leave it queued: once the + # updated head's required checks pass, GitHub's own native + # auto-merge could merge it without this scheduler ever getting a + # chance to require a fresh independent approval on that new + # head. Disarm before requesting the update rather than after. + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + f"branch is {behind_by} commit(s) behind base (GitHub mergeability is " + f"{merge_state}) with no live current-head approval to authorize " + "auto-merge; obtain fresh independent approval before re-enabling auto-merge" + ), + ) + ) + if not update_branches: + return decide("wait", "current-head OpenCode review approved; 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 merge_state == "BEHIND": + freshness_reason = "current-head OpenCode review approved" + else: + freshness_reason = ( + "current-head OpenCode review approved; " + 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", + ) + if not head_mutation_credential_starts_workflows(): + return decide( + "wait", + f"{block_reason}; {non_triggering_head_mutation_reason('last-push approval head restamp')}", + ) + 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, + ) + ) + + if not current_head_approved and auto_merge_enabled: + # Neither behind-by disarm path applies (the branch is not behind + # base) and the last-push-approval restamp does not apply either (it + # requires current_head_approved). Yet auto-merge is still armed with + # no live current-head approval -- whether from a previously valid + # approval a new push has since invalidated, or from auto-merge armed + # before any review ever ran, this scheduler draws no distinction + # between the two (see the behind-by disarm path and the prior + # unconditional catch-all below, neither of which drew one either). + # Disarm immediately here, before any of the wait/dispatch branches + # below (OpenCode running, deterministic-fallback wait, stale-review + # retry, or the ordinary Strix/OpenCode dispatch cascade -- the + # everyday state for a PR between or during reviews) can return + # without having done so. Relying on a catch-all reached only once + # dispatch has nothing left to do would let GitHub's own native + # auto-merge complete the merge first if this scheduler is the only + # thing enforcing the OpenCode-approval requirement. + 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" + ), + ) + ) + + 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 approval_reason: + return decide("wait", approval_reason) + 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": + revalidation = revalidate_before_merge() + if revalidation: + return revalidation + 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}") + revalidation = revalidate_before_merge() + if revalidation: + return revalidation + 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 in {"missing", "failed"}: + 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_result = dispatch_strix_evidence(repo, security_workflow, pr, dry_run=dry_run) + if dispatch_result == "already_running": + return decide("wait", "same-head Strix evidence is still running") + if dispatch_result == "repository_busy": + return decide( + "wait", + "current head has no completed Strix evidence; target repository already has active Strix evidence", + ) + 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", + ) + + # No autoMergeRequest re-check is needed here: the hoisted + # `not current_head_approved and auto_merge_enabled` guard above already + # disarmed and returned before any of the wait/dispatch branches between + # it and here could be reached, so auto-merge cannot still be armed by + # this point. + 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(head_mutation_credential_upgrade_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 head_mutation_credential_upgrade_summary(decisions: list[Decision]) -> list[str]: + """Return a GitHub Actions Summary section for withheld head mutations.""" + waits = [decision for decision in decisions if parse_non_triggering_head_mutation_reason(decision.reason)] + if not waits: + return [] + summary, automation_limit = head_mutation_credential_guidance_text() + lines = ["", "### Head mutation withheld", "", summary, automation_limit] + lines.extend( + [ + "Configure `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, or the OpenCode app credential, then rerun the scheduler.", + "Alternatively, let the PR author push the branch so required checks start from the owning actor.", + "", + "Withheld decisions:", + ] + ) + lines.extend(f"- PR #{decision.pr}: {decision.reason}" for decision in waits) + return lines + + +def parse_non_triggering_head_mutation_reason(reason: str) -> bool: + """Return whether a reason describes a withheld non-triggering head mutation.""" + return ( + "whose head mutations never start new workflow runs" in reason + or "which is not allowlisted as workflow-starting" in reason + ) + + +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) + + +@contextlib.contextmanager +def declared_mutation_token_source(source: str) -> Iterator[None]: + """Declare a scheduler mutation credential source for the enclosed block.""" + previous = os.environ.get("SCHEDULER_MUTATION_TOKEN_SOURCE") + os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = source + try: + yield + finally: + if previous is None: + os.environ.pop("SCHEDULER_MUTATION_TOKEN_SOURCE", None) + else: + os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = previous + + +def self_test() -> None: + """Exercise scheduler invariants without GitHub network access.""" + with declared_mutation_token_source("PR_REVIEW_MERGE_TOKEN"): + self_test_scheduler_invariants() + + +def self_test_scheduler_invariants() -> None: + """Exercise scheduler invariants with a workflow-starting mutation credential.""" + 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, + "author": {"login": "pull-request-author"}, + "headRefOid": "abc", + "baseRefName": "main", + "baseRefOid": "base", + "headRefName": "feature", + "mergeStateStatus": "CLEAN", + "restMergeableState": "CLEAN", + "isDraft": False, + "isCrossRepository": False, + "maintainerCanModify": False, + "headRepository": {"nameWithOwner": "owner/repo"}, + "reviewDecision": "APPROVED", + "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"}, + }, + { + "state": "APPROVED", + "author": {"login": "independent-reviewer"}, + "submittedAt": "2026-06-25T15:43: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"} + sample["reviews"]["nodes"].append( + { + "state": "APPROVED", + "author": {"login": "independent-reviewer"}, + "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["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, + "author": {"login": "pull-request-author"}, + "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"}, + }, + { + "state": "APPROVED", + "author": {"login": "independent-reviewer"}, + "submittedAt": "2026-06-25T15:43: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" + with declared_mutation_token_source("github-token"): + 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" + withheld_guidance = decision_guidance( + Decision(1, "wait", non_triggering_head_mutation_reason("branch update")) + ) + assert withheld_guidance + assert withheld_guidance["type"] == "head_mutation_credential_upgrade" + assert withheld_guidance["token"] == "workflow GITHUB_TOKEN" + assert not head_mutation_credential_starts_workflows() + assert head_mutation_credential_starts_workflows() + 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" + with declared_mutation_token_source("github-token"): + entry = decision_contract_entry(Decision(1, "update_branch", "ok")) + assert entry["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( + "--allow-draft-review-dispatch", + action="store_true", + help=( + "Allow a --pr-number draft PR to receive Strix/OpenCode review " + "dispatch. Structurally review-only: never merges, enables " + "auto-merge, or updates the branch. A manual operator override " + "for direct CLI use only -- no caller-supplied signal reaching " + "this script (repository_dispatch client_payload included) is " + "trusted to set this automatically, because it cannot be bound " + "to a specific validated request. The production automatic path " + "is inspect_pr()'s own active_draft_review_request() marker " + "check, gated on a cryptographically validated, exact-head-named " + "artifact that only a legitimate mention invocation can create." + ), + ) + 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( + "--stacked-review-dispatch-limit", + type=int, + default=None, + help="Optional separate OpenCode review dispatch limit for stacked PRs; -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.""" + # Each invocation is a fresh look at GitHub; never reuse another + # invocation's active_workflow_runs cache (relevant when a process + # calls main() more than once, tests included). + reset_active_workflow_runs_cache() + 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.stacked_review_dispatch_limit is not None and args.stacked_review_dispatch_limit < -1: + raise SystemExit("--stacked-review-dispatch-limit must be -1 or greater") + if args.branch_update_limit < -1: + raise SystemExit("--branch-update-limit must be -1 or greater") + if args.allow_draft_review_dispatch and not args.pr_number: + raise SystemExit( + "--allow-draft-review-dispatch requires --pr-number; it is a single-PR " + "review-only exception, never a default for the multi-PR queue sweep" + ) + prs = fetch_pr(args.repo, args.pr_number) if args.pr_number else fetch_open_prs(args.repo, args.max_prs) + if not args.pr_number: + # Stacked PRs have no injected required workflow and depend exclusively + # on this bounded sweep; default-base PRs also receive event-driven runs. + prs.sort(key=lambda pr: pr.get("baseRefName") == args.base_branch) + decisions = [] + review_dispatches_used = 0 + stacked_review_dispatches_used = 0 + branch_updates_used = 0 + for pr in prs: + stacked_pr = pr.get("baseRefName") != args.base_branch + if stacked_pr and args.stacked_review_dispatch_limit is not None: + review_dispatch_allowed = ( + args.stacked_review_dispatch_limit < 0 + or stacked_review_dispatches_used < args.stacked_review_dispatch_limit + ) + else: + 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, + allow_draft_review_dispatch=args.allow_draft_review_dispatch, + ) + except RuntimeError as exc: + if is_rate_limited_error(exc): + # A mid-scan shared-installation rate-limit exhaustion (e.g. + # from an active-run read, cancellation, dispatch, merge, or + # branch update inside inspect_pr(), as opposed to the + # fetch_open_prs()/fetch_pr() calls above the loop) must + # propagate exactly like that earlier path does, instead of + # being folded into an ordinary action_error decision here. + # Swallowing it and continuing the loop would keep spending + # the same exhausted bucket on every remaining PR in this + # repository; returning 0 afterward would also mean this + # never reaches the workflow's "API rate limit exceeded" + # skip-and-defer branch (which only fires on a non-zero exit + # code), so later repositories in the same org-sweep rotation + # would keep spending the shared bucket too. Print the + # summary for the PRs already inspected so their decisions + # and dispatch/update counts are not lost, then let the error + # propagate and exit non-zero like the pre-loop rate-limit + # path. + decisions.append( + Decision( + pr.get("number", 0), + "action_error", + summarize_action_error(exc), + ) + ) + print_summary( + decisions, + dry_run=args.dry_run, + base_branch=args.base_branch, + project_flow=args.project_flow, + ) + raise + decision = Decision( + pr.get("number", 0), + "action_error", + summarize_action_error(exc), + ) + decisions.append(decision) + if decision.action in {"review_dispatch", "security_dispatch"}: + if stacked_pr and args.stacked_review_dispatch_limit is not None: + stacked_review_dispatches_used += 1 + else: + 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/tests/test_scheduler_opencode_followup_defer_contract.py b/tests/test_scheduler_opencode_followup_defer_contract.py new file mode 100644 index 0000000000..c6564e6d6d --- /dev/null +++ b/tests/test_scheduler_opencode_followup_defer_contract.py @@ -0,0 +1,80 @@ +"""Cross-file contract for OpenCode follow-up rate-limit deferral.""" + +from __future__ import annotations + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DISPATCH_WORKFLOW_PATH = ( + REPOSITORY_ROOT / ".github" / "workflows" / "opencode-review-dispatch.yml" +) +SCHEDULER_FACADE_PATH = ( + REPOSITORY_ROOT / "scripts" / "ci" / "pr_review_merge_scheduler.py" +) + + +def _merge_scheduler_step(workflow_source: str) -> str: + """Return the OpenCode post-approval merge-scheduler step.""" + + marker = " - name: Run merge scheduler after approval\n" + step_start = workflow_source.index(marker) + try: + step_end = workflow_source.index("\n - name:", step_start + len(marker)) + except ValueError: + step_end = len(workflow_source) + return workflow_source[step_start:step_end] + + +def test_facade_signature_matches_the_live_opencode_followup_caller() -> None: + """Fail when caller arguments drift away from the scoped defer predicate.""" + + workflow_source = DISPATCH_WORKFLOW_PATH.read_text(encoding="utf-8") + scheduler_step = _merge_scheduler_step(workflow_source) + facade_source = SCHEDULER_FACADE_PATH.read_text(encoding="utf-8") + + assert workflow_source.startswith("name: OpenCode Review Dispatch\n") + for required_argument in ( + '--max-prs 1', + '--review-dispatch-limit 0', + '--merge-mode direct_or_auto', + '--pr-number "$PR_NUMBER"', + '--no-trigger-reviews', + '--enable-auto-merge', + '--no-update-branches', + ): + assert required_argument in scheduler_step + + assert 'GITHUB_WORKFLOW", "") == "OpenCode Review Dispatch"' in facade_source + assert '_argument_value(argument_values, "--max-prs") == "1"' in facade_source + assert ( + '_argument_value(argument_values, "--review-dispatch-limit") == "0"' + in facade_source + ) + assert '== "direct_or_auto"' in facade_source + + +def test_followup_documents_the_authoritative_retry_owner() -> None: + """Keep a bounded scheduler path after this best-effort caller defers.""" + + workflow_source = DISPATCH_WORKFLOW_PATH.read_text(encoding="utf-8") + scheduler_step = _merge_scheduler_step(workflow_source) + facade_source = SCHEDULER_FACADE_PATH.read_text(encoding="utf-8") + + assert "scheduled scheduler paths remain authoritative" in scheduler_step + assert "review-event and scheduled scheduler paths remain authoritative" in scheduler_step + assert "Required PR Review Merge Scheduler heartbeat" in facade_source + + +def test_rate_limit_defer_stops_the_existing_outer_retry_loop() -> None: + """Pair caller non-zero retry behavior with facade success-on-defer behavior.""" + + workflow_source = DISPATCH_WORKFLOW_PATH.read_text(encoding="utf-8") + scheduler_step = _merge_scheduler_step(workflow_source) + facade_source = SCHEDULER_FACADE_PATH.read_text(encoding="utf-8") + + assert "for attempt in 1 2 3; do" in scheduler_step + assert 'sleep "$((attempt * 5))"' in scheduler_step + assert "and _is_opencode_post_approval_followup(argument_values)" in facade_source + assert "return 0" in facade_source + assert "scheduler_outcome=deferred_rate_limit" in facade_source diff --git a/tests/test_scheduler_rate_limit_fail_fast_entrypoint.py b/tests/test_scheduler_rate_limit_fail_fast_entrypoint.py new file mode 100644 index 0000000000..d092bbe01c --- /dev/null +++ b/tests/test_scheduler_rate_limit_fail_fast_entrypoint.py @@ -0,0 +1,292 @@ +"""Contracts for fail-fast GitHub primary rate-limit handling.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.ci import pr_review_merge_scheduler as scheduler_facade +from scripts.ci import pr_review_merge_scheduler_core as scheduler_core + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +FACADE_PATH = ( + REPOSITORY_ROOT / "scripts" / "ci" / "pr_review_merge_scheduler.py" +) +CORE_PATH = ( + REPOSITORY_ROOT + / "scripts" + / "ci" + / "pr_review_merge_scheduler_core.py" +) + + +def _post_approval_arguments() -> list[str]: + """Return the exact OpenCode post-publication scheduler signature.""" + + return [ + "--repo", + "ContextualWisdomLab/example-service", + "--base-branch", + "main", + "--max-prs", + "1", + "--project-flow", + "github-flow", + "--review-workflow", + "Required OpenCode Review", + "--security-workflow", + "Strix Security Scan", + "--review-dispatch-limit", + "0", + "--no-trigger-reviews", + "--enable-auto-merge", + "--merge-mode", + "direct_or_auto", + "--no-update-branches", + "--pr-number", + "42", + ] + + +@pytest.fixture(autouse=True) +def restore_scheduler_api_helpers(): + """Restore core API helpers after each installer-focused regression test.""" + + original_graphql = scheduler_core.gh_graphql + original_rest = scheduler_core.gh_api_json + yield + scheduler_core.gh_graphql = original_graphql + scheduler_core.gh_api_json = original_rest + + +def test_graphql_rate_limit_fails_after_one_request_without_sleep( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not hold a runner once the shared GraphQL bucket is exhausted.""" + + calls: list[list[str]] = [] + sleeps: list[int] = [] + + def exhausted_read( + command: list[str], *, stdin: str | None = None + ) -> str: + calls.append(command) + assert stdin == "query { viewer { login } }" + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "run_github_read", exhausted_read) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + scheduler_facade.install_fail_fast_rate_limit_policy() + + with pytest.raises(RuntimeError, match="API rate limit exceeded"): + scheduler_core.gh_graphql("query { viewer { login } }") + + assert len(calls) == 1 + assert sleeps == [] + assert ["gh", "api", "rate_limit"] not in calls + + +def test_rest_rate_limit_fails_after_one_request_without_sleep( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not query reset metadata or sleep after a REST bucket exhaustion.""" + + calls: list[list[str]] = [] + sleeps: list[int] = [] + + def exhausted_read( + command: list[str], *, stdin: str | None = None + ) -> str: + calls.append(command) + assert stdin is None + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "run_github_read", exhausted_read) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + scheduler_facade.install_fail_fast_rate_limit_policy() + + with pytest.raises(RuntimeError, match="API rate limit exceeded"): + scheduler_core.gh_api_json("repos/example/project") + + assert calls == [["gh", "api", "repos/example/project"]] + assert sleeps == [] + + +def test_transient_transport_error_keeps_one_short_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preserve bounded recovery for a passing GitHub transport failure.""" + + responses: list[object] = [ + RuntimeError("temporary server error"), + '{"ok": true}', + ] + sleeps: list[int] = [] + + def transient_read( + command: list[str], *, stdin: str | None = None + ) -> str: + assert command == ["gh", "api", "repos/example/project"] + assert stdin is None + response = responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + monkeypatch.setattr(scheduler_core, "run_github_read", transient_read) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + scheduler_facade.install_fail_fast_rate_limit_policy() + + assert scheduler_core.gh_api_json("repos/example/project") == { + "ok": True + } + assert sleeps == [1] + assert responses == [] + + +def test_opencode_followup_accepts_typed_rate_limit_defer_without_outer_retry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Stop the OpenCode caller's 5, 10, and 15 second retry sleeps.""" + + argument_values = _post_approval_arguments() + summary_path = tmp_path / "step-summary.md" + sleeps: list[int] = [] + + def deferred_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "main", deferred_main) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + monkeypatch.setenv("GITHUB_WORKFLOW", "OpenCode Review Dispatch") + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_path)) + + assert scheduler_facade.run_cli(argument_values) == 0 + assert sleeps == [] + summary = summary_path.read_text(encoding="utf-8") + assert "outcome: `deferred_rate_limit`" in summary + assert "retry owner: Required PR Review Merge Scheduler heartbeat" in summary + assert "runner-held sleep: 0 seconds" in summary + + +def test_org_sweep_rate_limit_remains_nonzero_and_stops_rotation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preserve #1245's organization-rotation stop signal.""" + + argument_values = [ + "--repo", + "ContextualWisdomLab/example-service", + "--base-branch", + "main", + "--max-prs", + "8", + "--review-dispatch-limit", + "3", + ] + + def deferred_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "main", deferred_main) + monkeypatch.setenv("GITHUB_WORKFLOW", "Required PR Review Merge Scheduler") + + assert scheduler_facade.run_cli(argument_values) == 1 + + +def test_caller_name_alone_cannot_relabel_org_scan_as_accepted_defer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Require the exact post-approval argument signature as well as workflow.""" + + argument_values = ["--repo", "ContextualWisdomLab/example-service"] + + def deferred_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "main", deferred_main) + monkeypatch.setenv("GITHUB_WORKFLOW", "OpenCode Review Dispatch") + + assert scheduler_facade.run_cli(argument_values) == 1 + + +def test_cli_keeps_non_rate_limit_failure_blocking( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not relabel an unrelated scheduler defect as accepted deferral.""" + + argument_values = _post_approval_arguments() + + def failing_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("invalid repository payload") + + monkeypatch.setattr(scheduler_core, "main", failing_main) + monkeypatch.setenv("GITHUB_WORKFLOW", "OpenCode Review Dispatch") + + assert scheduler_facade.run_cli(argument_values) == 1 + + +def test_legacy_monkeypatches_are_forwarded_to_the_core_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep existing tests and callers on the stable import path.""" + + sentinel = object() + monkeypatch.setattr( + scheduler_facade, + "DEFAULT_STALE_OPENCODE_MINUTES", + sentinel, + ) + + assert scheduler_core.DEFAULT_STALE_OPENCODE_MINUTES is sentinel + assert scheduler_facade.DEFAULT_STALE_OPENCODE_MINUTES is sentinel + + +def test_wildcard_import_preserves_the_original_public_scheduler_api() -> None: + """Export delegated public APIs through the stable facade path.""" + + imported_namespace: dict[str, object] = {} + exec( + "from scripts.ci.pr_review_merge_scheduler import *", + imported_namespace, + ) + + assert imported_namespace["main"] is scheduler_core.main + assert imported_namespace["gh_graphql"] is scheduler_core.gh_graphql + assert imported_namespace["gh_api_json"] is scheduler_core.gh_api_json + assert "_scheduler_core" not in imported_namespace + assert "main" in scheduler_facade.__all__ + + +def test_core_owns_the_existing_dispatch_contract_markers() -> None: + """Keep static dispatch evidence on the implementation, not only facade.""" + + core_source = CORE_PATH.read_text(encoding="utf-8") + for marker in ( + 'f"repos/{dispatch_repo}/dispatches"', + '"event_type": "opencode-review"', + '"event_type": "strix-scan"', + ): + assert marker in core_source + + +def test_facade_installs_no_reset_lookup_on_the_production_entrypoint() -> None: + """Guard against reintroducing rate-limit polling into the stable CLI.""" + + facade_source = FACADE_PATH.read_text(encoding="utf-8") + + assert "install_fail_fast_rate_limit_policy()" in facade_source + assert "rate_limit_retry_delay_seconds(" not in facade_source + assert '["gh", "api", "rate_limit"]' not in facade_source + assert "deferring without runner-held sleep" in facade_source + assert "scheduler_outcome=deferred_rate_limit" in facade_source + assert "Required PR Review Merge Scheduler heartbeat" in facade_source + assert 'GITHUB_WORKFLOW", "") == "OpenCode Review Dispatch"' in facade_source + assert "__all__ = tuple(" in facade_source