@@ -106,7 +106,19 @@ jobs:
106106 DIFF_MAX=3000
107107 SINCE_MAX=2000
108108 LOG_WINDOW=120
109- CTX_MAX_BYTES=600000
109+ # Byte budgets, split across the step's two outputs rather than applied to one of
110+ # them. threads is its own output written before the context file, so a cap that
111+ # only measured the context bounded nothing: 400 inline comments rendered 1.1 MB of
112+ # threads on their own. The total here is deliberately far below any plausible
113+ # runner limit -- the largest PR reviewed across the org in a week rendered about
114+ # 150 KB -- because the runner accounts for output size in UTF-16, so a byte count
115+ # here is not the number it checks against.
116+ THREADS_MAX_BYTES=100000
117+ CTX_MAX_BYTES=200000
118+ # One job log can be mostly a single line: LOG_WINDOW counts lines and a CI log
119+ # line has no length limit, so a base64 or JSON dump next to the first error marker
120+ # would otherwise consume the whole context ahead of the diff.
121+ LOG_MAX_BYTES=40000
110122 CTX="${RUNNER_TEMP}/pr-context.md"
111123 : > "$CTX"
112124
@@ -119,6 +131,17 @@ jobs:
119131 # to the bare call: on 2.96 the first attempt fails and the second succeeds, on
120132 # 2.97+ the first succeeds. Pinning either form breaks on the other, and the
121133 # runner image updates weekly.
134+ # head -c against a *file*, never a pipe: `sed ... | head -c` closes the pipe early
135+ # and SIGPIPE takes the producer down under pipefail, which is the shape that has
136+ # already cost this step its error window once.
137+ cap_file() {
138+ if [ "$(wc -c < "$1" | tr -d " ")" -gt "$2" ]; then
139+ head -c "$2" "$1" > "$1.cut"
140+ mv "$1.cut" "$1"
141+ echo "($3)" >> "$1"
142+ fi
143+ }
144+
122145 fetch_raw() {
123146 RAW_OUT=$1
124147 shift
@@ -140,8 +163,21 @@ jobs:
140163 # Never fail the review over the cycle number; degrade to 1, but say so. gh
141164 # writes its error body to stdout, so an unguarded pipe into jq aborts the step
142165 # under `bash -e` and skips the failure-notification step below.
166+ # CTX_WARNINGS collects the degradations the *model* has to know about, as opposed
167+ # to the ones only an operator cares about. The distinction is whether the fallback
168+ # is blank or is an assertion: "Could not read the diff." is visibly missing data,
169+ # but "REVIEW CYCLE: 1" and "No prior review comments." are claims, and a failed
170+ # read makes them false ones.
171+ WARN_FILE="${RUNNER_TEMP}/ctx-warnings.md"
172+ : > "$WARN_FILE"
143173 if ! REVIEWS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --paginate); then
144174 echo "::warning::Could not read prior reviews; treating this as review cycle 1."
175+ {
176+ echo "- The prior reviews could not be read, so the REVIEW CYCLE number in this"
177+ echo " prompt may be wrong: it defaults to 1. If this is not really your first"
178+ echo " review, treat the cycle ladder as unknown, and do not take the cycle"
179+ echo " number as evidence that nothing was raised before."
180+ } >> "$WARN_FILE"
145181 REVIEWS=''
146182 fi
147183 CYCLE=$(printf '%s' "$REVIEWS" | jq -s "$CYCLE_JQ" 2>/dev/null) || CYCLE=''
@@ -160,10 +196,24 @@ jobs:
160196 # Same guard as the counter above: unguarded `gh api | jq` aborts the step, and a
161197 # failure here *skips* the review step, so the notify step's failure check never
162198 # fires and the PR gets no review and no explanation.
199+ COMMENTS_OK=1
163200 if ! COMMENTS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/comments" --paginate); then
201+ COMMENTS_OK=0
164202 echo "::warning::Could not read prior review comments; reviewing without them."
203+ {
204+ echo "- The prior inline review comments could not be read. That block is empty"
205+ echo " because the fetch failed, not because there were none. Do not conclude"
206+ echo " that no feedback was given; read the threads with gh pr view before"
207+ echo " re-raising anything."
208+ } >> "$WARN_FILE"
165209 COMMENTS=''
166210 fi
211+ # "No prior review comments." is only true when the fetch worked and returned
212+ # none. Saying it after a failed fetch is the same false claim as an empty CI block
213+ # reading as a green one, and it is the claim the cycle ladder acts on.
214+ if [ "$COMMENTS_OK" -eq 0 ]; then
215+ THREADS='Unavailable: the prior inline review comments could not be read. This block is empty because the fetch failed, not because there were none.'
216+ else
167217 THREADS=$(printf '%s' "$COMMENTS" | jq -s -r '
168218 (add // []) | sort_by(.created_at) |
169219 if length == 0 then "No prior review comments."
@@ -174,30 +224,51 @@ jobs:
174224 (if .line then "Line: \(.line)" else empty end),
175225 (if .in_reply_to_id then "Reply to #\(.in_reply_to_id)" else "Thread #\(.id)" end),
176226 "",
177- .body
227+ (( .body // "")[0:3000])
178228 end
179- ') || THREADS='No prior review comments.'
229+ ') || THREADS='Unavailable: the prior inline review comments could not be parsed.'
230+ fi
180231
181232 # The prompt wraps both blocks below in <prior_review_comments> and <pr_context>
182233 # and tells the reviewer to treat their contents as data. A PR body, a diff hunk,
183234 # or a CI log containing the closing tag ends the block early, and everything the
184235 # author wrote after it lands *outside* the marked region, where it reads as
185236 # prompt. The tags are fixed strings, so neutralising them is complete: there is
186237 # no other spelling the model parses as the same delimiter.
238+ # perl, not sed: this has to be case-insensitive and whitespace-tolerant, and BSD
239+ # sed has no case-insensitive substitute flag, so a sed version would either be a
240+ # GNU-only `I` flag or twenty spelled-out character classes. perl ships on every
241+ # runner image. `</pr_context >`, `</PR_CONTEXT>` and `< / pr_context foo="1">` all
242+ # read as the same delimiter to a model, so matching the shape is the only version
243+ # of this that is not walked around by whitespace.
187244 strip_block_tags() {
188- sed -e 's|<\(/\{0,1\}\)pr_context>|[\1pr_context]|g' \
189- -e 's|<\(/\{0,1\}\)prior_review_comments>|[\1prior_review_comments]|g'
245+ perl -pe 's{< \s* /? \s* (?: pr_context | prior_review_comments ) [^>]* >}{[block tag removed]}gix'
190246 }
191247
248+ THREADS_FILE="${RUNNER_TEMP}/threads.md"
249+ printf '%s\n' "$THREADS" > "$THREADS_FILE"
250+ cap_file "$THREADS_FILE" "$THREADS_MAX_BYTES" \
251+ "prior review comments truncated at ${THREADS_MAX_BYTES} bytes; read the rest with gh pr view"
252+
192253 DELIMITER="REVIEW_CONTEXT_$(openssl rand -hex 16)"
193254 {
194255 echo "threads<<${DELIMITER}"
195- printf '%s\n' "$THREADS" | strip_block_tags
256+ strip_block_tags < "$THREADS_FILE"
196257 echo "${DELIMITER}"
197258 } >> $GITHUB_OUTPUT
198259
199260 # Title and body reach the shell through env, never a ${{ }} interpolation: both
200261 # are attacker-controlled text and would otherwise be spliced into this script.
262+ # First in the file on purpose: the byte cap keeps the head, so anything the
263+ # reviewer must not miss has to be above the blocks that can grow.
264+ if [ -s "$WARN_FILE" ]; then
265+ {
266+ echo "## Context warnings"
267+ cat "$WARN_FILE"
268+ echo
269+ } >> "$CTX"
270+ fi
271+
201272 {
202273 echo "## Pull request"
203274 echo "Title: ${PR_TITLE}"
@@ -275,7 +346,10 @@ jobs:
275346 fi
276347 SUMMARY=$(grep -E "$LOG_SUMMARY_RE" "$JOB_LOG" | tail -n 20) || SUMMARY=''
277348 if [ -n "$SUMMARY" ]; then
278- { echo "Summary lines:"; printf '%s\n' "$SUMMARY"; echo; } >> "$CTX"
349+ EXCERPT="${RUNNER_TEMP}/job-${JOB_ID}-summary.txt"
350+ printf '%s\n' "$SUMMARY" > "$EXCERPT"
351+ cap_file "$EXCERPT" "$LOG_MAX_BYTES" "summary truncated"
352+ { echo "Summary lines:"; cat "$EXCERPT"; echo; } >> "$CTX"
279353 fi
280354 # The *first* error marker: later steps in the same job add their own, and the
281355 # failing step's is the one with the cause above it.
@@ -291,12 +365,20 @@ jobs:
291365 if [ -n "$ERR_LINE" ]; then
292366 START=$((ERR_LINE - LOG_WINDOW + 1))
293367 if [ "$START" -lt 1 ]; then START=1; fi
368+ EXCERPT="${RUNNER_TEMP}/job-${JOB_ID}-window.txt"
369+ sed -n "${START},${ERR_LINE}p" "$JOB_LOG" > "$EXCERPT"
370+ cap_file "$EXCERPT" "$LOG_MAX_BYTES" \
371+ "log excerpt truncated at ${LOG_MAX_BYTES} bytes"
294372 {
295373 echo "Log lines ${START}-${ERR_LINE}, ending at the first error:"
296- sed -n "${START},${ERR_LINE}p" "$JOB_LOG "
374+ cat "$EXCERPT "
297375 } >> "$CTX"
298376 else
299- { echo "Last ${LOG_WINDOW} log lines:"; tail -n "$LOG_WINDOW" "$JOB_LOG"; } >> "$CTX"
377+ EXCERPT="${RUNNER_TEMP}/job-${JOB_ID}-tail.txt"
378+ tail -n "$LOG_WINDOW" "$JOB_LOG" > "$EXCERPT"
379+ cap_file "$EXCERPT" "$LOG_MAX_BYTES" \
380+ "log excerpt truncated at ${LOG_MAX_BYTES} bytes"
381+ { echo "Last ${LOG_WINDOW} log lines:"; cat "$EXCERPT"; } >> "$CTX"
300382 fi
301383 done
302384
@@ -390,10 +472,8 @@ jobs:
390472 # the file far below this, so hitting it means one of them regressed.
391473 if [ "$(wc -c < "$CTX" | tr -d " ")" -gt "$CTX_MAX_BYTES" ]; then
392474 echo "::warning::Review context exceeded ${CTX_MAX_BYTES} bytes and was truncated."
393- head -c "$CTX_MAX_BYTES" "$CTX" > "${CTX}.cut"
394- mv "${CTX}.cut" "$CTX"
395- echo "(context truncated at ${CTX_MAX_BYTES} bytes)" >> "$CTX"
396475 fi
476+ cap_file "$CTX" "$CTX_MAX_BYTES" "context truncated at ${CTX_MAX_BYTES} bytes"
397477
398478 CTX_DELIMITER="PR_CONTEXT_$(openssl rand -hex 16)"
399479 {
0 commit comments