@@ -26,12 +26,53 @@ set -eo pipefail
2626: " ${PR_NUMBER:? the calling step must set PR_NUMBER} "
2727: " ${REPO:? the calling step must set REPO} "
2828
29+ # How many bytes a file occupies once JSON-escaped. Defined here, above the budget
30+ # block, because the budget is denominated in escaped bytes -- see PROMPT_ARG_LIMIT
31+ # below for why that is the unit and not raw bytes.
32+ #
33+ # `jq -Rs .` reads the whole file as one JSON string and emits it quoted, which is the
34+ # transformation toJson() applies to the prompt input. Measured against the run that
35+ # failed, jq came within 0.5% and on the high side, which is the side to be wrong on.
36+ #
37+ # head -c cuts on a byte boundary and so can split a UTF-8 character. jq does not fail
38+ # on that; it substitutes U+FFFD, three bytes where the fragment was one or two, so a
39+ # mid-character cut can only over-report. The fallback is for a jq that is missing or
40+ # refuses outright: two bytes per input byte is what a file of nothing but quotes and
41+ # newlines costs, and over-estimating only trims more.
42+ escaped_bytes () {
43+ local n=' '
44+ n=$( jq -Rs . < " $1 " 2> /dev/null | wc -c | tr -d ' ' ) || n=' '
45+ case " $n " in
46+ ' ' | * [!0-9]* ) n=$(( $(wc - c < "$1 " | tr - d ' ') * 2 )) ;;
47+ esac
48+ printf ' %s' " $n "
49+ }
50+
2951# Caps. The median PR reviewed across the org is 161 changed lines and the largest
3052# in a week was 3,448, so 3,000 patch lines covers the corpus; the cap exists so
3153# one generated-file PR cannot blow up the prompt.
3254DIFF_MAX=3000
3355SINCE_MAX=2000
3456LOG_WINDOW=120
57+ # The two diff blocks share one line budget rather than holding independent caps.
58+ # They overlap by construction: the since-last-review diff is a subset of the full
59+ # diff, exactly equal to it on a single-file PR, and DIFF_MAX + SINCE_MAX let the pair
60+ # reach 5,000 lines of largely the same patch. The dashboard PR named below carried 42
61+ # KB of "since your last review" stacked on 66 KB of "full diff" -- the same file,
62+ # twice -- and the pair is what put the prompt over the limit.
63+ #
64+ # The budget below bounds what that costs, but bounding it is not the same as not
65+ # spending it: every line the duplicate takes is a line of the budget the rest of the
66+ # context does not get. So the pair is capped together and the full diff is the block
67+ # that yields, because on cycle 2+ what changed since the last round is the reviewer's
68+ # subject and the full patch is one allowlisted `gh pr diff` away. Cycle 1 has no
69+ # since-diff, so the full diff keeps very nearly the whole budget.
70+ #
71+ # No floor is written under the full diff because SINCE_MAX sits below this number:
72+ # the since-diff can spend at most 2,000 of the 3,000 lines, so the full diff always
73+ # keeps the remaining 1,000. A floor would be a branch no input reaches. Raising
74+ # SINCE_MAX to meet or exceed this number is the change that would need one.
75+ DIFF_BUDGET_LINES=3000
3576# Byte budgets. Both of this step's outputs are interpolated into the SAME `prompt:`
3677# string in the review step, and that string reaches the reviewer as one environment
3778# variable -- so the binding limit is the kernel's MAX_ARG_STRLEN, 32 * PAGE_SIZE =
@@ -46,16 +87,36 @@ LOG_WINDOW=120
4687# The earlier note here reasoned about the runner's UTF-16 accounting of step outputs.
4788# That limit is real and separate; it is not the one that fails first.
4889PROMPT_ARG_LIMIT=131072
90+ # Every budget below counts *escaped* bytes, because the environment variable that
91+ # fails first does not hold the prompt as written. claude-code-action's action.yml
92+ # sets `ALL_INPUTS: toJson(inputs)` on the step it runs, so the prompt is carried
93+ # twice: once raw as PROMPT, and once JSON-escaped inside ALL_INPUTS. The escaped copy
94+ # is always the larger of the two, so it is always the one that reaches the limit
95+ # first, and bounding the raw string leaves the real one unbounded.
96+ #
97+ # A Grafana dashboard PR is the proof: 123,401 raw bytes -- inside the limit -- and
98+ # 135,366 escaped, and it failed on two consecutive pushes. A budget denominated in
99+ # raw bytes does not see it at all.
100+ #
101+ # The expansion is not a constant that could be folded in as a factor. Prose costs
102+ # about 1.02x and a quote-and-newline dense JSON diff about 1.20x, so the same 3,000
103+ # lines land either side of the limit depending only on what the file holds. Hence
104+ # every measurement below runs through escaped_bytes.
105+ #
106+ # What toJson(inputs) serializes beside the prompt: 38 further inputs, measured at
107+ # 1,356 bytes on the run that failed. They are inside the same variable and spend the
108+ # same limit. Rounded up.
109+ ALL_INPUTS_OTHER_BYTES=2000
49110# What the workflow wraps around the two outputs: 494 bytes of literal header and the
50111# two data-tag blocks with their do-not-follow notices, plus the interpolated REPO,
51112# PR number and cycle. Rounded up.
52113PROMPT_WRAPPER_BYTES=600
53114# The static prompt is appended to that same string and spends the same budget, so it
54115# is measured rather than hard-coded: editing the prompt document must shrink what is
55- # left for context, not silently overflow the limit.
116+ # left for context, not silently overflow the limit. Measured escaped, like the rest.
56117PROMPT_DOC=" $( dirname " $0 " ) /../docs/claude-pr-review-prompt.md"
57118if [ -r " $PROMPT_DOC " ]; then
58- PROMPT_DOC_BYTES=$( wc -c < " $PROMPT_DOC " | tr -d " " )
119+ PROMPT_DOC_BYTES=$( escaped_bytes " $PROMPT_DOC " )
59120else
60121 # A moved path or a narrowed sparse-checkout pattern. Assume large rather than
61122 # assume nothing: an over-generous figure truncates context, a missing one puts the
@@ -64,10 +125,11 @@ else
64125 echo " ::warning::Could not measure ${PROMPT_DOC} ; assuming ${PROMPT_DOC_BYTES} bytes when sizing the prompt budget."
65126fi
66127# 1 KB of slack. Deliberately small: every byte held back here is context the reviewer
67- # does not get, and the three terms above are measured rather than estimated. The
68- # budget lands near 122 KB, so a pull request that assembles under the limit today is
69- # not newly truncated -- only the ones that already fail outright change behaviour.
70- PROMPT_BUDGET=$(( PROMPT_ARG_LIMIT - PROMPT_WRAPPER_BYTES - PROMPT_DOC_BYTES - 1024 ))
128+ # does not get, and the terms above are measured rather than estimated. A pull request
129+ # that assembles under the limit today is not newly truncated -- only the ones that
130+ # already fail outright change behaviour.
131+ PROMPT_BUDGET=$(( PROMPT_ARG_LIMIT - ALL_INPUTS_OTHER_BYTES - PROMPT_WRAPPER_BYTES \
132+ - PROMPT_DOC_BYTES - 1024 ))
71133# threads keeps a cap of its own so a comment dump cannot eat the budget the diff
72134# needs: 400 inline comments rendered 1.1 MB on their own. It is also held to half the
73135# budget, so a long review history can never starve the diff completely.
@@ -102,6 +164,47 @@ cap_file() {
102164 fi
103165}
104166
167+ # cap_file_escaped <file> <budget> <notice> -- trim <file> until it fits <budget> once
168+ # escaped. The raw cut point is found by measuring, scaling and re-measuring rather
169+ # than by assuming a ratio, because the ratio is a property of the content: the same
170+ # 3,000 lines cost 1.02x as prose and 1.20x as JSON. Escaped size is monotonic in raw
171+ # length, so scaling by how far over budget the file is converges downward. Two or
172+ # three passes is typical on real input; the loop bound is a backstop, not the
173+ # mechanism.
174+ cap_file_escaped () {
175+ local file=$1 budget=$2 notice=$3 esc raw target i=0
176+ # The notice is appended after the cut, so its bytes come out of the budget first. A
177+ # cap that put the file back over the limit by announcing itself would be the same
178+ # bug in miniature.
179+ budget=$(( budget - 300 ))
180+ if [ " $budget " -lt 1 ]; then budget=1; fi
181+ esc=$( escaped_bytes " $file " )
182+ if [ " $esc " -le " $budget " ]; then return 0; fi
183+ while [ " $i " -lt 8 ]; do
184+ raw=$( wc -c < " $file " | tr -d ' ' )
185+ # The 0.98 is deliberate undershoot: the ratio is measured over the whole file but
186+ # applied to a prefix, and a prefix denser than the average would otherwise land
187+ # just over and spend another pass.
188+ target=$( awk -v r=" $raw " -v e=" $esc " -v b=" $budget " \
189+ ' BEGIN { t = int(r * b / e * 0.98); print (t < 1) ? 1 : t }' )
190+ head -c " $target " " $file " > " $file .cut"
191+ mv " $file .cut" " $file "
192+ esc=$( escaped_bytes " $file " )
193+ if [ " $esc " -le " $budget " ]; then break ; fi
194+ i=$(( i + 1 ))
195+ done
196+ # Drop the partial line the byte cut left behind. A context ending in `"range": tru`
197+ # puts a mangled fragment of a patch line where the reviewer reads patch lines, and
198+ # it is the last thing before the notice. Guarded on there being an earlier boundary
199+ # to fall back to: a block that is one enormous line -- a base64 CI log dump is how
200+ # that happens -- has none, and losing all of it would cost more than ending
201+ # mid-line. Removing a line only shrinks the file, so the budget still holds.
202+ if [ " $( awk ' END {print NR}' " $file " ) " -gt 1 ]; then
203+ if sed ' $d' " $file " > " $file .cut" ; then mv " $file .cut" " $file " ; fi
204+ fi
205+ echo " ($notice )" >> " $file "
206+ }
207+
105208fetch_raw () {
106209 RAW_OUT=$1
107210 shift
@@ -213,8 +316,8 @@ strip_block_tags() {
213316# tag behind: there are none left for it to bisect.
214317THREADS_FILE=" ${RUNNER_TEMP} /threads.md"
215318printf ' %s\n' " $THREADS " | strip_block_tags > " $THREADS_FILE "
216- cap_file " $THREADS_FILE " " $THREADS_MAX_BYTES " \
217- " prior review comments truncated at ${THREADS_MAX_BYTES} bytes ; read the rest with gh pr view"
319+ cap_file_escaped " $THREADS_FILE " " $THREADS_MAX_BYTES " \
320+ " prior review comments truncated; read the rest with gh pr view"
218321
219322DELIMITER=" REVIEW_CONTEXT_$( openssl rand -hex 16) "
220323{
@@ -362,6 +465,11 @@ done
362465# because inline comments and the round's verdict are separate review objects.
363466LAST_REVIEW_JQ=' [.[][] | select(.user.login == "claude[bot]") | select(.submitted_at != null) | {commit_id, submitted_at}] | sort_by(.submitted_at) | last | (.commit_id // "")'
364467LAST_SHA=$( printf ' %s' " $REVIEWS " | jq -s -r " $LAST_REVIEW_JQ " 2> /dev/null) || LAST_SHA=' '
468+ # Lines this block spends, which the full diff below subtracts from the shared budget.
469+ # It stays 0 on every path that renders no patch -- cycle 1, a rebase, a failed fetch
470+ # -- so the full diff gets the whole budget exactly as it did before there was a
471+ # since-diff to share with.
472+ SINCE_USED=0
365473if [ -n " $LAST_SHA " ] && [ " $LAST_SHA " != " null" ] && [ " $LAST_SHA " != " $HEAD_SHA " ]; then
366474 SINCE_FILE=" ${RUNNER_TEMP} /since-last-review.diff"
367475 # The compare API, not git: the checkout is fetch-depth 1, so no base branch and
@@ -385,6 +493,8 @@ if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" != "null" ] && [ "$LAST_SHA" != "$HEAD_SH
385493 # awk, not `wc -l`: wc pads its count with spaces on BSD and the number
386494 # is interpolated into the notice below, not just compared.
387495 SINCE_LINES=$( awk ' END {print NR}' " $SINCE_FILE " )
496+ SINCE_USED=$SINCE_LINES
497+ if [ " $SINCE_USED " -gt " $SINCE_MAX " ]; then SINCE_USED=$SINCE_MAX ; fi
388498 {
389499 echo
390500 echo " ## Diff since your last review (${LAST_SHA} to ${HEAD_SHA} )"
@@ -406,6 +516,12 @@ if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" != "null" ] && [ "$LAST_SHA" != "$HEAD_SH
406516 fi
407517fi
408518
519+ # Whatever the since-diff left of the shared budget, bounded above by DIFF_MAX so a PR
520+ # with no since-diff renders exactly what it always did. SINCE_MAX keeps the
521+ # subtraction from reaching zero; see DIFF_BUDGET_LINES above.
522+ FULL_DIFF_MAX=$(( DIFF_BUDGET_LINES - SINCE_USED))
523+ if [ " $FULL_DIFF_MAX " -gt " $DIFF_MAX " ]; then FULL_DIFF_MAX=$DIFF_MAX ; fi
524+
409525DIFF_FILE=" ${RUNNER_TEMP} /pr.diff"
410526if fetch_raw " $DIFF_FILE " pr diff " $PR_NUMBER " --repo " $REPO " ; then
411527 DIFF_LINES=$( awk ' END {print NR}' " $DIFF_FILE " )
@@ -419,9 +535,9 @@ if fetch_raw "$DIFF_FILE" pr diff "$PR_NUMBER" --repo "$REPO"; then
419535 echo " The diff came back empty. That is unusual for a pull request; treat it"
420536 echo " as missing rather than as \" nothing changed\" and run gh pr diff."
421537 else
422- head -n " $DIFF_MAX " " $DIFF_FILE "
423- if [ " $DIFF_LINES " -gt " $DIFF_MAX " ]; then
424- echo " (truncated: first ${DIFF_MAX } of ${DIFF_LINES} lines; run gh pr diff for the rest)"
538+ head -n " $FULL_DIFF_MAX " " $DIFF_FILE "
539+ if [ " $DIFF_LINES " -gt " $FULL_DIFF_MAX " ]; then
540+ echo " (truncated: first ${FULL_DIFF_MAX } of ${DIFF_LINES} lines; run gh pr diff for the rest)"
425541 fi
426542 fi
427543 } >> " $CTX "
447563# final size is known rather than assumed. The per-block caps above still matter -- they
448564# decide *what* survives truncation, and they keep any one block from arriving here
449565# having already crowded out the diff -- but this is what makes the total fit.
450- THREADS_BYTES=$( wc -c < " $THREADS_FILE " | tr -d " " )
566+ THREADS_BYTES=$( escaped_bytes " $THREADS_FILE " )
451567CTX_MAX_BYTES=$(( PROMPT_BUDGET - THREADS_BYTES))
452568# Unreachable while THREADS_MAX_BYTES is clamped to half the budget. Kept because the
453569# alternative if that clamp is ever loosened is `head -c` with a negative count, and an
462578# author-controlled, and this is the file they land in.
463579strip_block_tags < " $CTX " > " $CTX .stripped"
464580mv " $CTX .stripped" " $CTX "
465- if [ " $( wc -c < " $CTX " | tr -d " " ) " -gt " $CTX_MAX_BYTES " ]; then
466- echo " ::warning::Review context exceeded ${CTX_MAX_BYTES} bytes and was truncated."
581+ CTX_ESCAPED=$( escaped_bytes " $CTX " )
582+ if [ " $CTX_ESCAPED " -gt " $CTX_MAX_BYTES " ]; then
583+ # A notice rather than a warning. No line cap bounds bytes: 3,000 lines of prose is
584+ # about 60 KB escaped and 3,000 lines of dashboard JSON about 200 KB, so a
585+ # generated-file PR reaches this legitimately and often, and a warning that cried
586+ # regression every time would stop being read. It is here so an operator reading a
587+ # thin review can see the context was cut and by how much -- and so a budget that
588+ # starts firing on ordinary prose PRs, which would mean something really did
589+ # regress, is visible rather than silent.
590+ echo " ::notice::Review context reached ${CTX_ESCAPED} escaped bytes against a ${CTX_MAX_BYTES} byte budget and was truncated to fit the review prompt."
467591fi
468- cap_file " $CTX " " $CTX_MAX_BYTES " \
469- " context truncated at ${CTX_MAX_BYTES} bytes ; read what is missing with gh pr diff and gh pr view"
592+ cap_file_escaped " $CTX " " $CTX_MAX_BYTES " \
593+ " context truncated to fit the review prompt ; read what is missing with gh pr diff and gh pr view"
470594
471595CTX_DELIMITER=" PR_CONTEXT_$( openssl rand -hex 16) "
472596{
0 commit comments