Skip to content

feat(pr-risk): grade a pull request by number on demand (workflow_dispatch) - #113

Merged
mattmillerai merged 4 commits into
mainfrom
matt/pr-risk-dispatch-by-number
Aug 3, 2026
Merged

feat(pr-risk): grade a pull request by number on demand (workflow_dispatch)#113
mattmillerai merged 4 commits into
mainfrom
matt/pr-risk-dispatch-by-number

Conversation

@mattmillerai

@mattmillerai mattmillerai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

ELI5

The risk grader could only ever grade the PR that just poked it. So a repo that turns the grader on today has thirty already-open PRs it can never label — each one has to be poked individually, and poking one means running that repo's entire CI. This adds a button: name a PR number (or a list) and it grades those, no poke required. With no number supplied, nothing about the old path changes.

Motivation

Two live gaps, both of them in the label-the-open-queue product rather than the offline calibration corpus:

  1. A repo that enrolls mid-stream cannot grade the queue it already has. The first consumer enrollment left exactly one PR ever graded — its own — and 30 others unlabeled until each independently receives a synchronize/reopened/ready_for_review. Generating those events costs ~750 jobs on 2-vCPU runners for 30 advisory labels, because six workflows in that repo share the same event types. An empty-commit push costs the same and pollutes 30 branches' history.
  2. There is no manual re-grade. After a .github/risk.json change, or on a PR carrying the human-owned risk-dispute label, the only way to re-grade was to push a commit to the PR.

The grading logic needed no change to support this: it was already an API read keyed on a PR number, never a read of the event payload. The event coupling was two lines of workflow YAML.

Provenance

  • Authored by: agent session, worktree off origin/main at 1c9443a
  • Verified: actionlint clean on both workflows; YAML parses; shellcheck -x clean on all six shell files; test_grade_pr_risk.sh 49 passed / 0 failed and untouched; test_apply_risk_label.sh 24 passed / 0 failed; new test_grade_targets.sh 91 passed / 0 failed. (The review round moved the last two: the label script gained 404-tolerance on a concurrent stale-label DELETE, and the targets suite grew from 68 to 91 phases — see the review-resolution commit.) Plus a live read-only run of the new orchestration against real open PRs (details under Test plan).
  • Deviations: the --stdin mode is not the batch vehicle — see Notes.
  • Review round (facd005): ten panel findings resolved — the ref/path are now percent-encoded into the contents
    read (a raw # in a branch name truncated it into the silent default-branch read this PR exists to prevent), a
    404 for the ref is distinguished from a 404 for the file, the pre-grader reads retry a transient failure like
    the grader already does, the step summary no longer renders a grade table for a target whose label write failed
    and no longer discards rows when the grade step is cancelled mid-batch, the start gate is the job's deadline
    rather than the wait budget, and sourcing the script no longer replaces the sourcing shell's EXIT trap.

Reviewer context

  • Type: feature — additive optional inputs on the reusable, plus a refactor that moves the per-target sequence out of the job body into a script
  • Slots into: the advisory pr-risk shadow pilot. The label remains the entire product; nothing gates, routes, or merges
  • Blast radius: consumers pin full SHAs, so nobody moves until they bump their own pin. The backward-compatibility claim is asserted against the stubbed gh call log, not stated — see Test plan

Summary

  • Two optional workflow_call inputs. pr_number grades one PR; pr_numbers grades a comma-separated list and wins when both are set. PR_NUMBERS resolves to pr_numbers || pr_number || github.event.pull_request.number, so the event path is the same single number it always was. Both are string, not number: workflow_dispatch inputs arrive as strings, and an empty string is what lets the fall-through stay one expression.
  • The base ref is re-read from the API per target, and an unresolvable one FAILS that target. This is the load-bearing half. The ref selects which branch's .github/risk.json judges the PR, and an empty ?ref= is not an error to the contents API — it silently resolves to the default branch. Defaulting it would grade a PR against another branch's rules, which is the same failure the existing non-404 guard exists to prevent, arriving by a different door. It is not hypothetical: of five open PRs sampled on the pilot repo, two are stacked on feature branches.
  • The event path spends no extra API call. When a base ref arrives from the payload it is used as-is. A supplied number, though, ignores it — the target may be a different PR than the event's.
  • Batch never abandons the rest for one bad target. Every target is attempted and its outcome recorded, then the run reports whether any failed. A target the job's time budget cannot reach is reported by number as not attempted, rather than started and cut off; a run cancelled mid-label is the one outcome with no signal at all, because the summary step never renders either.
  • The per-target sequence moves into scripts/pr-risk/grade-targets.sh. It is per-target either way, so leaving it inline meant a second copy of the settle poll and the override contract for the by-number path. One copy — and it is unit-testable for the first time, which is what lets the claims above be tests rather than assertions.
  • ungraded still keeps the run green, on both paths. An unreadable PR is a reported verdict, not a broken run.
  • Bot-authored and fork PRs grade on the dispatch path. The actor and fork clauses in a caller's if: are token guards — a bot's or a fork's pull_request run gets a read-only GITHUB_TOKEN, so the label write would 403 — and neither applies when a human presses the button. Fork risk is untouched: external still comes from the API's own isCrossRepository, never the actor, and still grades R3.
  • The header's copy-paste caller block is corrected for both event shapes, and this is not cosmetic — the live consumer caller trips both traps today:
    • Its if: includes github.event.pull_request.head.repo.full_name == github.repository, which is false on workflow_dispatch, so the job would skip silently — no run, no error, no annotation. Guard clauses are now scoped behind github.event_name != 'pull_request'.
    • Its concurrency group is ${{ github.workflow }}-${{ github.event.pull_request.number }}, which collapses to one constant group on dispatch, so with cancel-in-progress a batch would have each dispatch cancel the previous. The key now carries the resolved target.
    • A caller-side skip is undetectable from inside the reusable (no run is created), so the header is the only lever. What the reusable can do, and now does, is refuse to be a silent no-op once reached: a run resolving no target fails loudly naming both inputs.
  • wait_for_checks_minutes on a backfill: measured rather than assumed. A dispatched run's own check attaches to the dispatched ref, not the PR's head commit, so it is absent from the graded PR's rollup and a settled PR reads its true state on the first poll — grading real tiers, not the R2 floor that 0 produces on the event path. It is still not free: 0 breaks out after a single read, ahead of the "require a settled reading to repeat" confirmation, so a target pushed to minutes ago lands the honest R2. 1 costs one 15s backoff plus a second read per PR. The header, the input description and the README all say prefer 1, not 0.

Notes

  • --stdin is not the batch vehicle, despite being the obvious candidate. It grades pre-collected scorecard records fed to the grading jq — it performs no API read and cannot turn a PR number into a record. Batch is therefore a loop in the orchestration layer, where the per-target base ref and per-target override fetch have to live anyway.
  • Two retry constants gained env overrides (MAX_UNREADABLE_TRIES, READ_RETRY_BUDGET_SECONDS, plus POLL_DELAY_SECONDS) purely so the suite can reach the unreadable-PR branch without sleeping through the production backoff. CI passes none of them, so the defaults are what runs.
  • The target list is capped at 50 and rejects non-numeric, leading-zero and duplicate entries. Leading zeros are rejected rather than normalised: GitHub resolves 007 to 7, so tolerating it would let 7,007 present as two targets for one PR and label it twice.
  • test-pr-risk.yml's path filter now includes pr-risk.yml, which passes the scripts their whole input contract through env and could otherwise break them without touching a file under scripts/pr-risk/.

Test plan

Verified by unit test (hermetic — gh stubbed on PATH, every call logged so the tests assert on which requests were made):

  • The event path issues no base-ref PR read and fetches the override from the payload's ref — the backward-compat claim, asserted against the call log
  • The by-number path resolves the base ref from the API and reads both override files from that ref, never from an empty one
  • An unresolvable base ref, and an empty one, fail their target having read no override and never invoked the grader
  • A non-404 override read fails the target with no tier invented and no label applied
  • A batch of three whose middle target is unreadable still grades the first and last
  • ungraded keeps the run green; a failed label write fails its target
  • Forks grade R3 from the API-derived fork flag; bot-authored PRs grade at all
  • A run id absent from the rollup (the dispatch shape) excludes nothing, so a settled rollup reads SUCCESS and reversibility is not floored at R2
  • Empty / non-numeric / leading-zero / over-long target lists are refused with exit 2, not silently partially graded
  • The grader suite passes untouched (49). The label suite grew by 3 (21 -> 24) in the review round, for the
    one behaviour change there: a stale-label DELETE that 404s is the removal loop's goal state, not a failure

Verified live, read-only, against real open PRs on the pilot repo (DRY_RUN=1, so no label was written):

  • Batch of 1094,1086,999999: both real PRs graded R1; #1086's override was read from its feature-branch base matt/be-5532-cql-flat-pattern-costs, not main — the load-bearing behaviour, against the real API
  • The nonexistent 999999 failed at the base-ref hop and did not abandon the batch; the run reported 2 graded, 0 ungraded, 1 failed
  • Cost at wait_for_checks_minutes: 1 is 15s per PR, matching the predicted single-backoff-plus-confirmation
  • Grading a settled PR with a run id absent from its rollup yields checks_state=SUCCESS, pending=false and a real R1 — the empirical basis for the low-wait recommendation

Not verified until this is merged and a caller pins it — stated plainly rather than claimed:

  • A true workflow_dispatch end-to-end run. It needs a consumer caller pinned to a SHA of this branch, which is awkward pre-merge; the reusable is only reachable through a caller's uses:
  • The label write on the dispatch path. Locally it was dry-run, and the claim that a dispatch's GITHUB_TOKEN is writable where a bot's pull_request token is not is reasoned from GitHub's documented behaviour, not observed here
  • That a real dispatch's check run is genuinely absent from the graded PR's rollup. The unit test proves the grader handles a foreign run id correctly, which is the same code path, but the attachment behaviour itself is GitHub's

After merge, the natural first exercise is the backfill this was built for: bump the pilot consumer's caller to the new SHA, add the workflow_dispatch block from the header, and dispatch its open queue with wait_for_checks_minutes: 1. Running that backfill is deliberately a separate operational step, not part of this PR.

The grader could only ever grade the PR whose event triggered it, so a repo
enrolling mid-stream had no way to grade the open queue it already had, and a
re-grade after a risk-map change or a `risk-dispute` meant pushing a commit.

The grading logic needed no change: it was already an API read keyed on a PR
number rather than a read of the event payload. Two optional `workflow_call`
inputs expose that — `pr_number` for one PR and `pr_numbers` for a
comma-separated list — and with neither supplied the target, the base ref and
every emitted label are what they were before.

The per-target sequence (resolve the base ref, fetch that ref's override files,
poll the grader until the rest of the rollup settles, sync the one label) moves
out of the job body into scripts/pr-risk/grade-targets.sh. It is per-target
either way, so leaving it inline meant a second copy of the settle poll and the
override contract for the by-number path; as a script there is one copy, and it
is unit-testable for the first time.

Four things the by-number path has to get right:

* The base ref is re-read from the API per target, because there is no event
  payload to take it from, and an unresolvable one FAILS that target. An empty
  ref is not an error to the contents API — it silently resolves to the default
  branch — so defaulting it would grade a PR against another branch's rules.
  Live PRs are commonly stacked on feature branches, not the default one.
* A batch never abandons the rest for one bad target: every target is attempted
  and recorded, then the run reports whether any failed. A target the job's time
  budget cannot reach is reported by number as not attempted, rather than
  started and cut off.
* Bot-authored and fork PRs are graded here. The actor and fork clauses in a
  caller's `if:` are token guards — a bot's or fork's `pull_request` run gets a
  read-only token, so the label write would 403 — and neither applies on a
  dispatch. Fork risk is untouched: `external` still comes from the API's own
  fork flag and still grades R3.
* A run that resolves no target fails loudly instead of exiting 0. A dispatch
  button that silently grades nothing is worse than no button.

The header's copy-paste caller block is updated for both event shapes: guard
clauses scoped to the event they describe (an unscoped fork clause is false on a
dispatch, so the job would skip silently) and a concurrency key carrying the
resolved target (an event-only key collapses to one constant group, so a batch
would have each dispatch cancel the previous one).
…h isolation

Hermetic, in the shape of the existing suites: `gh` is stubbed on PATH and every
call it receives is logged, so the tests assert on WHICH requests were made and
not only on the outcome. 68 assertions across 14 phases.

The two that matter most are the ones that fail silently in production:

* The event path spends no extra API call. With a base ref supplied from the
  payload, no PR read is issued and the override is fetched from that ref — the
  backward-compatibility claim, asserted against the call log rather than stated.
* The by-number path reads the override from the target's OWN base ref, and an
  unresolvable or empty one fails that target having read no override at all.
  Both hazards are invisible when they regress: the wrong `.github/risk.json`
  still produces a confident-looking tier.

Also pinned: a batch where the middle target is unreadable still grades the
first and last; `ungraded` keeps the run green on both paths; a failed label
write fails its target; forks grade R3 and bots grade at all; a foreign run id
excludes nothing from the rollup, which is the dispatch shape, so a settled PR
reads its true SUCCESS instead of the R2 floor; and the target list rejects
non-numeric, leading-zero, over-long and empty inputs rather than quietly
grading a subset.

The two retry constants gain env overrides so the suite can reach the
unreadable-PR branch without sleeping through the production backoff. CI passes
neither, so the defaults are what runs.

test-pr-risk.yml gains the new suite and shellchecks the new files. Its path
filter now includes pr-risk.yml, which passes the scripts their whole input
contract through env and could otherwise break them without touching a file
under scripts/pr-risk/.
The README is the other half of the header for anyone enrolling a repo, so it
carries the same three consequences of the by-number path: bots and forks are
graded there and why that is a token question rather than a risk one, both guard
clauses and the concurrency key must be scoped to the event or the dispatch is a
silent no-op, and a low check-settle wait is right for a backfill while `0`
still is not.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3cd1ee8a-80d8-4975-9630-d0bdfd809ab9

📥 Commits

Reviewing files that changed from the base of the PR and between 1c9443a and facd005.

📒 Files selected for processing (7)
  • .github/workflows/pr-risk.yml
  • .github/workflows/test-pr-risk.yml
  • scripts/pr-risk/README.md
  • scripts/pr-risk/apply-risk-label.sh
  • scripts/pr-risk/grade-targets.sh
  • scripts/pr-risk/tests/test_apply_risk_label.sh
  • scripts/pr-risk/tests/test_grade_targets.sh

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

@mattmillerai
mattmillerai marked this pull request as ready for review August 3, 2026 21:56
@mattmillerai mattmillerai added the cursor-review Multi-model cursor review label Aug 3, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 10 finding(s).

Severity Count
🟠 High 2
🟡 Medium 6
🟢 Low 2

Panel: 8/8 reviewers contributed findings.

Comment thread scripts/pr-risk/grade-targets.sh Outdated
Comment thread .github/workflows/pr-risk.yml
Comment thread scripts/pr-risk/grade-targets.sh
Comment thread .github/workflows/pr-risk.yml Outdated
Comment thread .github/workflows/pr-risk.yml Outdated
Comment thread scripts/pr-risk/grade-targets.sh
Comment thread scripts/pr-risk/grade-targets.sh Outdated
Comment thread scripts/pr-risk/grade-targets.sh Outdated
Comment thread scripts/pr-risk/grade-targets.sh Outdated
Comment thread scripts/pr-risk/grade-targets.sh Outdated
…mmary claiming a label it did not apply

Review findings on the by-number grading surface, worst first.

* PERCENT-ENCODE THE REF AND THE OVERRIDE PATH. `?ref=${base}` interpolated raw
  truncates at a `#`, and git permits `#`, `&`, `+` and `%` in branch names — a PR
  based on `fix/#123-thing` reached the contents endpoint with an EMPTY `?ref=`,
  which is not an error there: it silently resolves to the repository default
  branch. That is the "graded against rules nobody read" failure resolve_base_ref
  exists to prevent, arriving through a different door. Paths are encoded per
  segment so `/` stays structural.
* A 404 FOR THE REF IS NOT A 404 FOR THE FILE. The contents endpoint 404s both for
  an absent path (benign: use the shipped default) and for a base branch that was
  deleted or renamed, reachable on a by-number re-grade of an old PR. The second
  now fails its target instead of grading against the generic map.
* THE PRE-GRADER READS RETRY. The base-ref and override reads are the first hop
  for every target and had no retry, while the grader one step later retries the
  same failure class four times. Rate limits are global, not per-PR, so one
  secondary-rate-limit burst mid-backfill failed every remaining target wholesale
  — the inverse of "one unreadable PR never abandons the rest". A definitive
  answer (404/401/422) is still never retried; an ambiguous 403 is retried only
  when the body reads like a rate limit.
* THE SUMMARY NO LONGER RENDERS A GRADE TABLE FOR A FAILED TARGET. A label write
  that 403s left a record carrying `.risk`, so the summary headed it "PR risk
  grade: R1" with the full axis table — the one outcome where the label on the PR
  is not what the summary shows.
* THE SUMMARY BRANCHES ON RECORDED ROWS, NOT ON A STEP OUTPUT. `targets` is
  written by the grade step's last lines, so a step cancelled mid-batch emitted
  none of it, `${TARGETS:-1}` chose the single-target rendering, and every target
  after the first was silently discarded — on exactly the path those records
  exist for. Counters are derived from the rows too, and a row count short of the
  target count now says so.
* THE START GATE IS THE JOB'S DEADLINE, NOT THE WAIT BUDGET. They are different
  questions: a spent wait budget only means no target may sleep, and grading with
  zero wait is still a real grade. At `JOB_TIMEOUT_MINUTES <= 5` the wait budget
  was 0 from the first instant and a batch recorded every target `skipped`,
  grading nothing. The first target is now always attempted.
* CLAMP THE SETTLE SLEEP TO THE DEADLINE. The loop tested `now < deadline` then
  slept a full 15/30/60/120s, overrunning the caller's wait by up to 105s per
  target and charging it to the later targets' budget.
* AN EMPTY TIER IS THE UNKNOWN LANE. A record that is empty or not JSON makes jq
  print nothing at exit 0; apply-risk-label.sh maps that to `risk:ungraded`, so
  counting it `graded` credited a labeled-ungraded PR as graded.
* SOURCING HAS NO SIDE EFFECTS. The mktemp calls and `trap ... EXIT` ran at file
  scope, so sourcing replaced the sourcing shell's EXIT trap and cost it its own
  cleanup. Both are deferred to first use, and the trap is installed only by a
  direct invocation.
* A CONCURRENT DELETE OF AN ALREADY-REMOVED LABEL IS NOT A FAILURE (404 means the
  loop's goal state). A batch cannot serialize per-PR — one run, N PRs, one
  concurrency group — so the residual overlap window is documented in the header
  and the README rather than left implied.

Tests: targets 68 -> 91, label 21 -> 24, grader 49 untouched; actionlint and
shellcheck clean.
@mattmillerai mattmillerai added cursor-review Multi-model cursor review and removed cursor-review Multi-model cursor review labels Aug 3, 2026
@mattmillerai

Copy link
Copy Markdown
Contributor Author

Review round resolved in `facd005` — all ten panel findings addressed, threads replied to individually and resolved. Summary:

High

  • The base ref and the override path are percent-encoded into the contents read (enc / enc_path, per-segment for paths). Raw, a branch named fix/#123-thing truncated the URL at the # and arrived with an empty ?ref= — which resolves silently to the default branch, i.e. the exact "graded against rules nobody read" failure re-reading the base ref exists to prevent.
  • The label-sync race: fixed the half that went red (a DELETE 404 on a label a concurrent run already removed is the removal loop's goal state, not a failure) and documented the residual window plus its mitigations. No concurrency key can serialize a batch per-PR — one run, N PRs, one group — so the race-free rewrite of the sync itself is a follow-up, with the reasoning on the thread.

Medium

  • A 404 for the ref ("no commit found for the ref") is now distinguished from a 404 for the file and fails the target instead of grading against the generic map.
  • The pre-grader reads (base ref, both overrides) retry a transient failure with backoff, as the grader already did — global rate limits made them the batch's weakest hop.
  • The step summary no longer renders a grade table for a target whose label write failed (it headed that outcome PR risk grade: R1, asserting a label the PR did not carry), and no longer discards rows when the grade step is cancelled mid-batch: it branches on recorded rows, derives its counters from them, and says when the row count is short of the target count.
  • The start gate is the job's deadline, not the wait budget — those answer different questions, and conflating them made a batch grade nothing at JOB_TIMEOUT_MINUTES <= 5.
  • Each settle sleep is clamped to the remaining deadline (the backoff overran the caller's wait by up to 105s per target).

Low

  • Sourcing the script no longer creates temp files or replaces the sourcing shell's EXIT trap.
  • An empty tier is the unknown lane at both ends, so a labeled-risk:ungraded PR can no longer be counted as graded.

Tests: targets suite 68 → 91, label suite 21 → 24, grader suite 49 untouched; shellcheck and actionlint clean. The rewritten summary body was also exercised against fixtures for all nine renderable shapes, since a run: block has no unit harness. PR body updated where it claimed the label suite was untouched.

Still not verified pre-merge, unchanged from the original body: a true workflow_dispatch end-to-end run and the label write on that path both need a consumer caller pinned to a SHA of this branch.

@mattmillerai

Copy link
Copy Markdown
Contributor Author

🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:

  • BE-6342 — Make the pr-risk label sync race-safe (a batch dispatch can overlap an event run for the same PR) — filed as agent-spike (premise unverified)

The following carry agent-spike instead of agent-ok because their reachability claim was not backed by evidence (BE-5378) — the claim is investigated before any code is written, and "the premise does not hold" is a valid, successful outcome:

  • Make the pr-risk label sync race-safe (a batch dispatch can overlap an event run for the same PR) — no reachability block in the proposal

@mattmillerai
mattmillerai merged commit b9b5212 into main Aug 3, 2026
16 of 23 checks passed
@mattmillerai
mattmillerai deleted the matt/pr-risk-dispatch-by-number branch August 3, 2026 23:08

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 10 finding(s).

Severity Count
🟠 High 1
🟡 Medium 7
🟢 Low 2

Panel: 8/8 reviewers contributed findings.

else
echo "No pull request was graded: targets WERE resolved, but the grade step recorded no outcome for any of them — it was cancelled or died before the first target finished. The dispatched numbers are in the step log; none of them was labeled."
fi
elif [ "$ROWS" -eq 1 ] && [ "${TARGETS:-1}" = 1 ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High[ "${TARGETS:-1}" = 1 ] defaults an unset targets output back to 1, which reintroduces the failure the comment above claims to fix: a batch of N cancelled or timed out after recording exactly one row never writes targets, so ROWS is 1 and the summary renders the full single-PR axis table with no hint that the other targets were never graded. Default to 0 (or let the empty case fall through to the batch branch) so the "never reported its own summary" warning fires. Raised by 3 of 8 reviewers (gemini-3.1-pro edge-case, claude-opus-5-thinking-max edge-case, gpt-5.6-sol-max adversarial).

ROWS="$(jq -s 'length' "$RESULTS" 2>/dev/null || echo 0)"
COUNTS="$(jq -sr '[(map(select(.status == "graded")) | length),
(map(select(.status == "ungraded")) | length),
(map(select(.status == "failed")) | length),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Mediumjq -s fails on the whole file if any single line is malformed — exactly what a step killed mid-record_result leaves behind — so both ROWS and COUNTS fall back to zero and the summary prints "No pull request was graded ... recorded no outcome for any of them" even though earlier targets were graded and labeled. Use a per-line-tolerant read such as jq -R 'fromjson? // empty' so recorded rows survive a truncated tail. Raised by 2 of 8 reviewers (gemini-3.1-pro adversarial, claude-opus-5-thinking-max edge-case).

sleep "$nap"; G_WAITED=$(( G_WAITED + nap ))
delay=$(( delay * 2 )); [ "$delay" -le 120 ] || delay=120
done
G_TIER="$(jq -r '.risk.tier // "unknown"' "$record" 2>/dev/null)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Mediumjq -r '.risk.tier // "unknown"' has its exit status discarded, so a truncated or malformed grader record silently becomes tier unknown, gets risk:ungraded applied, and the run exits green — masking a real grader/protocol failure as a reported verdict. Distinguish a jq parse failure from a genuinely absent .risk.tier and fail the target in the former case. Raised by 2 of 8 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case; related observation from kimi-k2.7-code adversarial).

# is the failure the single-target clamp in main() was added for, one level up.
now="$(date +%s)"
deadline=$(( now + 60 * WAIT_MINUTES ))
[ "$deadline" -le "$OVERALL_DEADLINE" ] || [ "$TARGET_COUNT" -eq 1 ] || deadline="$OVERALL_DEADLINE"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium — The [ "$TARGET_COUNT" -eq 1 ] exemption from the OVERALL_DEADLINE clamp computes the settle deadline as now + 60 * WAIT_MINUTES where now is taken after the base-ref and two override reads, which now retry with backoff (up to ~90s). With wait_for_checks_minutes near the clamp maximum this pushes the settle window past the reserved headroom and the run can be cancelled mid-sleep before the label is applied — the exact outcome the clamp exists to prevent. Raised by 3 of 8 reviewers (gemini-3.1-pro adversarial, gemini-3.1-pro edge-case, claude-opus-5-thinking-max adversarial).

# JOB_TIMEOUT_MINUTES <= 5 the wait budget is 0 from the first instant, which made a batch record
# every target `skipped` and grade NOTHING.
OVERALL_DEADLINE=$(( started + 60 * max_wait ))
JOB_DEADLINE=$(( started + 60 * JOB_TIMEOUT_MINUTES - 90 ))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MediumJOB_DEADLINE = started + 60*JOB_TIMEOUT_MINUTES - 90 is anchored to this script's start, not the job's, so the checkout and runner setup that ran first have already eaten into the reserve; worse, JOB_TIMEOUT_MINUTES=1 (legal per the ^[1-9][0-9]*$ check) puts the deadline in the past, skipping every target after the first and disabling all retry_read retries. Guard the degenerate case the way max_wait does with its > 5 ? ... : 0 clamp, and derive the anchor from a job-start timestamp passed in by the workflow. Raised by 4 of 8 reviewers (claude-opus-5-thinking-max edge-case, claude-opus-5-thinking-max adversarial, gpt-5.6-sol-max edge-case, kimi-k2.7-code adversarial).

fi
ref="$(tr -d '\n' < "$OUTF")"
case "$ref" in
""|null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Mediumgh --jq '.base.ref' renders both a JSON null and the literal branch name null as the bare string null, so the ""|null) case rejects a PR whose base branch is genuinely named null. Distinguish them by reading the field with a jq expression that marks absence explicitly (e.g. --jq '.base.ref // "\u0000"') rather than pattern-matching the rendered text. Raised by 4 of 8 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case, gemini-3.1-pro adversarial, kimi-k2.7-code edge-case).

# check and skipped the target label for a PR that was in the desired state.
if ! ghq api -X DELETE "repos/$REPO/issues/$PR_NUMBER/labels/$(enc "$l")" >/dev/null; then
case "$(gherr)" in
*"(HTTP 404)"*) log "stale '$l' was already gone (404) — treating it as removed" ;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium — Accepting any (HTTP 404) on the stale-label DELETE as "already gone" also swallows the 404 GitHub returns for a nonexistent issue, an inaccessible repo, or a label-name encoding mismatch, so a persistent removal failure leaves the PR carrying two contradictory risk:* labels behind a green check. Re-read the label list after the 404 and only accept it if the label is genuinely absent from the PR. Raised by 3 of 8 reviewers (gpt-5.6-sol-max adversarial, gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max adversarial/edge-case).

if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
# `tier` is the single-target tier, which is what the step summary heading reads; a batch
# has no one tier and says so rather than picking one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MediumG_TIER is appended to $GITHUB_OUTPUT as a bare tier=<value> with no validation; it comes from jq -r '.risk.tier' on a record influenced by the --map override read from the PR's base branch, so a tier string containing a newline injects extra key=value lines and lets a caller set arbitrary step outputs consumed by the summary step. Constrain it to ^[A-Za-z0-9_:.-]+$ or use the random-delimiter heredoc form. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).

local max_wait started
started="$(date +%s)"
max_wait=$(( JOB_TIMEOUT_MINUTES > 5 ? JOB_TIMEOUT_MINUTES - 5 : 0 ))
[ "$WAIT_MINUTES" -ge 0 ] 2>/dev/null || WAIT_MINUTES=10

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Low[ "$WAIT_MINUTES" -ge 0 ] 2>/dev/null is not a type check: bash's test runs integer operands through arithmetic evaluation, so a[$(cmd)] executes rather than being rejected, and the value is re-evaluated later in $(( now + 60 * WAIT_MINUTES )). It also silently replaces a valid fractional number input like 0.5 with the 10-minute default. Apply the same ^[0-9]+$ regex used for JOB_TIMEOUT_MINUTES and MAX_TARGETS, and to POLL_DELAY_SECONDS, READ_RETRY_* and MAX_UNREADABLE_TRIES. Raised by 2 of 8 reviewers (claude-opus-5-thinking-max adversarial, gpt-5.6-sol-max edge-case).

bash "$LABELER" > "$LABELF" || label_rc=$?
label="$(tail -n 1 "$LABELF")"
if [ "$label_rc" -ne 0 ]; then
record_result "$num" failed "$G_TIER" "" "$record" "$G_WAITED" "$base" "graded ${G_TIER} but the label write FAILED (rc=${label_rc}) — the PR still carries whatever label it had"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Low — The recorded note claims "the PR still carries whatever label it had", but apply-risk-label.sh deletes stale risk:* labels before POSTing the target one, so a failure on the add (403, or label creation failing) leaves the PR with no risk label at all and the summary describes a state the PR is not in. Word the note to cover both, or have the labeler report which phase failed. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max edge-case).

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

Labels

cursor-review Multi-model cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants