fix(standards-sync-stuck-automerge-alert): split the stuck-PR scan into a cheap page fetch and a per-candidate merge-state probe with retry#234
Conversation
…to a cheap page fetch and a per-candidate merge-state probe with retry The single-page query selected mergeStateStatus across a 50-node page, which forces an on-demand mergeability computation per PR server-side and reliably returns an opaque GraphQL error, failing every scheduled run. Page fetches now select only the fields needed to identify armed, past-threshold bot PRs; mergeStateStatus is fetched one candidate at a time. Every GraphQL call retries with exponential backoff so a transient server error no longer aborts the scan and falsely clears the tracking issue; a persistent failure still propagates and fails the run loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
…e two-phase scan tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
|
Claude finished @kyle-sexton's task in 2m 41s —— View job PR Review
SummaryThis is a solid fix to a real operational problem. The two-phase split is the right architectural move: removing CorrectnessRetry logic — the off-by-one is correct: for (let attempt = 1; ; attempt += 1) {
try {
return await github.graphql(query, variables);
} catch (error) {
if (attempt >= retryAttempts) throw error; // throws on attempt 4 (with retryAttempts=4)
...
await sleep(delayMs);
}
}With False all-clear prevention is structurally sound: Candidate scoping — Phase-2 probes are sequential — One minor observation — the // .github/workflows/standards-sync-stuck-automerge-alert.yml, line 141–142
const retryAttempts = Number(process.env.GRAPHQL_RETRY_ATTEMPTS ?? 4);
const retryBaseMs = Number(process.env.GRAPHQL_RETRY_BASE_MS ?? 1000);are dead code in the Actions runtime: both vars are declared in the step's Security / CLAUDE.md alignmentTrigger surface: unchanged — still SHA pins: no Permissions: no changes to the job-level Log hygiene — the new // line 159
core.info(`GraphQL ${description} failed (attempt ${attempt}/${retryAttempts}): ${error.message}; retrying in ${delayMs}ms.`);logs GraphQL variables: queries use parameterized Test coverageThe new tests directly match the invariants the PR claims to establish:
The One note on the "queries every manifest-derived target repository" test: it maps all VerdictApprove. The fix is correct, secure, well-tested, and CLAUDE.md-compliant. The one remaining acceptance criterion from the PR description (≥2 consecutive green scheduled runs against the real fleet) is appropriately flagged as post-merge follow-through. |
|
Claude finished @kyle-sexton's task in 2m 16s —— View job Security Review
Scope: SummaryNo critical or important security issues found. The change is security-positive overall: the split-phase design reduces the attack surface of each GraphQL call, and the false-clear protection is correctly preserved — One low-confidence suggestion follows. SUGGESTION —
|
| Check | Result |
|---|---|
on: trigger surface |
Unchanged; no dangerous pull_request_target or workflow_run with secrets introduced |
permissions: blocks |
Unchanged; narrowest-needed grants preserved |
New uses: entries |
None introduced |
Script injection via ${{ }} in run: |
New env vars GRAPHQL_RETRY_ATTEMPTS/GRAPHQL_RETRY_BASE_MS are literal string constants, not expressions over user-controlled context |
| GraphQL query construction | Both listQuery and mergeStateQuery use parameterized typed variables ($repo: String!, $number: Int!); no interpolation into query text |
| False-clear invariant | core.setOutput('stuck-count', ...) is only reached after the full scan loop; a throw anywhere in the loop leaves the output unset, so the close-issue branch is unreachable on failure |
| Token scope | permission-pull-requests: read App token unchanged; issue-write token scope unchanged |
GRAPHQL_RETRY_ATTEMPTS as attack surface |
Set to the literal '4' in step env:, not derived from any workflow inputs: or context expression; callers cannot tune it to trigger unbounded retry loops |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2ed4293b87
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
AI reply (work-loop lane). Review-finding classification:
|
…om the per-PR probe to close the page-vs-probe race
Auto-merge can be disarmed or re-armed in the interval between the phase-1 page
fetch and the phase-2 merge-state probe, so trusting the page's
autoMergeRequest.enabledAt could report a disarmed PR as stuck or report a stale
armed duration for a freshly re-armed one. The probe now also selects
autoMergeRequest { enabledAt }; the stuck decision re-checks armed state and the
threshold against that fresh value and computes armedHoursAgo from it, skipping a
PR that is no longer armed or is now armed under the threshold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
|
Claude finished @kyle-sexton's task in 1m 37s —— View job Review of TOCTOU fix commit (
|
| Test | Scenario | Assert |
|---|---|---|
disarmed between page fetch and probe |
probeOverrides: { 8: { enabledAt: null } } |
stuck-count === '0', report === null |
duration computed from probe-fresh enabledAt |
page says 20h, probe says 6h | report matches /| 6h |/, not /20h/ |
re-armed under threshold between page and probe |
page says 20h (over), probe says 1h (under) | stuck-count === '0', report === null |
The "enabledAt" in override check (not just override.enabledAt) is the right way to distinguish "explicitly set to null" from "not present" — correctly handles the disarm case where null is a meaningful value.
No regressions introduced
isArmedCandidatebehavior is unchanged — the refactor toarmedHoursSinceis equivalent to the previous inline arithmetic.BLOCKEDcheck remains first in the probe post-processing, so non-blocked PRs still skip without touchingenabledAt.armedHoursAgo: Math.floor(armedHours)is computed from the samearmedHoursvariable used for the threshold check — single source of truth.- False-all-clear prevention and retry logic are untouched.
Verdict
Approve. The fix is correct, complete, and well-tested. All three TOCTOU scenarios are covered, the helper extraction is a genuine DRY improvement (not incidental cleanup), and the probeOverrides harness mechanism is clean without being over-engineered. No issues found.
|
Claude finished @kyle-sexton's task in 3m 23s —— View job Security Review
Scope: No critical or important security findings. TOCTOU fix verification (07f706e)The codex P2 finding is correctly resolved. The phase-2 probe now fetches // https://github.com/melodic-software/ci-workflows/blob/07f706eb6e9a101a032ba310372c842c66139cfd/.github/workflows/standards-sync-stuck-automerge-alert.yml#L249-L253
const pullRequest = result.repository?.pullRequest;
if (pullRequest?.mergeStateStatus !== 'BLOCKED') continue;
const armedHours = armedHoursSince(pullRequest.autoMergeRequest?.enabledAt);
if (armedHours === null || armedHours < thresholdHours) continue;
stuck.push({ repo, number: candidate.number, url: candidate.url, armedHoursAgo: Math.floor(armedHours) });The phase-1 value ( New code introduced —
|
| Field | Type | Source | Used as |
|---|---|---|---|
candidate.number |
Int! (GraphQL typed) |
GitHub API | GraphQL $number variable in phase-2 probe |
candidate.url |
string | GitHub API | Cosmetic display link in Markdown report |
Neither field is string-interpolated into query text. candidate.number is passed as a typed GraphQL variable ($number: Int!). No injection path.
The stuck array entries contain only hardcoded strings, GitHub-API-sourced integers and URLs, and Math.floor(armedHours) (integer). The Markdown report wraps repo in backticks and uses integer values elsewhere. No injection surface in the report body.
Other checks (clean)
| Check | Result |
|---|---|
on: trigger surface |
Unchanged; workflow_call only |
permissions: |
Unchanged; contents: read on the job |
New uses: entries |
None |
${{ }} expressions in run: |
None new; MANIFEST env-var pattern (pre-existing, correct) |
| GraphQL query construction | Both queries use parameterized typed variables; only first: ${PAGE_SIZE} (numeric constant 50) is interpolated |
| False-clear invariant | core.setOutput('stuck-count', ...) is only reached after the full scan loop; a throw anywhere leaves the output unset |
| Retry-amplified false-clear | graphqlWithRetry rethrows on exhaustion — the throw propagates, scan step fails, close-issue branch is unreachable |
candidates array scope |
Declared inside for (const repo of repoNames) — resets per-repo, no cross-repo leakage |
No security issues to flag. The change is security-neutral to positive: the split-phase design reduces each query's attack surface, the TOCTOU fix is correctly scoped to probe-fresh data only, and the retry wrapper introduces no new caller-reachable surface.
…ody gate (#240) ## Summary Plugin-created PRs in this repo trip the `pr-issue-linkage` gate because the source-control plugin's portable default PR body scaffolds only `## Summary` and `## Test plan`, omitting the `## Related` section this repo requires. This declares the team-tracked `pr_body_required_sections` key in `.claude/source-control.md` — the plugin's designed per-repo seam (`reference/config-resolution.md`) — so `/source-control:pull-request create` both drafts a `## Related` section and pre-checks it before `gh pr create`. The key is a **closed list** that replaces the plugin's default wholesale, so the portable `Summary` / `Test plan` sections are re-declared alongside `Related`. The closing-keyword / `No linked issue` half of the gate is a separate, independent mechanism the plugin already satisfies natively (its create logic always emits a `Closes #N` line or a `No related issue:` opt-out marker); it is not expressible through this heading-only key, so no attempt is made to encode it here. ## Test plan - No executable behavior changes; this is a plugin-config data file. - This PR's own body is the live verification: it carries `## Summary`, `## Test plan`, and a non-empty `## Related` section plus the `No linked issue` marker, so the `pr-issue-linkage / pr-issue-linkage` required check must pass on it. ## Related No linked issue. This closes no GitHub issue; it is a convention-config change. It exists because the gate previously tripped on plugin-composed bodies: PR #234 in this repo and dotfiles #301 in a sibling repo both failed the same closing-keyword-plus-`## Related` validation on creation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0169XBydnqC5S6bkHDz1TDwL Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Closes #226
Summary
number/url/author/autoMergeRequest { enabledAt }(nomergeStateStatus), phase 2 probesmergeStateStatusone PR at a time viarepository.pullRequest(number:), and only for armed, past-threshold bot PRs.graphqlWithRetry— exponential backoff (GRAPHQL_RETRY_ATTEMPTS=4,GRAPHQL_RETRY_BASE_MS=1000, both step-env-tunable), rethrowing the last error on exhaustion so a persistent failure still fails the run loudly. On a throw thestuck-countoutput is never set, so the close-issue branch cannot fire a false all-clear.MAX_PAGESsoundness guard unchanged; "stuck" semantics are preserved (theBLOCKEDcheck moved from the bulk page filter to the per-candidate probe).uses:SHA pins, or permissions blocks.Test plan
node --test .github/scripts/*.test.cjs(CI's exact command): 257/257 pass, including 24 instandards-sync-stuck-automerge-alert.test.cjs— new coverage asserts the page query never selectsmergeStateStatus, exactly one phase-2 probe per candidate (no bulk fan-out), transient-error retry on both page fetch and probe, and persistent-error loud failure with no false all-clear.actionlinton the changed workflow: clean.biome check(CI config/scope): clean.Related
🤖 Generated with Claude Code