Skip to content

fix(scheduler): require independent exact-head approval - #1270

Open
seonghobae wants to merge 17 commits into
mainfrom
codex/scheduler-independent-exact-head-approval-20260824
Open

fix(scheduler): require independent exact-head approval#1270
seonghobae wants to merge 17 commits into
mainfrom
codex/scheduler-independent-exact-head-approval-20260824

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Customer outcome

The organization merge scheduler refuses direct merge and auto-merge until GitHub reports reviewDecision: APPROVED and a non-author, non-OpenCode formal approval is bound to the exact live head. If stale authorization left auto-merge armed, the scheduler disables it.

Root cause and minimal repair

PR #828 identified a real separation-of-duties gap but its old-base implementation replayed stale files and split the mature scheduler into a second core. This clean replacement reuses the existing scheduler and adds the shared authorization boundary, exact PR-author evidence, regressions, changelog, and APA 7 doctoring.

The current head also repairs check-run chronology: canceled-before-start and queued reruns use their non-null check-suite creation time, so a predecessor success cannot outrank a newer terminal or pending rerun merely because startedAt is absent.

The independent-review helper considers only each eligible reviewer's latest exact-head approval-affecting state. A later same-head change request or dismissal therefore revokes that reviewer's earlier approval. Generic github-actions[bot], missing identities, self-review, comment-only review, and predecessor-head approval remain fail closed. GitHub remains authoritative for last-pusher, CODEOWNERS, required-review, and ruleset policy.

Exact current-head evidence

  • Head: 63cd827f8a66ba36c0bc0af61e0830fbab0622a2
  • Live PR base: main@1d8e872487838e16a003e96e76df9300c388e258
  • Protected central main at revalidation: 1ff8268255b061461d9d49b4cab4febf9a8e7bfa
  • Current changed files: CHANGELOG.md, docs/doctoring/scheduler-independent-current-head-approval.md, scripts/ci/pr_review_merge_scheduler.py, and tests/test_pr_review_merge_scheduler.py.
  • Exact-head Security Scan, SAST, CodeQL, OSV, SBOM, Python Security, Scorecard, Secret Scan, organization quality, coverage quality, artifact attestation, and changed-path Strix quality are terminal success.
  • Required Strix run 33328950520 completed authoritative analysis on this exact head and reported zero exploitable vulnerabilities. Artifact 9737188756 is bound to this head with digest sha256:aa8bc70e4bdc3da73216911da419968e778963ca6c4619a295299da317816e4d.
  • Required OpenCode run 33328950514 correctly failed because the Reviews API has no exact-current-head APPROVED or CHANGES_REQUESTED OpenCode verdict.
  • Required Noema run 33328950526 emitted Current head does not have a primary OpenCode approval; Noema review skipped but concluded GitHub-success. This is the known fail-closed control defect owned by fix(security): repair pip audit and schedule orchestrator review #1198 and is non-passing evidence.
  • Current unresolved review thread count: 7. All seven are current, non-outdated Devin Info threads; they remain unresolved and are therefore non-passing under the live thread-resolution contract.
  • Exact-current-head qualifying independent formal approval count: 0.

Merge boundary

Do not transfer review or check evidence from #828 or any predecessor head. This exact head remains unmergeable under its own separation-of-duties contract until required Noema is fail-closed, a qualifying current-head OpenCode verdict exists, and an independent exact-head formal approval is present. Project #1 remains In Progress.

Decision: WAIT_FOR_EXACT_HEAD_FORMAL_VERDICTS_AND_INDEPENDENT_APPROVAL.


Open in Devin Review

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 32b0a7fb-2d25-4bfb-b422-b687b357200e

📥 Commits

Reviewing files that changed from the base of the PR and between 6370b32 and 80c7f38.

📒 Files selected for processing (2)
  • scripts/ci/pr_review_merge_scheduler.py
  • tests/test_pr_review_merge_scheduler.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

PR Review Merge Scheduler가 현재 HEAD의 독립 승인과 GitHub aggregate 승인을 병합 조건으로 적용합니다. 최신 CheckRun과 Coverage 실행을 판별하고, 조건 충족 후 OpenCode 리뷰를 재디스패치합니다.

Changes

병합 스케줄러 정책

Layer / File(s) Summary
리뷰 이력 및 최신 CheckRun 선택
scripts/ci/pr_review_merge_scheduler.py, tests/test_pr_review_merge_scheduler.py
GraphQL과 REST 리뷰 이력을 페이지네이션합니다. 리뷰 작성자와 봇을 식별합니다. 최신 CheckRun과 repository_dispatch 실행을 시간과 상태 기준으로 선택합니다.
현재 HEAD 승인 및 병합 게이트
scripts/ci/pr_review_merge_scheduler.py, tests/test_pr_review_merge_scheduler.py, docs/doctoring/scheduler-independent-current-head-approval.md
GitHub reviewDecisionAPPROVED이고 비작성자·비-OpenCode 주체의 최신 exact-head formal approval이 있을 때만 병합합니다. 직접 병합과 auto-merge 전에 PR과 승인 정책을 재검증합니다.
Coverage 해소 후 OpenCode 재디스패치
scripts/ci/pr_review_merge_scheduler.py, tests/test_pr_review_merge_scheduler.py, CHANGELOG.md
Coverage 전용 blocker를 구분하고 최신 coverage evidence와 dispatch 실행을 확인합니다. 조건이 충족되면 현재 HEAD에 OpenCode 리뷰를 재디스패치합니다. 충돌하는 head, 실패한 sibling job, 미해결 thread, Strix 및 기타 실패 검사는 계속 차단합니다.
정책 검증 및 변경 기록
tests/test_pr_review_merge_scheduler.py, CHANGELOG.md, docs/doctoring/scheduler-independent-current-head-approval.md
승인, pagination, CheckRun 선택, Coverage 재시도, branch update, conflict 및 auto-merge 해제 동작을 검증하고 변경 사항을 기록합니다.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: 🟠 High · up to 80c7f

The scheduler strengthens exact-head independent approval, but two merge paths can still retain or enable auto-merge after approval becomes stale or revoked. That could allow a protected merge without the required current independent authorization, so the PR is not ready to merge until those transitions are addressed.

Sequence Diagram(s)

sequenceDiagram
  participant Scheduler
  participant GitHubAPI
  participant CoverageEvidence
  participant OpenCodeWorkflow
  Scheduler->>GitHubAPI: 현재 PR, 리뷰, head 및 CheckRun 조회
  GitHubAPI-->>Scheduler: 승인 상태와 검사 결과
  Scheduler->>CoverageEvidence: 최신 coverage evidence 확인
  CoverageEvidence-->>Scheduler: Coverage 상태
  Scheduler->>OpenCodeWorkflow: 조건 충족 시 현재 HEAD 리뷰 재디스패치
  OpenCodeWorkflow-->>GitHubAPI: OpenCode CheckRun 생성
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.12% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 138 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목은 스케줄러가 독립적인 exact-head 승인을 요구하도록 변경한 PR의 핵심 내용을 정확하고 간결하게 설명합니다.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/scheduler-independent-exact-head-approval-20260824

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent Please review the exact current head 5da3a5b0c1f0b48011d7b81ccc721f0a55c6f455. This is the protected-main replacement for stale PR #828: it reuses the existing scheduler, requires aggregate approval plus an independent non-author, non-bot exact-head approval, guards both direct merge and auto-merge, and disarms already-armed auto-merge when authorization is absent. Local exact evidence: 126 focused tests; 1,405 passed/1 skipped; 8,370 statements + 3,230 branches at 100%; docstrings 100%.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent Please review exact head aa0c93e5d461daf64c160b91066f90bad57a532f. Verify the independent-approval helper uses each eligible reviewer’s latest exact-head approval-affecting state, including that a later same-head CHANGES_REQUESTED or DISMISSED state revokes an earlier approval. Also re-evaluate direct merge, auto-merge disarm, repository reviewDecision, tests, coverage, doctoring, and changelog without transferring evidence from #828 or predecessor head 5da3a5b0.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae
seonghobae enabled auto-merge (squash) August 24, 2026 04:02
devin-ai-integration[bot]

This comment was marked as resolved.

@opencode-agent
opencode-agent Bot disabled auto-merge August 24, 2026 04:36
@seonghobae
seonghobae enabled auto-merge (squash) August 24, 2026 04:45
@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent review

Review exact current head 6cb7144 against protected main@0c6b9a6459c9dbdf5e23fb01df7a32a8a14964b3. Walk through the scheduler approval, exact-head binding, stale-review handling, and failure evidence; publish a substantive Reviews API verdict on this SHA.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head local verification for 6cb7144: full pytest passed (1410 passed, 1 skipped, 16 subtests); coverage.py statement and branch coverage both 100% across scripts/ci; interrogate public docstring gate passed for scripts/ci/pr_review_merge_scheduler.py; compileall and git diff --check passed. No source change was needed after review. Hosted Checks and an independent exact-head approval remain required.

@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent review Exact head: 6cb7144. Review the complete changed diff and report substantive findings with file/line evidence.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Current exact-head Strix RCA: the required Strix run reached the scan gate, emitted zero vulnerabilities, but produced no authoritative report artifact. The log records NVIDIA NIM provider exhaustion/rate limiting followed by the configured openai-direct fallback returning HTTP 404. This is typed provider/backend infrastructure failure and fail-closed behavior, not a source finding.

Next action: rerun the same-head Strix evidence after provider availability is restored; do not merge until the exact-head security evidence is complete.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head review follow-up\n\nCurrent exact head: 6cb7144\n\nThe Strix provider rerun is now executing for this head after the earlier provider/backend failure. Please review the full scheduler diff and publish a substantive exact-head verdict only after the current required Checks complete. No predecessor success or review evidence is being reused.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head verification receipt

  • Exact head: 6cb714470c5a0c31b36cb64df8959cd020f22d52
  • Exact base: 0c6b9a6459c9dbdf5e23fb01df7a32a8a14964b3
  • Verified merge-result tree: ed152dd8264301552e763a733e0ede848222746a (equal to exact head tree)
  • Focused scheduler/queue regression: 191 passed
  • Full suite: 1410 passed, 1 skipped, 16 subtests
  • Production statement and branch coverage: 100%
  • Public docstrings: 100%
  • Ruff, Python compile, diff check, and interrogate: passed
  • Hosted current evidence: CodeQL, coverage, dependency, OSV, Noema, scheduler, and Trivy checks passed; Strix failure is the known provider-only NIM/OpenAI fallback failure, and current OpenCode review remains pending.
  • Decision: WAIT_AND_REMEDIATE until current-head review/Strix gates are terminal; normal auto-merge remains enabled.

No source, workflow, ruleset, protected-branch, or bypass mutation was used.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head changed by the remote stack maintainer; predecessor evidence is discarded.

Current exact head: ccc1220cd7710d023d4f10d79ccd170a636b4516
Current exact base: 0c6b9a6459c9dbdf5e23fb01df7a32a8a14964b3

#1266 was merged into this branch through the normal PR path, so the combined scheduler tree requires a fresh full review and hosted verification. @opencode-agent please inspect the complete current diff, exact-head dispatch-history retry floor, central active-run boundary, and the merged coverage-retry behavior, then publish a substantive Reviews API verdict tied only to this SHA. Do not reuse predecessor reviews or checks.

devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@seonghobae

seonghobae commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Exact current-head remediation on c55eb40: fixed failed_status_checks so a newer successful coverage-evidence run supersedes an older failed run in both normal merge and coverage-only retry paths. Reused one UTC-aware datetime.min fallback in latest_check_runs. Focused scheduler suite: 146 passed; git diff --check passed. The branch remains protected with no current formal approval. Please issue a fresh independent substantive review and required checks for this exact head; predecessor evidence is not reused. No bypass or gate weakening was used.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Correction: exact current-head remediation on c55eb40. Fixed failed_status_checks so a newer successful coverage-evidence run supersedes an older failed run in both normal merge and coverage-only retry paths. Reused one UTC-aware datetime.min fallback in latest_check_runs. Focused scheduler suite: 146 passed; git diff --check passed. The branch remains protected with no current formal approval. Please issue a fresh independent substantive review and required checks for this exact head; predecessor evidence is not reused. No bypass or gate weakening was used.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head local verification completed for c55eb40: full pytest 1429 passed, 1 skipped, 16 subtests; coverage branch and statement 100 percent across scripts/ci (8499 statements, 3300 branches); interrogate docstring coverage 100 percent; Ruff lint passed; compileall and git diff --check passed. Ruff format check still reports pre-existing formatting drift in the two full files, so no unrelated reformat was introduced. Hosted required checks and independent approval remain pending; normal merge only.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head follow-up on 8423ff1: fixed the valid central coverage authority gap. When SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY is set, metadata-only Required OpenCode Review coverage-evidence no longer supersedes a failed OpenCode Review Dispatch run; retry and failed-check decisions use authoritative dispatch evidence while ordinary same-workflow reruns still supersede older failures. Added regression coverage and changelog entry. Verification: full pytest 1430 passed, 1 skipped, 16 subtests; statement and branch coverage 100 percent across scripts/ci (8504 statements, 3302 branches); interrogate docstring coverage 100 percent; Ruff lint, compileall, and git diff --check passed. The branch remains protected with no independent current-head approval. Please issue a fresh substantive review and required hosted checks for this exact head; no bypass or gate weakening was used.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Disposition for the two exact-head review threads on 8423ff1: the cross-workflow coverage finding was valid and is fixed by excluding the central Required OpenCode Review metadata placeholder from authoritative coverage superseding; the new regression proves a newer placeholder success cannot mask failed OpenCode Review Dispatch evidence. The independent-approval observation is intentionally fail-closed: GitHub App and bot identities are not independent human approval under this scheduler policy. Both findings are now dispositioned; no source uncertainty remains from these threads.

devin-ai-integration[bot]

This comment was marked as resolved.

@opencode-agent
opencode-agent Bot disabled auto-merge August 24, 2026 08:34

Copy link
Copy Markdown
Contributor Author

Resolved the stale merge conflict against main

Merged origin/main (1d8e8724) into this PR's head via a merge commit (189ce625), pushed to the existing branch. Three files conflicted: CHANGELOG.md (additive, kept both sides' entries), and two hunks in scripts/ci/pr_review_merge_scheduler.py. tests/test_pr_review_merge_scheduler.py auto-merged cleanly.

The real design conflict, and why both sides survive

Since this PR branched, main independently landed its own recovery path for a stuck CHANGES_REQUESTED review: can_retry_check_gated_opencode_review(), keyed on the generic CHECK_GATED_OPENCODE_CHANGE_REQUEST_MARKER ("OpenCode could not approve from deterministic current-head evidence because GitHub Checks have failed.") — when that marker is present and checks are no longer failing, main just falls through the if has_current_head_changes_requested(pr): block entirely and lets the ordinary Strix/OpenCode dispatch pipeline further down re-review the PR, with no retry-floor timer.

This PR's own contribution here is a different, narrower mechanism keyed on COVERAGE_REVIEW_MARKERS ("coverage evidence did not pass", "coverage-evidence", "required test/docstring evidence") — i.e. specifically a coverage-only gate, not a general check failure — and it is materially more cautious: it requires coverage AND Strix evidence to both be independently "complete", requires no other failed checks, and enforces a same-head retry-floor timer (coverage_retry_wait_reason) before re-dispatching, with its own auto-merge-disarm messaging at each step.

These match different marker strings in the review body and neither supersedes the other — a given blocked review is either coverage-gated or check-gated, not both, and both recovery paths are real, independently-tested features (is_non_authoritative_coverage_check_run / coverage_evidence_indices / superseded_coverage_evidence_indices, this PR's other contribution against a stale coverage placeholder masking real dispatch failures, had already auto-merged with no conflict — main didn't touch that code).

Resolution: kept this PR's coverage_ready block byte-for-byte (every branch inside it already returns). When coverage_ready is False, added a check_gated_retry_ready check using main's can_retry_check_gated_opencode_review() with its exact original conditions; only when neither retry path applies does the code fall through to the shared block/disarm-auto-merge path (kept with this PR's conflict_suffix merge-guidance enhancement, which is purely additive to the message text and doesn't change main's control flow when it returns "block").

Test evidence (Python 3.12 venv — this repo's actual CI target)

  • PYTHONPATH=. python -m pytest tests/test_pr_review_merge_scheduler.py -q159 passed (both mechanisms' existing regression tests pass unmodified, confirming neither path regressed the other)
  • PYTHONPATH=. python -m pytest tests -q1931 passed, 1 skipped, 21 subtests passed
  • coverage run -m pytest tests -q && coverage report100% on scripts/ci/pr_review_merge_scheduler.py (1,914 stmts / 806 branches, including the new check_gated_retry_ready branch) and 100% total across scripts/ci/** (10,129 stmts / 4,018 branches)
  • interrogate -c pyproject.toml .PASSED (100.0%)
  • python -m py_compile scripts/ci/pr_review_merge_scheduler.py and git diff --check → clean

Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

Comment thread scripts/ci/pr_review_merge_scheduler.py
Comment on lines 1739 to 1744
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")

@devin-ai-integration devin-ai-integration Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Coverage retry keeps sibling blockers

ignore_opencode suppresses only OpenCode’s prior result. Failed sibling jobs and unrelated status contexts still block the same-head retry.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

reviews(last: 100) in the shared GraphQL fragment and the REST fallback's
single per_page=100 page both silently truncate a PR's review history once
it accumulates more than 100 review events. An early independent APPROVED
review can fall out of that window, permanently blinding
has_independent_current_head_approval (and its sibling exact-head-review
consumers) even though GitHub still has the review.

Call-graph investigation showed the bulk queue-scan path (fetch_open_prs /
fetch_open_prs_rest) feeds merge decisions directly -- main() calls
inspect_pr on those PR nodes without ever re-fetching a single PR when the
scheduler runs its push-triggered or org-queue-sweep sweeps (no
--pr-number) -- so both the bulk and single-PR fetch paths needed the fix,
not just fetch_pr/fetch_pr_rest.

- GraphQL: add pageInfo to the shared reviews connection and a new
  PR_REVIEWS_PAGE_QUERY that walks backward (last/before) past the initial
  window; complete_all_pr_reviews backfills any PR whose first page reports
  hasPreviousPage, called from both fetch_open_prs and fetch_pr.
- REST: rest_pr_node (shared by fetch_open_prs_rest and fetch_pr_rest) now
  calls fetch_all_pr_reviews_rest, which loops page=1,2,... until a short
  page ends the history.
- Both paginators propagate any page-fetch failure (fail closed) and bound
  their loop (MAX_REVIEW_PAGINATION_PAGES for GraphQL; REST terminates on a
  short/empty page) against a pathological pageInfo loop.

Adds regression tests for both fetch paths' pagination, ordering, and
fail-closed behavior on a page-fetch failure, plus an end-to-end test
reproducing the reported bug (a genuine independent APPROVED review at
position 1 of 106 reviews, invisible before the fix and visible after).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX

Copy link
Copy Markdown
Contributor Author

Fixed: PR review data was silently truncated past 100 reviews in both fetch paths

The bug (as confirmed)

scripts/ci/pr_review_merge_scheduler.py fetched pr["reviews"]["nodes"] from two places, both capped at 100:

  1. GraphQLPULL_REQUEST_FIELDS_FRAGMENT declared reviews(last: 100) { nodes {...} }, reused by both OPEN_PRS_QUERY (bulk queue scan) and PR_BY_NUMBER_QUERY (single-PR fetch). Neither paginated past that window.
  2. REST fallbackrest_pr_node() called .../reviews?per_page=100 once, never looping.

Once a PR passed 100 total review events, an early genuine independent APPROVED review could fall out of the fetched window and has_independent_current_head_approval (and its siblings: current_head_review_state, has_current_head_approval, has_current_head_changes_requested, latest_current_head_coverage_change_request, stale_opencode_change_request_ids, stale_opencode_approval_ids) would never see it again — silently blocking auto-merge forever.

Call-graph finding (step 1)

I traced main()'s dispatch: fetch_pr(...) is used only when --pr-number is passed; otherwise fetch_open_prs(...) (bulk scan) is used, and its PR nodes go directly into inspect_pr()has_current_head_approval / merge_approval_block_reason with no re-fetch of a single PR in between.

Checking .github/workflows/pr-review-merge-scheduler.yml, the push trigger (to main/develop/master) and the org-queue-sweep job both invoke the scheduler without --pr-number — i.e. real merge decisions are made directly off the unpaginated bulk-fetch data, not just used for coarse filtering before a full re-fetch. So the "minimal fix" hypothesis (only fetch_pr/fetch_pr_rest) does not hold — both the bulk and single-PR fetch paths needed the fix. Fixed accordingly.

What changed

  • GraphQL: added pageInfo { hasPreviousPage startCursor } to the shared reviews connection, plus a new PR_REVIEWS_PAGE_QUERY that walks backward (last: 100, before: $cursor) past the initial window. complete_paginated_pr_reviews/complete_all_pr_reviews backfill any PR whose first page reports hasPreviousPage, merging pages so the result stays oldest-first (the order every reversed(...) consumer in this module expects). Wired into both fetch_open_prs and fetch_pr.
  • REST: rest_pr_node (shared by fetch_open_prs_rest and fetch_pr_rest) now calls a new fetch_all_pr_reviews_rest, which loops page=1,2,3,... until a page shorter than 100 rows confirms the end of history.
  • Fail-closed: both paginators let a page-fetch failure propagate (no partial-data fallback), matching existing single-page behavior.
  • Bounded: GraphQL side has MAX_REVIEW_PAGINATION_PAGES = 500 as a defensive cap against a pathological hasPreviousPage loop; REST terminates naturally on a short/empty page.
  • No other decision logic changed — this only widens the review data existing consumers see.

Regression tests added (tests/test_pr_review_merge_scheduler.py)

New tests cover: multi-page GraphQL merge ordering, pagination skipped for PRs that don't need it, missing-startCursor failure, page-fetch-failure propagation (GraphQL and REST), the pathological-loop bound, REST pagination past 100 (including the full-page-then-empty-page edge case), and wiring through both fetch_open_prs and fetch_pr.

The key end-to-end regression test (test_fetch_pr_pagination_recovers_independent_approval_past_100_reviews) builds a synthetic 106-review history: one genuine independent-reviewer APPROVED review at chronological position 1, followed by 105 bot COMMENTED reviews that fill a last: 100 page and push the real approval into the second (older) page.

Before/after evidence — I stashed only the production-code change (kept the new tests), ran the suite, and confirmed the expected failure mode:

assert len(pr["reviews"]["nodes"]) == 106
AssertionError: assert 100 == 106

i.e. pre-fix, fetch_pr returned only the newest 100 reviews and the genuine early approval was invisible (13 new tests failed for this reason, all against the unmodified fetch/pagination functions). After restoring the fix, the same test passes and has_independent_current_head_approval(pr) returns True with the full 106-node history.

Full verification (from the fix commit, in an isolated worktree)

python3 -m pytest tests/ -q
  → 1945 passed, 1 skipped, 21 subtests passed

coverage run -m pytest tests && coverage report --show-missing
  → scripts/ci/pr_review_merge_scheduler.py   100%
  → TOTAL                                     100%

interrogate
  → RESULT: PASSED (minimum: 100.0%, actual: 100.0%)

python3 -m ruff check scripts/ci/pr_review_merge_scheduler.py tests/test_pr_review_merge_scheduler.py
  → 6 findings, identical (same messages/rules, only shifted line numbers) to the
    pre-fix base commit 189ce625 — confirmed by diffing ruff output against the
    unmodified files; zero new findings introduced.

git diff --check
  → clean

Pushed as 2139aec1 on codex/scheduler-independent-exact-head-approval-20260824 (base was 189ce625, confirmed unmoved immediately before push).


Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 5 new potential issues.

Devin Review

@devin-ai-integration devin-ai-integration Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: REST fallback remains fail-closed

The REST path assigns REVIEW_REQUIRED because it lacks authoritative aggregate approval. GraphQL outages therefore pause merges rather than weakening authorization.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread scripts/ci/pr_review_merge_scheduler.py
Comment thread scripts/ci/pr_review_merge_scheduler.py Outdated
Comment on lines +835 to +836
nodes = list(page.get("nodes") or []) + nodes
page_info = page.get("pageInfo") or {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Pagination preserves review ordering

Older GraphQL pages are prepended to the newest window. Reverse scans still evaluate each reviewer’s latest exact-head policy state first.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1473 to +1475
seen_reviewers.add(reviewer)
if state == "APPROVED":
return True

@devin-ai-integration devin-ai-integration Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Revocations suppress earlier approvals

A reviewer’s latest exact-head dismissal or change request enters seen_reviewers without authorizing. Older approvals from that reviewer cannot resurface.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Two bugs found by review on this PR's independent-approval gate:

- latest_check_runs() treated a check run with a null startedAt as
  unconditionally older than a predecessor that already has a
  timestamp. GitHub leaves startedAt null while a check is QUEUED, so
  a freshly dispatched coverage-evidence rerun could lose to a stale,
  already-completed run of the same (workflow, name) key, letting
  coverage_evidence_state() report "complete" from the wrong run.
  Fall back to the check run's own pending status (the same predicate
  running_check_state() already uses) to break the tie only when a
  timestamp comparison is impossible, while a node with no timestamp
  and no pending status still defers to the timestamped predecessor.

- inspect_pr() only evaluated merge_approval_block_reason() when
  current_head_approved was already True, so a PR with autoMergeRequest
  armed from before an unreviewed push, and an outdated branch, fell
  into the branch-update wait path with the auto-merge request left
  queued. Once the updated head's required checks passed, GitHub's own
  native auto-merge could complete the merge without this scheduler
  ever requiring a fresh independent approval on the new head. The
  behind-by branch now disarms auto-merge immediately whenever it is
  armed without a live current-head approval, instead of preserving it
  through the branch update.

Regression tests added for both; existing tests that asserted the
buggy "auto-merge already enabled ... remains queued" outcome for
unapproved PRs were updated to the corrected fail-closed contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit (731e427b) on top of 2139aec1 fixing both bugs Devin flagged on this head.

Bug A — queued coverage rerun losing to a stale completed run

latest_check_runs() compared check runs with (started_at or epoch, index), so a freshly created rerun that is still QUEUED (GitHub leaves CheckRun.startedAt null until a check actually starts) could never win a tie-break against an already-completed predecessor of the same (workflow, name) key, regardless of index. I confirmed via GitHub's public GraphQL schema (CheckRun type) that there is no queryable check-run creation-time field to use instead — only startedAt/completedAt exist — so the fix uses the check run's own pending status (the same running_check_state() predicate already used elsewhere) to break the tie only when a timestamp comparison is impossible: a node with a null startedAt and a still-pending status (QUEUED/IN_PROGRESS/etc.) now supersedes a timestamped predecessor in the same key; a node with a null startedAt and no pending status (e.g. cancelled before it started) still defers to the timestamped predecessor, preserving existing behavior for that case.

New regression test test_coverage_evidence_state_prefers_queued_rerun_over_stale_completed_run:

  • Before fix: fails — latest_check_runs() returns the older COMPLETED/SUCCESS node instead of the newer QUEUED one (assert 'COMPLETED' == 'QUEUED').
  • After fix: passes — the queued rerun is authoritative and coverage_evidence_state() reports "running", not the stale "complete".

The existing tests that specifically pin "time, not rollup order" behavior when both runs have real timestamps (test_coverage_evidence_state_prefers_newest_rerun, test_coverage_evidence_state_prefers_newest_run_across_workflows) and the ones pinning "a null-timestamp duplicate with no pending status doesn't override a timestamped one" (test_failed_status_checks_prefers_timestamped_duplicate_check_runs) still pass unchanged — the fix only changes behavior in the specific null-timestamp-with-pending-status case.

Bug B — unapproved PRs retaining a stale auto-merge arm through the branch-update path

approval_reason = merge_approval_block_reason(pr) if current_head_approved else None meant the if approval_reason and auto_merge_enabled: disarm check was a no-op whenever current_head_approved was False — e.g. right after an unreviewed push invalidates OpenCode's exact-head approval. When the branch was also behind base, inspect_pr() fell into if behind_by and (current_head_approved or auto_merge_enabled): and took the request_branch_update(...) path with suffix = "; existing auto-merge request remains queued" — literally documenting that it left a stale, unapproved auto-merge request armed. Once the updated head's required checks passed, GitHub's own native auto-merge could complete the merge without this scheduler ever getting a chance to require a fresh independent approval on the new head, defeating this PR's entire purpose.

Fix: inside that behind_by branch, if current_head_approved is False (the only way to reach that branch with it false is auto_merge_enabled being True), the scheduler now calls disable_auto_merge_decision(...) immediately instead of requesting a branch update — before ever touching update_branches/branch_update_allowed, since disarming a stale authorization is a safety action, not a branch mutation subject to the update-limit/flag. All the other already-correct unconditional disarm points in inspect_pr() (unresolved threads, current-head changes-requested, DIRTY/CONFLICTING, action_required checks, UNKNOWN mergeability, and the final "no approval" fallback) were left untouched — they already disarm regardless of approval state and were not part of this gap.

New regression test test_outdated_unapproved_branch_disarms_stale_auto_merge_instead_of_updating:

  • Before fix: fails — assert 'update_branch' == 'disable_auto_merge'.
  • After fix: passes — the PR is disarmed with reason "auto-merge disabled; branch is 1 commit(s) behind base (GitHub mergeability is BEHIND) with no live current-head approval to authorize auto-merge; obtain fresh independent approval before re-enabling auto-merge".

I also updated the existing tests in test_inspect_pr_blocks_and_waits_for_policy_states and test_main_limits_review_dispatches_and_branch_updates that asserted the old "auto-merge already enabled ... update_branch ... existing auto-merge request remains queued" outcome for unapproved PRs (they now assert disable_auto_merge); the sibling scenarios in the same tests that have a genuine, live, independent current-head approval (merge_approved_reviews()) are unchanged, since those already correctly keep auto-merge armed while requesting the branch update.

Verification

From /tmp/pr1270-fix2 (isolated worktree off 2139aec1, remote unchanged when I started and when I pushed):

python3 -m pytest tests/ -q
→ 1947 passed, 1 skipped, 21 subtests passed

coverage run -m pytest tests && coverage report --show-missing
→ scripts/ci/pr_review_merge_scheduler.py  100% (1952 stmts, 820 branches)
→ TOTAL  100%

interrogate
→ RESULT: PASSED (minimum: 100.0%, actual: 100.0%)

python3 -m ruff check scripts/ci/pr_review_merge_scheduler.py tests/test_pr_review_merge_scheduler.py
→ 6 pre-existing findings, identical before and after this change (confirmed by diffing ruff output against the unmodified 2139aec1 head) — no new findings introduced

git diff --check
→ clean

tests/test_pr_review_merge_scheduler.py alone: 175 passed (174 before, +2 new regression tests, with 1 pre-existing test's fixtures adjusted to keep exercising the branch-update-limit path it was written for, now using a genuinely-approved PR instead of an unapproved one that this fix now correctly disarms).


Generated by Claude Code

devin-ai-integration[bot]

This comment was marked as resolved.

…scade

inspect_pr() only checked pr.get("autoMergeRequest") in the merge_state ==
"UNKNOWN" branch and in a final catch-all reached once every wait/dispatch
branch above it had nothing left to do. Once current_head_approved was False
and the branch was not behind base (so neither behind_by disarm path
applied), execution could fall through the OpenCode-running wait, the
workflow_run deterministic-fallback wait, the stale-OpenCode retry dispatch,
and the ordinary Strix/OpenCode dispatch cascade -- the everyday state for a
PR between or during reviews -- and return a plain wait/dispatch decision
without ever disarming a stale auto-merge request. If GitHub's own required
checks do not themselves gate on this scheduler's OpenCode approval, GitHub's
native auto-merge could complete the merge before the scheduler's next run
ever reached the catch-all.

Hoist a single unconditional check -- `not current_head_approved and
auto_merge_enabled` -- right after the behind_by and last-push-approval-
restamp gates (both of which only apply when current_head_approved is True,
so this hoist cannot affect the already-approved path) and before any of
those wait/dispatch branches. It disarms with the same reason text the old
catch-all used, which is now dead code for the unapproved case and has been
removed (kept for the merge_state == "UNKNOWN" branch's own, still-reachable
approved+armed case).

Added a regression test reproducing the gap (a CLEAN, unapproved, auto-merge-
armed PR with no Strix evidence previously returned "security_dispatch" with
auto-merge left queued; now returns "disable_auto_merge"). Updated the
existing unapproved+UNKNOWN-mergeability test to the new, more accurate
"no OpenCode approval" reason, and added a new approved+UNKNOWN+armed test to
keep that branch's own check covered now that the unapproved case no longer
reaches it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX

Copy link
Copy Markdown
Contributor Author

Fixed the remaining gap Devin flagged after the previous fix on this PR ("although a separate clean-PR path remains affected").

The unguarded code path

In inspect_pr() (scripts/ci/pr_review_merge_scheduler.py), once current_head_approved is False and the branch is not behind base (behind_by falsy, so neither behind_by-based disarm block applies), execution could fall through several wait/dispatch branches that never checked pr.get("autoMergeRequest") before returning:

  • the OpenCode-running wait (opencode_state == "running")
  • the workflow_run deterministic-fallback wait
  • the stale-OpenCode retry dispatch (opencode_state == "stale")
  • the ordinary Strix/OpenCode dispatch cascade under trigger_reviews (missing/running Strix evidence, OpenCode dispatch) — the everyday state for a PR between or during reviews

Only a final catch-all, reached solely once every one of those branches had nothing left to do, checked and disarmed auto-merge. So an unapproved, CLEAN-mergeability PR with auto-merge armed (from a stale/invalidated prior approval, or armed early by a human before any review ran) that was actively going through its normal review cycle would return a plain security_dispatch/wait decision with auto-merge left queued. If GitHub's own required checks don't themselves gate on this scheduler's OpenCode approval, GitHub's native auto-merge could complete the merge before the scheduler's next run ever reached the catch-all — the exact bypass this PR exists to prevent.

The fix

Hoisted a single unconditional check, if not current_head_approved and auto_merge_enabled:, immediately after the behind_by gate and the last-push-approval restamp check (both of which only fire when current_head_approved is True, so the hoist cannot affect the already-approved path) and before any of the wait/dispatch branches above. It calls the same disable_auto_merge_decision(...) the rest of the file already uses, with the same reason text the old catch-all used ("current head has no OpenCode approval; wait for fresh same-head approval before re-enabling auto-merge"). Consistent with the existing behind_by disarm path and the doctoring doc ("an already armed auto-merge request is disabled when authorization is absent"), no distinction is drawn between "armed before any review ran" and "armed on a now-stale approval" — the scheduler disarms unconditionally in both cases, matching precedent already in the file.

The now-unreachable check in the final catch-all was removed (it is dead code once the hoist runs first for every unapproved+armed case at that point in the function) to avoid an uncovered branch under the 100%-coverage gate. The merge_state == "UNKNOWN" branch's own auto-merge check is untouched and remains live for its one still-reachable case: an approved PR whose mergeability is still being calculated (verified with a new test, see below).

Regression test evidence

Before the fix (reproduced first):

FAILED tests/test_pr_review_merge_scheduler.py::test_clean_unapproved_armed_pr_disarms_auto_merge_before_review_dispatch
AssertionError: assert 'security_dispatch' == 'disable_auto_merge'

A CLEAN, unapproved PR with autoMergeRequest armed and no Strix evidence returned security_dispatch, leaving auto-merge queued.

After the fix: same test now passes — the scheduler returns disable_auto_merge instead.

Also added test_approved_unknown_mergeability_disarms_auto_merge_pending_evaluation to keep the merge_state == "UNKNOWN" branch's approved-only auto-merge check covered now that the unapproved case no longer reaches it, and updated the existing unapproved+UNKNOWN-mergeability assertion in test_inspect_pr_blocks_and_waits_for_policy_states to the new, more accurate "current head has no OpenCode approval" reason text (the action, disable_auto_merge, is unchanged).

Full verification (from the fix commit)

python3 -m pytest tests/ -q
→ 1949 passed, 1 skipped, 21 subtests passed

coverage run -m pytest tests && coverage report --show-missing
→ scripts/ci/pr_review_merge_scheduler.py: 1952 stmts, 0 missed, 100%
→ TOTAL: 10167 stmts, 0 missed, 100%

interrogate
→ RESULT: PASSED (minimum: 100.0%, actual: 100.0%)

python3 -m ruff check scripts/ci/pr_review_merge_scheduler.py tests/test_pr_review_merge_scheduler.py
→ 6 pre-existing findings, all unrelated to this diff and present on the unmodified branch head (verified via git stash)

git diff --check
→ clean

Pushed as 6370b320 on top of 731e427b.


Generated by Claude Code

devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

latest_coverage_evidence_index() picked the newest coverage-evidence
check run across workflow names using a naive timestamp max(), which
had the same flaw latest_check_runs() was just fixed for: GitHub
leaves CheckRun.startedAt null while a check is QUEUED, so a freshly
dispatched coverage-evidence rerun in one workflow could lose to an
older, already-completed coverage-evidence run in a *different*
workflow, letting coverage_evidence_state() report stale "complete"
status while the real, currently-relevant rerun was still pending.

Extract the recency rule latest_check_runs() uses (defer to the check
run's own pending status via running_check_state() only when a
timestamp comparison is impossible) into a shared check_run_supersedes()
helper, and fold latest_coverage_evidence_index()'s candidates through
it instead of using max() with a pure-timestamp key. latest_check_runs()
itself is unchanged in behavior -- it now just calls the extracted
helper -- so its existing tests continue to pass unmodified.

Added a regression test mirroring the existing same-workflow queued-
rerun test, but across two different workflow names ("Required
OpenCode Review" completed older vs. "OpenCode Review Dispatch" queued
newer with startedAt: null); it fails against the prior implementation
and passes after the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX

Copy link
Copy Markdown
Contributor Author

Fixed: cross-workflow coverage-evidence rerun ordering (commit 39f4ae65)

Fixed the sibling of the latest_check_runs() queued-check-ordering bug (731e427b), this time in latest_coverage_evidence_index().

The bug

latest_coverage_evidence_index() picked the newest coverage-evidence check run across workflow names using max(coverage_indices, key=lambda item: (parse_github_datetime(...) or datetime.min, item)). GitHub leaves CheckRun.startedAt null while a check is QUEUED — that null timestamp sorted as datetime.min (oldest possible), so a freshly dispatched coverage-evidence rerun in one workflow could lose the max() comparison to an older, already-completed coverage-evidence run in a different workflow. Downstream, coverage_evidence_state(pr) would then report stale "complete" from the wrong run instead of "running", potentially authorizing an OpenCode redispatch decision (coverage_retry_wait_reason) before the real, current coverage result was known. Exact same class of bug as the just-landed latest_check_runs() fix — just triggered across workflow names instead of within one (workflow, name) key.

The fix

Mirrors the earlier fix's design exactly, via a shared helper rather than a duplicated inline check:

  • Extracted the recency rule latest_check_runs() introduced (defer to the check run's own pending status via running_check_state() only when a timestamp comparison is impossible, otherwise compare timestamps, with index as the final tiebreaker) into a new check_run_supersedes(started_at, node, index, previous_started_at, previous_index) -> bool helper.
  • latest_check_runs() now calls this helper instead of the inline three-branch comparison it previously had — no behavioral change, so its existing tests pass unmodified.
  • latest_coverage_evidence_index() now folds its candidate indices through the same helper (replacing the max()-with-timestamp-key implementation), so a genuinely newer but still-QUEUED coverage-evidence run in one workflow correctly outranks an older completed run in a different workflow, while a null-timestamp entry with no pending status still falls back to timestamp ordering.

Regression test (reproduced first, then fixed)

Added test_coverage_evidence_state_prefers_queued_rerun_over_stale_completed_run_across_workflows, sibling to the existing same-workflow test_coverage_evidence_state_prefers_queued_rerun_over_stale_completed_run: an older, completed SUCCESS coverage-evidence run from "Required OpenCode Review" vs. a newer QUEUED (startedAt: null) coverage-evidence run from "OpenCode Review Dispatch" (both workflow-name conventions already used elsewhere in this test file).

  • Before the fix: test failed — assert check_runs[latest_index]["status"] == "QUEUED"AssertionError: assert 'COMPLETED' == 'QUEUED'.
  • After the fix: test passes — the queued run is correctly selected as authoritative and coverage_evidence_state(pr) == "running".

Full verification (from the isolated worktree, on top of 6370b320)

python3 -m pytest tests/ -q
  1950 passed, 1 skipped, 21 subtests passed

coverage run -m pytest tests -q && coverage report
  scripts/ci/pr_review_merge_scheduler.py   1959 stmts, 0 missed, 822 branches, 0 partial  100%
  TOTAL                                     10174 stmts, 0 missed, 4034 branches, 0 partial  100%

interrogate
  RESULT: PASSED (minimum: 100.0%, actual: 100.0%)

python3 -m ruff check scripts/ci/pr_review_merge_scheduler.py tests/test_pr_review_merge_scheduler.py
  6 pre-existing findings, unchanged in count/content before and after this diff (verified via git stash);
  none introduced by this change

git diff --check
  clean

Pushed to codex/scheduler-independent-exact-head-approval-20260824 at 39f4ae65 (on top of 6370b320).


Generated by Claude Code

devin-ai-integration[bot]

This comment was marked as resolved.

claude added 2 commits August 30, 2026 17:43
…ffix

CodeRabbit flagged has_independent_current_head_approval's
reviewer.endswith("[bot]") check as unreliable because GitHub's GraphQL
API can omit the "[bot]" suffix from a bot actor's login (unlike REST,
which reliably appends it). Verified empirically against this org's own
PR #1270: the GraphQL-backed pull_request_read get_review_comments path
returned "coderabbitai" and "devin-ai-integration" (no suffix) for the
same accounts REST's get_reviews returned as "coderabbitai[bot]" and
"devin-ai-integration[bot]". A GraphQL bot review whose login happens to
omit the suffix could therefore count as an "independent" human approval
in this exact separation-of-duties gate.

Add __typename to the author field selection in both review-fetching
GraphQL queries, and add is_bot_review_author() to exclude a review
whenever its login ends with "[bot]" OR its author __typename is "Bot" --
keeping the suffix check for REST reviews, which never carry __typename.

Reproduced first: the new tests failed against the prior code (missing
__typename in the fragment/query, and an AttributeError for the
not-yet-added helper) before the fix, and pass after it.
latest_opencode_dispatch_started_at called
active_workflow_runs(dispatch_repo, ("completed",)) with no filter, which
paginates every completed run ever recorded in the central dispatch
repository via `gh api --paginate --slurp` -- verified by reading
active_workflow_runs itself, which has no depth limit and applies all
matching (event, title prefix, exact head SHA) client-side after the
fetch. Since that repository's completed-run count only grows, this
call site (reachable from the live inspect_pr path via
coverage_retry_wait_reason, not dead code) could page through hundreds
of runs per inspected PR and exhaust the REST rate limit; a resulting
RuntimeError there specifically makes coverage_retry_wait_reason report
"same-head OpenCode dispatch history is unavailable" and stall retries.

GitHub's "List workflow runs for a repository" REST endpoint supports
both `event` and `created` query parameters. Give active_workflow_runs
optional event/created kwargs that add matching `-f` query parameters
(existing callers that omit them keep the prior unfiltered request), and
have latest_opencode_dispatch_started_at accept an optional `since` bound
-- passed by its only caller as the coverage-request review's own
submittedAt, since a dispatch created at or before that timestamp can
never become the returned maximum. This narrows the REST query itself
instead of changing which run is returned.

Regression tests assert the constructed gh api args carry
`event=repository_dispatch` and the `created=>=...` lower bound, that
`since` forwards into `active_workflow_runs`, and that callers omitting
the new kwargs still request the unfiltered query.

Copy link
Copy Markdown
Contributor Author

Triaged and fixed 2 more CodeRabbit findings on top of 39f4ae65. Both confirmed real; commits 92d3afad and 7ac1ed3b (pushed to this branch, current head 7ac1ed3b).

Finding 1 — GraphQL bot actors can omit the [bot] login suffix — FIXED

Empirical verification (this environment's gh/GraphQL access turned out to be sandbox-restricted to a pinned operation set, so I verified against this org's own real data instead via the GitHub MCP tools):

Called pull_request_read on this PR (#1270) through two different API surfaces for the same known bot accounts:

  • get_reviews (REST, user.login): "login":"coderabbitai[bot]", "login":"devin-ai-integration[bot]" — suffix present, as expected.
  • get_review_comments (GraphQL-backed — thread IDs are PRRT_..., GraphQL-only node IDs): the same accounts came back as "author":"coderabbitai" and "author":"devin-ai-integration"no [bot] suffix.

That's a direct, live confirmation of CodeRabbit's claim using real production data from this repo, not a hypothetical. has_independent_current_head_approval's reviewer.endswith("[bot]") check (added in an earlier fix on this same PR, commit b748ccbf, for a different CodeRabbit finding) is therefore incomplete: a GraphQL-fetched bot review whose login happens to omit the suffix would count as an "independent" human approval — an authorization-bypass-adjacent bug in the exact separation-of-duties gate this PR exists to add.

Fix:

  • Added __typename to the author field selection in both review-fetching GraphQL queries (PULL_REQUEST_FIELDS_FRAGMENT's reviews block and PR_REVIEWS_PAGE_QUERY).
  • Added is_bot_review_author(): excludes a review when its login ends with [bot] or its author.__typename == "Bot". Kept the suffix check (REST's rest_review_node never sets __typename, so REST reviews still rely on it).
  • Wired is_bot_review_author() into has_independent_current_head_approval in place of the bare suffix check.

Before/after regression evidence: New tests (test_scheduler_review_queries_request_bot_typename, test_independent_approval_excludes_graphql_bot_actor_missing_suffix, test_is_bot_review_author_covers_suffix_and_typename_and_neither) were run against the pre-fix code first and failed as expected (missing __typename in the fragment/query; AttributeError: module has no attribute 'is_bot_review_author'). After the fix, all three pass.

Finding 2 — unbounded pagination in the same-head OpenCode dispatch-history lookup — FIXED

Verification: Read active_workflow_runs — it calls gh api ... --paginate --slurp with no depth limit and no event/created query params; the one place a caller narrows results is entirely client-side, after the full fetch. latest_opencode_dispatch_started_at calls it as active_workflow_runs(dispatch_repo, ("completed",)) (line ~2503 pre-fix) with zero filtering, then matches on event, a display-title prefix, and exact head SHA — all client-side. Confirmed this is live, reachable code: coverage_retry_wait_reasonlatest_opencode_dispatch_started_at is called directly from inspect_pr's main scheduler path (not dead/test-only), and a RuntimeError there does produce exactly the "same-head OpenCode dispatch history is unavailable" wait reason CodeRabbit described. GitHub's "List workflow runs for a repository" REST endpoint does document both event and created query parameters.

Fix:

  • active_workflow_runs gained optional keyword-only event/created params that add matching -f query args to the gh api call. Omitted by default, so every other caller (there are 4 call sites total) keeps its exact prior unfiltered request — only this one call site needed bounding.
  • latest_opencode_dispatch_started_at gained an optional since: datetime | None param; when given, it requests event=repository_dispatch and created=>=<since> server-side. Its only caller (coverage_retry_wait_reason) passes the coverage-request review's own submittedAt as since — this is exact, not a heuristic window: a dispatch run created at or before that timestamp can never become the function's returned maximum (the caller only acts on dispatch_started_at > retry_anchor, and retry_anchor starts at that same submittedAt), so narrowing the query this way cannot change which run is selected.

Before/after regression evidence: New tests assert the constructed gh api args list contains event=repository_dispatch and created=>=2026-08-24T00:00:00Z for a bounded call, that omitting the new kwargs produces no event=/created= args at all (unfiltered callers unaffected), and that since forwards correctly from latest_opencode_dispatch_started_at into active_workflow_runs. Three pre-existing tests that monkeypatched the old 2-arg/3-arg signatures were updated to accept the new keyword-only params (no behavioral change to those tests, just signature compatibility).

Full verification (from the fixed worktree, both commits applied)

python3 -m pytest tests/ -q            → 1957 passed, 1 skipped, 21 subtests passed
coverage run -m pytest tests -q        → 1957 passed, 1 skipped, 21 subtests passed
coverage report --show-missing         → scripts/ci/pr_review_merge_scheduler.py: 1969 stmts / 828 branches, 100%; TOTAL 100%
interrogate                            → RESULT: PASSED (minimum: 100.0%, actual: 100.0%)
ruff check scripts/ci/pr_review_merge_scheduler.py tests/test_pr_review_merge_scheduler.py
                                        → 6 findings, all pre-existing on unmodified 39f4ae65 (confirmed by diffing
                                          finding locations against the original file — only line numbers shifted);
                                          zero new findings introduced
git diff --check                       → clean

Both findings were real; no false positives to report this time.


Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 3 new potential issues.

Devin Review

Comment thread scripts/ci/pr_review_merge_scheduler.py Outdated
Comment on lines +1595 to +1605
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"

@devin-ai-integration devin-ai-integration Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Placeholder exclusion is narrowly scoped

Central mode ignores only Required OpenCode Review’s coverage-evidence job. Dispatch evidence and failed sibling jobs remain authoritative.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread scripts/ci/pr_review_merge_scheduler.py
check_run_supersedes was a pairwise "does B supersede A" predicate folded
left-to-right across candidates. That is only valid when the relation 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 unconditionally beat a
null-timestamp current-best -- even when that later candidate was itself
older than whichever run the queued candidate had already displaced.

Replace the pairwise fold with a single derived recency key
(check_run_recency_key) per candidate: no-signal < timestamped (ranked by
timestamp) < pending-with-no-timestamp-yet, ties broken by index. Comparing
keys directly via Python tuple ordering is a valid total order by
construction, so latest_check_runs and latest_coverage_evidence_index now
pick the correct newest run regardless of candidate count or order.

Adds regression tests for both scenarios a bot reviewer identified on this
PR: a 3-candidate fold (completed@02:00, queued(null), completed@01:00)
that previously settled on the stale 01:00 run, and the queued candidate
appearing before the completed one it should outrank.

Copy link
Copy Markdown
Contributor Author

Fixed the non-transitive coverage-evidence fold Devin flagged

Confirmed the bug by reading the current code (as of 7ac1ed3b) and reproducing it before touching anything.

Root cause

check_run_supersedes(started_at, node, index, previous_started_at, previous_index) was a pairwise "does the new candidate beat the current best" predicate. latest_check_runs folded it per (workflow, name) key (fine — same-key reruns only ever have 2 live candidates in practice), but latest_coverage_evidence_index folded the same predicate left-to-right across every coverage-evidence check run regardless of workflow name, treating a non-transitive relation as if it were a valid total order.

The specific asymmetry: when the running "best so far" had no startedAt (a genuinely QUEUED rerun), the branch if previous_started_at is None and started_at is not None: return True let any subsequently-folded timestamped candidate override it unconditionally — without checking whether that untimed "best" was itself a presumed-newest pending run. So:

  • (1) completed@02:00 → best = 02:00
  • (2) queued(startedAt=null) supersedes 02:00 (correct in isolation — a pending rerun is presumed newer than anything already resolved) → best = queued
  • (3) completed@01:00 unconditionally supersedes the untimed "best" (queued) purely because it has a real timestamp → best = 01:00

Final answer: the stale 01:00 run — neither the actual newest timestamped run (02:00) nor the correctly-pending run. Reproduced both of Devin's named scenarios exactly (see below).

Fix

Replaced the pairwise fold with a single derived recency key, check_run_recency_key(node, started_at, index) -> (bucket, timestamp_or_epoch, index):

  • bucket 0 — no startedAt and not currently pending (e.g. cancelled before it started): no recency signal, sorts lowest.
  • bucket 1 — a real startedAt: ranked by that timestamp.
  • bucket 2 — no startedAt yet but actively pending (running_check_state(node) == "running", i.e. QUEUED/IN_PROGRESS/etc): GitHub only ever creates such a row after any run it might supersede, so it's presumed newer than every already-resolved run, regardless of that run's timestamp.
  • Ties within a bucket fall back to the later index (same tie-break the old code documented).

latest_check_runs now keeps (recency_key, node) per key and replaces the stored value only when the new key is >= the stored one. latest_coverage_evidence_index now just does max(coverage_indices, key=lambda i: check_run_recency_key(...)).

Why this is provably transitive: Python tuple comparison is a valid total order (lexicographic on </>=), and each candidate's key depends only on that candidate's own fields — never on "whatever happens to be the current best." That eliminates the exact failure mode above: there is no intermediate "best so far" state to lose information about (e.g. forgetting that the current best was itself a presumed-newest pending run once a later candidate is compared against it). max()/sorted() over these keys is order-independent and count-independent by construction — it is not an ad hoc pairwise rule being trusted to compose correctly across N candidates, it is a real order relation.

Regression tests added (both of Devin's named scenarios)

  1. test_latest_coverage_evidence_index_stays_transitive_across_three_candidates — exact 3-candidate order Devin described: completed@02:00, queued(null), completed@01:00 across three different workflow names. Asserts the stale 01:00 run never wins, and specifically that the queued run wins (consistent with the existing, already-locked-in precedent that a pending rerun is presumed newer than any already-resolved run — see test_coverage_evidence_state_prefers_queued_rerun_over_stale_completed_run_across_workflows).
  2. test_latest_coverage_evidence_index_prefers_queued_when_it_appears_first — the queued node placed before the completed node, verifying the queued-first ordering doesn't independently misbehave.

Before the fix (confirmed by running the new tests against the unfixed code): both failed —

  • Scenario 1: latest_index == 2 (the stale 01:00 run) instead of the queued run → AssertionError: the stale 01:00 completed run must never win the fold.
  • Scenario 2: check_runs[latest_index]["status"] == "COMPLETED" instead of "QUEUED".

After the fix: both pass, along with every pre-existing test.

Verification

python3 -m pytest tests/ -q
→ 1959 passed, 1 skipped, 21 subtests passed
coverage run -m pytest tests && coverage report --show-missing
→ scripts/ci/pr_review_merge_scheduler.py  1958 stmts  0 missing  822 branches  0 missing  100% cover
→ TOTAL 100%
interrogate
→ RESULT: PASSED (minimum: 100.0%, actual: 100.0%)
git diff --check
→ clean (no whitespace errors)

python3 -m ruff check scripts/ci/pr_review_merge_scheduler.py tests/test_pr_review_merge_scheduler.py reports 6 findings (unrelated shebang/import-sort/style nits), but I verified by stashing my change and re-running against the unmodified branch tip (7ac1ed3b) that these are the identical 6 pre-existing findings, none touching the lines I changed — there's no ruff.toml/pyproject.toml [tool.ruff] config in this repo and no CI workflow invokes ruff check, so this isn't one of the repo's enforced gates (those are pytest/coverage/interrogate per .github/CLAUDE.md). Left untouched to avoid unrelated diff churn against a file with exact-string contract tests.

Also confirmed check_run_supersedes had no other callers or doc references anywhere in the repo before removing it.

Pushed as 7ac1ed3b..e655e5ce on this branch (no force-push; branch was unchanged from 7ac1ed3b at push time).


Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

Comment thread scripts/ci/pr_review_merge_scheduler.py
Comment on lines 3140 to +3152
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"
),
)
)

@devin-ai-integration devin-ai-integration Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Approval loss disarms queued merges

Missing aggregate or independent approval disables existing auto-merge before later wait paths return. Unapproved heads receive the same protection before review dispatch.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…merge

Devin Review flagged a TOCTOU race on PR #1270: 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 (which can be many seconds to low-minutes, since this
scheduler processes many PRs and dispatches several API calls per PR), the
merge proceeds on authorization that no longer holds. The existing
--match-head-commit guard only protects against the *commit* changing in that
window; it does nothing to protect against the *review state* changing on the
identical commit.

Add revalidate_current_head_approval(repo, pr), which re-fetches the PR via
the existing fetch_pr() helper and recomputes the exact same decision
(has_current_head_approval + merge_approval_block_reason, the same helpers
used for the original snapshot) from the fresh data. It fails closed on any
re-fetch error or on the PR no longer being returned (closed/inaccessible),
and also catches the head moving between snapshot and re-check as a defense
in depth ahead of GitHub's own --match-head-commit guard.

Call it, via a small revalidate_before_merge() closure inside inspect_pr(),
immediately before each of the four merge_pr()/enable_auto_merge() call sites
(both the merge_state == "CLEAN" fast path and the other-mergeable-state
path each have a direct/direct_or_auto branch and a plain "auto" branch) --
not once upfront. If the fresh re-check no longer authorizes the merge, an
already-queued auto-merge request is disarmed via the existing
disable_auto_merge_decision() (never left queued for GitHub's own native
auto-merge to complete unsupervised); otherwise the scheduler waits. dry_run
inspection never mutates anything, so it skips the extra re-fetch entirely --
preserving every existing dry-run-based test in this file unmodified.

TDD: added tests/test_pr_review_merge_scheduler.py coverage that first
reproduces the race (mocking fetch_pr to return a freshly-revoked snapshot
between the initial approved snapshot and the mutating call) and confirms it
fails against the pre-fix code -- the scheduler would still call
merge_pr()/enable_auto_merge() despite the revocation -- then confirms the
fix blocks it across all four call sites (CLEAN and BLOCKED mergeability x
direct/direct_or_auto/auto merge modes), confirms a still-valid re-check lets
the merge proceed normally, confirms a re-check API failure fails closed
without merging, and confirms dry-run skips the extra fetch. Full suite:
1975 passed, 1 skipped; coverage 100% on scripts/ci; interrogate 100%; no new
ruff findings (diffed against the pre-fix commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX

Copy link
Copy Markdown
Contributor Author

Fixed: revoked approval could still authorize merge (TOCTOU) — commit 80c7f382

Devin Review flagged a security-severity race on this head: a same-head approval revoked after approval_reason is computed can still authorize a merge.

The bug (confirmed by reading inspect_pr() in full)

current_head_approved = has_current_head_approval(pr) and approval_reason = merge_approval_block_reason(pr) if current_head_approved else None are computed once, early in inspect_pr(), from the GraphQL/REST pr snapshot this scheduler invocation fetched at the start of its run (fetch_open_prs/fetch_pr, called well before inspect_pr in main()). I traced every downstream path and found four mutating call sites that still consume that stale approval_reason/current_head_approved pair much later in the same function call:

  1. merge_state == "CLEAN" fast path → merge_pr(...) (direct/direct_or_auto)
  2. same fast path → enable_auto_merge(...) (plain auto mode)
  3. the other-mergeable-state path → merge_pr(...) (direct_or_auto)
  4. same path → enable_auto_merge(...) (plain auto mode)

Each site already has if approval_reason: return decide("wait", approval_reason) guarding it — but that check reads the same upfront value. If the independent reviewer dismisses/revokes their exact-head approval (or GitHub otherwise recomputes reviewDecision) in the window between the initial snapshot and the mutating call — realistically many seconds to low-minutes, since this scheduler processes many PRs and makes several API calls per PR — approval_reason is still None from the stale read, so the merge proceeds on authorization that no longer holds. The --match-head-commit guard those mutations already carry only protects against the commit changing in that window; it does nothing for the review state changing on the identical commit.

The fix

Added revalidate_current_head_approval(repo, pr): re-fetches the PR via the existing fetch_pr() helper and recomputes the exact same decision using the exact same helpers as the original snapshot (has_current_head_approval + merge_approval_block_reason — no duplicated logic). It fails closed on a re-fetch error, on the PR no longer being returned (closed/inaccessible), and — as defense in depth ahead of --match-head-commit — if the head itself moved between snapshot and re-check.

A small revalidate_before_merge() closure inside inspect_pr() calls it (skipped entirely when dry_run — nothing mutates in dry-run, so the extra fetch buys nothing and every existing dry-run-based test in this file keeps working unmodified) and is invoked immediately before each of the four call sites above, not once upfront. If the fresh re-check no longer authorizes the merge: an already-queued auto-merge request is disarmed via the existing disable_auto_merge_decision(...) (never left queued for GitHub's own native auto-merge to complete unsupervised); otherwise the scheduler just waits with the fresh reason. This composes with, and does not replace, the SHA guard — both must hold at mutation time now, not just at snapshot time.

TDD (reproduced first, confirmed failing pre-fix)

Stashed only the production-code change and ran the new tests against unmodified e655e5ce:

FAILED test_inspect_pr_direct_merge_blocked_when_approval_revoked_before_merge
FAILED test_inspect_pr_direct_or_auto_merge_blocked_when_approval_revoked_before_merge
FAILED test_inspect_pr_auto_merge_blocked_when_approval_revoked_before_enable
FAILED test_inspect_pr_disables_queued_auto_merge_when_approval_revoked_before_merge
FAILED test_inspect_pr_blocked_direct_or_auto_merge_blocked_when_approval_revoked_before_merge
FAILED test_inspect_pr_blocked_auto_merge_blocked_when_approval_revoked_before_enable
FAILED test_inspect_pr_direct_merge_proceeds_when_revalidation_confirms_approval
FAILED test_inspect_pr_fails_closed_when_revalidation_refetch_errors
FAILED test_revalidate_current_head_approval_* (7 unit tests — attribute doesn't exist yet)

i.e. pre-fix, mocking fetch_pr to return a freshly-revoked snapshot between the approved snapshot and the mutating call confirmed the scheduler would still call merge_pr/enable_auto_merge despite the revocation, across all four call sites (both CLEAN and BLOCKED mergeability × direct/direct_or_auto/auto merge modes). Restoring the fix: all pass.

New coverage also confirms: a still-valid re-check lets the merge proceed normally (fetch called, merge_pr called); a re-check API failure fails closed without merging; dry_run=True never pays for the extra fetch (asserted fetch_pr uncalled across every existing dry-run merge/auto_merge assertion in this file).

Full verification (isolated worktree, base e655e5ce, fetched immediately before push to confirm nothing else had moved the branch)

PYTHONPATH=. python3 -m pytest tests -q
→ 1975 passed, 1 skipped, 21 subtests passed

coverage run -m pytest tests && coverage report --show-missing
→ scripts/ci/pr_review_merge_scheduler.py   1998 stmts / 844 branches   100%
→ TOTAL                                     10213 stmts / 4056 branches 100%

interrogate
→ RESULT: PASSED (minimum: 100.0%, actual: 100.0%)

ruff check .
→ 1 finding (unused `typing.Any` import in an unrelated test file), byte-for-byte
  identical output on the unmodified base commit e655e5ce (confirmed via git stash) —
  zero new findings introduced by this diff

git diff --check
→ clean

No CHANGELOG.md/docs/doctoring entry was added, matching this session's established pattern for the last 6 rounds on this PR (2139aec1 through e655e5ce) — none of those touched CHANGELOG.md or docs/.

Pushed as 80c7f382 on codex/scheduler-independent-exact-head-approval-20260824 (fast-forward from e655e5ce; branch confirmed unmoved immediately before push).


Generated by Claude Code

devin-ai-integration[bot]

This comment was marked as resolved.

check_run_recency_key gave a completed check run with no startedAt
(GitHub's shape for "cancelled before it ever started") the lowest
tier unconditionally, below any row with a real startedAt -- so an
older, already-successful check run could outrank a newer rerun that
was cancelled before starting, and the scheduler could merge on a
stale success (Devin BUG finding on PR #1270).

Fix: prefer checkSuite.createdAt over startedAt as the recency
signal. GitHub creates the check suite unconditionally the instant
the triggering push/rerun/dispatch fires, strictly before any of its
check runs can be queued, start, or be cancelled before starting, and
CheckSuite.createdAt is non-nullable in GitHub's schema -- so it is
always available, unlike startedAt. This is a general signal, not a
special case for conclusion == "cancelled": every check run is now
ranked by the earliest-available real creation timestamp, falling
back to the old startedAt/pending-tier heuristic only when
checkSuite.createdAt is absent (kept for backward compatibility with
existing fixtures/tests that predate this field).

Note: GraphQL CheckRun/CheckSuite.databaseId (Int) was considered and
rejected -- verified against GitHub's public schema and a live query
that today's check-run/check-suite database ids are ~10^11, well past
the 32-bit Int range those fields are typed as (the exact reason
GitHub has been migrating other databaseId fields to
fullDatabaseId: BigInt), so it cannot be trusted as an always-present
signal. checkSuite.createdAt is a real DateTime field with no such
limit.

Also fetches createdAt in the GraphQL check-run fragment, and adds a
parity REST fallback path (one extra check-suites-for-ref call,
joined by check_suite.id) so rest_check_node carries the same signal
as the GraphQL path.

Tests: two new regressions reproduce Devin's scenario (a) through
latest_check_runs and latest_coverage_evidence_index respectively
(confirmed failing pre-fix), and a third locks in scenario (b) --
a newer completed rerun still outranks an older queued run once
checkSuite.createdAt is available, so the fix doesn't regress the
existing "queued presumed newest" fallback behavior. All existing
check_run_recency_key/latest_check_runs/latest_coverage_evidence_index
tests (including the round-6 transitivity regressions) pass
unmodified in intent.

Full suite: 1978 passed, 1 skipped. coverage: 100% on scripts/ci.
interrogate: 100% docstrings. ruff: no new findings (the one
pre-existing unrelated F401 in
tests/test_repository_branch_coverage_javascript_and_noema.py,
already confirmed unrelated in round 7, is present identically on
base commit 80c7f38).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX

Copy link
Copy Markdown
Contributor Author

Fixed: canceled reruns inheriting stale success — commit 63cd827f

Devin Review flagged a BUG on this head: check_run_recency_key (added earlier this session, e655e5ce, to fix the non-transitive coverage-evidence fold) gave a completed-with-no-startedAt row the lowest tier unconditionally — but GitHub reports a check run as completed/cancelled with startedAt: null when a queued rerun is canceled before it ever starts, not only for rows that never mattered. So a newer rerun canceled before starting could always lose to an older, already-completed SUCCESS row of the same (workflow, name) — the scheduler could merge on a stale success.

Confirmed and reproduced first

Reproduced both of Devin's named scenarios against pre-fix code before changing anything:

  • older SUCCESS@startedAt vs. newer canceled-before-start (startedAt: null) → pre-fix picks the stale SUCCESS (bug confirmed).
  • older QUEUED vs. newer completed rerun → pre-fix picks the older QUEUED (the "queued presumed newest" heuristic also has no real chronology to fall back on).

What I ruled out first

Devin's fix framing suggested "fetch a reliable creation or sequence signal." I checked what's actually queryable rather than assuming:

  • GraphQL CheckRun has no createdAt (verified against GitHub's public schema) — only startedAt/completedAt.
  • CheckRun.databaseId / CheckSuite.databaseId (Int) looked promising as a sequence signal, but I verified against a live query on this repo that real check-run/check-suite ids are already ~10^11 — well past the 32-bit Int range those fields are typed as. That's the exact reason GitHub has been migrating other databaseId: Int fields (PullRequest, Issue, ProjectV2*, etc.) to fullDatabaseId: BigInt in this same schema. CheckRun/CheckSuite haven't gotten that migration yet, so databaseId can't be trusted as an "always present" signal here — rejected.

The fix

CheckSuite.createdAt is a real, non-nullable DateTime! field, and CheckRun.checkSuite is itself non-nullable — so it's always populated, for every check run, regardless of how it resolved. GitHub sets it unconditionally the instant the triggering push/rerun/dispatch creates the check suite, strictly before any check run inside it can be queued, start, or be canceled before starting. check_run_recency_key now prefers checkSuite.createdAt over startedAt as the recency timestamp, falling back to the old startedAt/pending-tier heuristic only when a payload omits it (kept for backward compatibility with existing fixtures). This is a general fix, not a special case for conclusion == "cancelled": every check run is ranked by the earliest-available real creation timestamp.

Added createdAt to the GraphQL check-run fragment (checkSuite { createdAt ... }), and kept the REST fallback in parity: rest_pr_node now also fetches commits/{sha}/check-suites and joins each check run's checkSuite.createdAt by check_suite.id, threaded through rest_check_node.

Test evidence

  • test_failed_status_checks_treats_cancelled_before_start_rerun_as_authoritative (same-(workflow, name) case, via latest_check_runs) and test_latest_coverage_evidence_index_ranks_cancelled_before_start_rerun_above_stale_success (cross-workflow case, via latest_coverage_evidence_index) — both confirmed failing against pre-fix code, passing post-fix.
  • test_latest_coverage_evidence_index_prefers_newer_completed_run_over_older_queued_run locks in Devin's second scenario: with checkSuite.createdAt available, a genuinely newer completed rerun still outranks an older queued run (no regression of the "queued presumed newest" fallback).
  • All existing check_run_recency_key/latest_check_runs/latest_coverage_evidence_index tests — including the round-6 transitivity regressions (test_latest_coverage_evidence_index_stays_transitive_across_three_candidates, test_latest_coverage_evidence_index_prefers_queued_when_it_appears_first) — pass unmodified, since they don't set checkSuite.createdAt and hit the preserved fallback path unchanged.
  • test_rest_pr_fallback_shapes_reviews_and_checks updated for the new REST call and asserted checkSuite.createdAt in its output.

Full verification (worktree, post-fix)

  • PYTHONPATH=. python3 -m pytest tests -q → 1978 passed, 1 skipped, 21 subtests passed.
  • coverage run -m pytest tests && coverage report → 100% on scripts/ci (statements + branches).
  • interrogate → 100% docstrings.
  • git diff --check → clean.
  • ruff check . → no new findings; the one pre-existing F401 in tests/test_repository_branch_coverage_javascript_and_noema.py (confirmed unrelated in round 7) is identical on base commit 80c7f382.

Pushed as a fast-forward commit on top of 80c7f382 (fetched immediately before pushing; branch hadn't moved).


Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +3076 to +3083
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Fresh approval guards merge mutations

Each merge branch re-fetches approval before mutation. The fresh check covers head movement, OpenCode revocation, aggregate policy, and independent approval.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants