@@ -78,12 +78,25 @@ jobs:
7878 - name : Gather review context
7979 if : github.event.pull_request.user.login != 'dependabot[bot]'
8080 id : context
81- # Eight API reads feed the prompt now. A failure in this step *skips* the review
81+ # Nine API reads feed the prompt now. A failure in this step *skips* the review
8282 # step, and with it the notify step's failure check, so the PR would get no review
8383 # and no explanation. Every command below is guarded individually; this is the
84- # backstop that keeps a bug in one block from costing the PR its review, at the cost
85- # of the prompt losing whichever blocks had not been written yet.
84+ # backstop that keeps a bug in one block from costing the PR its review.
85+ #
86+ # What it costs when it fires: pr_context is appended to $GITHUB_OUTPUT once, at the
87+ # end, so an abort anywhere before that leaves the output unset and the prompt gets
88+ # an *empty* <pr_context> -- not a partial one. review_cycle and threads are written
89+ # earlier and survive. Each degraded block carries a sentence saying what is
90+ # missing, but a degraded step carries nothing, so the prompt tells the reviewer to
91+ # fetch what it needs itself when the block is empty. Without that line the prompt
92+ # would be telling it not to re-fetch context it never received.
8693 continue-on-error : true
94+ # Explicitly, because the default for a run block is `bash -e {0}` -- no pipefail.
95+ # This step is mostly `gh ... | jq` pipelines, and gh writes its error body to
96+ # stdout, so without pipefail a failed fetch feeds its own error text to jq and the
97+ # block renders whatever jq makes of it instead of the guarded fallback sentence.
98+ # tests/context-step-test.sh runs the extracted script under the same shell.
99+ shell : bash
87100 run : |
88101 PR_NUMBER=${{ github.event.pull_request.number }}
89102 REPO=${{ github.repository }}
@@ -97,6 +110,22 @@ jobs:
97110 CTX="${RUNNER_TEMP}/pr-context.md"
98111 : > "$CTX"
99112
113+ # gh refuses a raw-text body containing ANSI colour unless told to allow escape
114+ # sequences, and a diff or a job log earns an escape byte from any file holding
115+ # terminal output -- this repository's own job-log fixtures do. That refusal and
116+ # its --allow-escape-sequences opt-out arrived together in gh 2.97.0 as a security
117+ # fix; ubuntu-latest ships 2.96.0, where the flag is an unknown-flag error and the
118+ # refusal does not exist either. So every raw fetch tries the flag and falls back
119+ # to the bare call: on 2.96 the first attempt fails and the second succeeds, on
120+ # 2.97+ the first succeeds. Pinning either form breaks on the other, and the
121+ # runner image updates weekly.
122+ fetch_raw() {
123+ RAW_OUT=$1
124+ shift
125+ gh "$@" --allow-escape-sequences > "$RAW_OUT" 2>/dev/null && return 0
126+ gh "$@" > "$RAW_OUT" 2>/dev/null
127+ }
128+
100129 # Count distinct commits already reviewed, never review state: the org ruleset
101130 # sets dismiss_stale_reviews_on_push, so a push flips a prior APPROVED to
102131 # DISMISSED and a state filter stops matching it. Inline comments each create
@@ -240,10 +269,7 @@ jobs:
240269 for JOB_ID in $JOB_IDS; do
241270 JOB_LOG="${RUNNER_TEMP}/job-${JOB_ID}.log"
242271 { echo; echo "### Failing job ${JOB_ID}"; } >> "$CTX"
243- # --allow-escape-sequences or gh refuses the body and writes nothing at all:
244- # job logs carry ANSI colour, and without the flag this is always "unavailable".
245- if ! gh api --allow-escape-sequences \
246- "repos/${REPO}/actions/jobs/${JOB_ID}/logs" > "$JOB_LOG" 2>/dev/null; then
272+ if ! fetch_raw "$JOB_LOG" api "repos/${REPO}/actions/jobs/${JOB_ID}/logs"; then
247273 echo "(log unavailable)" >> "$CTX"
248274 continue
249275 fi
@@ -276,14 +302,22 @@ jobs:
276302 SINCE_FILE="${RUNNER_TEMP}/since-last-review.diff"
277303 # The compare API, not git: the checkout is fetch-depth 1, so no base branch and
278304 # no prior commit exists locally to diff against.
279- # --allow-escape-sequences on every call whose body is raw text rather than
280- # JSON: gh refuses such a body outright and writes nothing. A diff earns an
281- # escape byte from any fixture holding terminal output -- this repository's own
282- # job-log fixtures do -- and without the flag the most important block in the
283- # prompt goes missing behind a warning.
284- if gh api "repos/${REPO}/compare/${LAST_SHA}...${HEAD_SHA}" \
285- -H "Accept: application/vnd.github.diff" --allow-escape-sequences \
286- > "$SINCE_FILE" 2>/dev/null; then
305+ #
306+ # Ask for the JSON first and only use the diff when the comparison is a clean
307+ # fast-forward. compare/A...B is three-dot, so it diffs from the *merge base* of
308+ # the two, which equals "since A" only while the branch has done nothing but gain
309+ # commits. After a rebase or a squash-and-force-push the old SHA usually stays
310+ # reachable, so this call succeeds and returns the whole PR plus anything the
311+ # rebase pulled in from upstream -- under a heading that says the opposite. A
312+ # reviewer trusting that heading re-raises issues the author already settled, and
313+ # SINCE_MAX can drop the part that genuinely is new. status is "ahead" only for
314+ # the fast-forward case; "diverged" and "behind" fall through to the message.
315+ COMPARE_STATUS_JQ='.status // "unknown"'
316+ SINCE_STATUS=$(gh api "repos/${REPO}/compare/${LAST_SHA}...${HEAD_SHA}" 2>/dev/null \
317+ | jq -r "$COMPARE_STATUS_JQ" 2>/dev/null) || SINCE_STATUS='unknown'
318+ if [ "$SINCE_STATUS" = "ahead" ] \
319+ && fetch_raw "$SINCE_FILE" api "repos/${REPO}/compare/${LAST_SHA}...${HEAD_SHA}" \
320+ -H "Accept: application/vnd.github.diff"; then
287321 # awk, not `wc -l`: wc pads its count with spaces on BSD and the number
288322 # is interpolated into the notice below, not just compared.
289323 SINCE_LINES=$(awk 'END {print NR}' "$SINCE_FILE")
@@ -299,15 +333,17 @@ jobs:
299333 {
300334 echo
301335 echo "## Diff since your last review"
302- echo "Unavailable: ${LAST_SHA} could not be compared to ${HEAD_SHA}."
303- echo "The branch was probably force-pushed. Review the full diff instead."
336+ echo "Unavailable: ${LAST_SHA} does not fast-forward to ${HEAD_SHA}"
337+ echo "(comparison status: ${SINCE_STATUS})."
338+ echo "The branch was rebased or force-pushed, so there is no meaningful"
339+ echo "\"since last review\" diff. Review the full diff below instead, and"
340+ echo "read the prior review comments to see what was already raised."
304341 } >> "$CTX"
305342 fi
306343 fi
307344
308345 DIFF_FILE="${RUNNER_TEMP}/pr.diff"
309- if gh pr diff "$PR_NUMBER" --repo "$REPO" --allow-escape-sequences \
310- > "$DIFF_FILE" 2>/dev/null; then
346+ if fetch_raw "$DIFF_FILE" pr diff "$PR_NUMBER" --repo "$REPO"; then
311347 DIFF_LINES=$(awk 'END {print NR}' "$DIFF_FILE")
312348 {
313349 echo
@@ -420,11 +456,14 @@ jobs:
420456 # norm strips the wrappers the reviewer puts in front of a real command (timeout,
421457 # cd .. &&, env VAR=x) so they do not all collapse into "other". verb returns the
422458 # first matching label or "other" -- the output is always one of these literals.
423- CMD_JQ='def norm: sub("^\\s+"; "") | sub("^timeout\\s+[0-9]+m?\\s+"; "") | sub("^cd\\s+[^&|;]+&&\\s*"; "") | sub("^env\\s+\\S+=\\S+\\s+"; ""); def verb: . as $c | ([[["^gh\\s+pr\\s+diff", "gh pr diff"], ["^gh\\s+pr\\s+view", "gh pr view"], ["^gh\\s+pr\\s+checks", "gh pr checks"], ["^gh\\s+pr\\s+review", "gh pr review"], ["^gh\\s+pr\\s+comment", "gh pr comment"], ["^gh\\s+api", "gh api"], ["^gh\\s", "gh other"], ["^git\\s+diff", "git diff"], ["^git\\s+log", "git log"], ["^git\\s+show", "git show"], ["^git\\s+blame", "git blame"], ["^git\\s", "git other"], ["^rg\\b", "rg"], ["^grep\\b", "grep"], ["^(fd|find)\\b", "find"], ["^(ls|tree)\\b", "ls"], ["^(sed|awk)\\b", "sed/awk"], ["^(cat|head|tail|wc)\\b", "cat/head/tail"], ["^(pytest|uv|python3?|cargo|npm|pnpm|yarn|bun|node|go|make|ruff|mypy|pyflakes)\\b", "run tests/build"]][] | select(.[0] as $re | $c | test($re))] | .[0] // ["", "other"]) | .[1]; def classify: {cmd: (norm | verb), compound: test("\\||&&|;|>")}; def toolname: if type == "string" and test("^[A-Za-z0-9_-]{1,64}$") then . else "unknown" end;'
459+ CMD_JQ='def norm: sub("^\\s+"; "") | sub("^timeout\\s+[0-9]+m?\\s+"; "") | sub("^cd\\s+[^&|;]+&&\\s*"; "") | sub("^env\\s+\\S+=\\S+\\s+"; ""); def verb: . as $c | ([[["^gh\\s+pr\\s+diff", "gh pr diff"], ["^gh\\s+pr\\s+view", "gh pr view"], ["^gh\\s+pr\\s+checks", "gh pr checks"], ["^gh\\s+pr\\s+review", "gh pr review"], ["^gh\\s+pr\\s+comment", "gh pr comment"], ["^gh\\s+api", "gh api"], ["^gh\\s", "gh other"], ["^git\\s+diff", "git diff"], ["^git\\s+log", "git log"], ["^git\\s+show", "git show"], ["^git\\s+blame", "git blame"], ["^git\\s", "git other"], ["^rg\\b", "rg"], ["^grep\\b", "grep"], ["^(fd|find)\\b", "find"], ["^(ls|tree)\\b", "ls"], ["^(sed|awk)\\b", "sed/awk"], ["^(cat|head|tail|wc)\\b", "cat/head/tail"], ["^(pytest|uv|python3?|cargo|npm|pnpm|yarn|bun|node|go|make|ruff|mypy|pyflakes)\\b", "run tests/build"]][] | select(.[0] as $re | $c | test($re))] | .[0] // ["", "other"]) | .[1]; def unquoted: gsub("\"[^\"]*\""; "") | gsub("\u0027[^\u0027]*\u0027"; ""); def classify: {cmd: (norm | verb), compound: (unquoted | test("\\||&&|;|>"))}; def toolname: if type == "string" and test("^[A-Za-z0-9_-]{1,64}$") then . else "unknown" end;'
424460 # commands and denied_commands answer two different questions: what the reviewer
425461 # spends its Bash budget on, and which of those the allowlist refuses. compound is
426462 # carried separately because an allowlisted command still gets denied when it is
427- # piped or redirected, which no tool name or verb alone would show.
463+ # piped or redirected, which no tool name or verb alone would show -- and it is
464+ # tested against the command with quoted spans removed, because `rg -n \"a|b\"` is
465+ # one allowlisted command and counting its alternation as a pipe would inflate
466+ # exactly the number the flag exists to produce.
428467 TOOL_USAGE_JQ='{tool_calls: ([.[]? | select(.type=="assistant") | .message.content[]? | select(.type=="tool_use") | .name | toolname] | group_by(.) | map({name: .[0], n: length}) | sort_by(-.n)), commands: ([.[]? | select(.type=="assistant") | .message.content[]? | select(.type=="tool_use" and .name=="Bash") | (.input.command // "") | classify] | group_by([.cmd, .compound]) | map({cmd: .[0].cmd, compound: .[0].compound, n: length}) | sort_by(-.n)), denials: (([.[]? | select(.type=="result")] | last // {}) | (.permission_denials // []) | map(.tool_name | toolname) | group_by(.) | map({name: .[0], n: length}) | sort_by(-.n)), denied_commands: (([.[]? | select(.type=="result")] | last // {}) | (.permission_denials // []) | map(select(.tool_name == "Bash") | (.tool_input.command // "") | classify) | group_by([.cmd, .compound]) | map({cmd: .[0].cmd, compound: .[0].compound, n: length}) | sort_by(-.n)), result: (([.[]? | select(.type=="result")] | last // {}) | {subtype, is_error, num_turns, duration_ms, total_cost_usd})}'
429468 jq "$CMD_JQ $TOOL_USAGE_JQ" "$EXECUTION_FILE" > "${RUNNER_TEMP}/claude-tool-usage.json"
430469 env :
0 commit comments