Skip to content

Commit f8d4e4f

Browse files
committed
fix(review): bound the blocks written above the diff
1 parent 54c15b8 commit f8d4e4f

3 files changed

Lines changed: 193 additions & 36 deletions

File tree

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ Both step outputs are interpolated into one `prompt:` string, so the binding lim
4141
"Argument list too long" while the action still reports success. The budget is denominated in
4242
*escaped* bytes, because the action carries the prompt a second time inside `toJson(inputs)` and the
4343
escaped copy is the larger one: a Grafana dashboard PR measured 123,401 raw bytes and 135,366
44-
escaped, and failed on two consecutive pushes. Beneath that total each block holds a share — comment
45-
threads at most half, CI log excerpts a quarter, the PR conversation an eighth, and the two diff
46-
blocks sharing 3,000 patch lines.
44+
escaped, and failed on two consecutive pushes. Beneath that total every block holds a byte share —
45+
comment threads and the since-last-review diff at most half each, CI log excerpts a quarter, the PR
46+
conversation and the changed-file list an eighth — with the two diff blocks also sharing 3,000 patch
47+
lines. A line cap is not a byte cap: at the 1.20x a quote-dense patch costs, 2,000 lines of dashboard
48+
JSON is about 120 KB escaped, so the since-diff needed both.
4749

4850
The full diff is all-or-nothing. It renders whole or it is replaced by a notice naming its size and
4951
telling the reviewer to run `gh pr diff`. A prefix reads as the whole patch: what survives a cut is
@@ -55,8 +57,9 @@ can replace itself: `gh pr diff` is allowlisted and was refused 0 times in 54 at
5557
since-last-review diff keeps its prefix for the same reason inverted — `gh api .../compare` is not
5658
allowlisted, so trading its prefix for a notice would trade partial information for none.
5759

58-
With every block bounded, the tail cut on the assembled context is a backstop rather than the
59-
ordinary path; the PR body is the one block with no cap of its own, and it is what still reaches it.
60+
With every fetched block bounded, the tail cut on the assembled context is a backstop rather than the
61+
ordinary path. The PR body is what still reaches it: it arrives through `env:` rather than an API
62+
read, and a generated release-note body is the remaining way for a context to exceed the budget.
6063

6164
Everything reaching the prompt is attacker-controlled — title, body, diff, CI logs, comments — so the
6265
block delimiters are neutralised by shape rather than by exact string: `</pr_context >`,

scripts/gather-review-context.sh

Lines changed: 75 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,16 @@ else
474474
echo "::warning::Could not read changed files."
475475
FILES="Could not read changed files."
476476
fi
477-
{ echo; echo "## Changed files"; printf '%s\n' "$FILES"; } >> "$CTX"
477+
# Capped, because `--paginate` returns up to GitHub's 3,000-file ceiling and a line per
478+
# file is around 60 bytes -- 180 KB, over the whole budget, from a block with no cap of its
479+
# own. It matters more than its size suggests: this is the block the full diff's omission
480+
# notice sends the reviewer to, so it is the last one that should be able to overflow. An
481+
# eighth of the budget, the same share as the conversation.
482+
FILES_FILE="${RUNNER_TEMP}/changed-files.md"
483+
printf '%s\n' "$FILES" | strip_block_tags > "$FILES_FILE"
484+
cap_file_escaped "$FILES_FILE" $((PROMPT_BUDGET / 8)) \
485+
"changed file list truncated; read the rest with gh pr view --json files"
486+
{ echo; echo "## Changed files"; cat "$FILES_FILE"; } >> "$CTX"
478487

479488
# The reviewer cannot run tests -- no dependencies are installed and the allowlist
480489
# would refuse anyway -- but CI already ran them. Whether they passed is the one
@@ -587,13 +596,30 @@ if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" != "null" ] && [ "$LAST_SHA" != "$HEAD_SH
587596
SINCE_LINES=$(awk 'END {print NR}' "$SINCE_FILE")
588597
SINCE_USED=$SINCE_LINES
589598
if [ "$SINCE_USED" -gt "$SINCE_MAX" ]; then SINCE_USED=$SINCE_MAX; fi
599+
# Line-capped and then byte-capped, because a line cap does not bound bytes: at the
600+
# 1.20x this file measures for quote-dense JSON, 2,000 lines of dashboard patch is
601+
# about 120 KB escaped, which is over CTX_MAX_BYTES on its own. This block is written
602+
# *above* the full diff, so without the byte cap it is the block that drives the tail
603+
# cut on a cycle-2+ generated-file PR -- and what the tail cut then removes is the
604+
# full diff's omission notice and the conversation, not the since-diff that spent the
605+
# budget. Half the budget, the same share the threads block gets, so the pair of
606+
# blocks the reviewer cannot re-fetch can never crowd out everything else together.
607+
#
608+
# cap_file_escaped rather than a smaller SINCE_MAX: the prefix is still what this
609+
# block wants, for the reason the full diff below no longer keeps one -- `gh api
610+
# .../compare` is not allowlisted, so a notice in place of this patch leaves the
611+
# reviewer nothing it can fetch instead.
612+
SINCE_CAPPED="${RUNNER_TEMP}/since-capped.diff"
613+
head -n "$SINCE_MAX" "$SINCE_FILE" > "$SINCE_CAPPED"
614+
if [ "$SINCE_LINES" -gt "$SINCE_MAX" ]; then
615+
echo "(${NOTICE_LINES} ${SINCE_MAX} of ${SINCE_LINES} lines)" >> "$SINCE_CAPPED"
616+
fi
617+
cap_file_escaped "$SINCE_CAPPED" $((PROMPT_BUDGET / 2)) \
618+
"${NOTICE_LINES} ${SINCE_MAX} of ${SINCE_LINES} lines, then cut to fit the prompt"
590619
{
591620
echo
592621
echo "## Diff since your last review (${LAST_SHA} to ${HEAD_SHA})"
593-
head -n "$SINCE_MAX" "$SINCE_FILE"
594-
if [ "$SINCE_LINES" -gt "$SINCE_MAX" ]; then
595-
echo "(${NOTICE_LINES} ${SINCE_MAX} of ${SINCE_LINES} lines)"
596-
fi
622+
cat "$SINCE_CAPPED"
597623
} >> "$CTX"
598624
else
599625
{
@@ -614,6 +640,31 @@ fi
614640
FULL_DIFF_MAX=$((DIFF_BUDGET_LINES - SINCE_USED))
615641
if [ "$FULL_DIFF_MAX" -gt "$DIFF_MAX" ]; then FULL_DIFF_MAX=$DIFF_MAX; fi
616642

643+
# Issue comments, not the pull comments above: the PR conversation is a separate
644+
# endpoint from the inline review threads, and only the threads were ever passed.
645+
#
646+
# Fetched and capped here, above the full diff, and written below it -- see the write site
647+
# for why the two are separated. Capped in its own file rather than appended straight to
648+
# $CTX, so the cap is on this block and not on the whole context: appending first and
649+
# capping after is the tail cut, which is what put this block's heading off the end of the
650+
# prompt on www.hotdata.dev#332. Stripped before the cap for the reason strip_block_tags
651+
# always runs first -- the substitution grows the text, so a cap on the unstripped file
652+
# bounds a smaller string than the one emitted.
653+
ISSUE_COMMENTS_JQ='[.[][]] | if length == 0 then "No PR conversation comments." else sort_by(.created_at) | map("--- \(.user.login) at \(.created_at)\n\((.body // "")[0:3000])") | join("\n") end'
654+
if CONVO_JSON=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate); then
655+
CONVO=$(printf '%s' "$CONVO_JSON" | jq -s -r "$ISSUE_COMMENTS_JQ" 2>/dev/null) \
656+
|| CONVO="Could not parse PR conversation comments."
657+
else
658+
echo "::warning::Could not read PR conversation comments."
659+
CONVO="Could not read PR conversation comments."
660+
fi
661+
CONVO_FILE="${RUNNER_TEMP}/pr-conversation.md"
662+
printf '%s\n' "$CONVO" | strip_block_tags > "$CONVO_FILE"
663+
cap_file_escaped "$CONVO_FILE" "$CONVO_MAX_BYTES" \
664+
"${NOTICE_CONVO}; read the rest with gh pr view"
665+
# What the block will really cost, heading included, rather than what it was allowed to.
666+
CONVO_BYTES=$(($(escaped_bytes "$CONVO_FILE") + 100))
667+
617668
DIFF_FILE="${RUNNER_TEMP}/pr.diff"
618669
if fetch_raw "$DIFF_FILE" pr diff "$PR_NUMBER" --repo "$REPO"; then
619670
DIFF_LINES=$(awk 'END {print NR}' "$DIFF_FILE")
@@ -624,8 +675,15 @@ if fetch_raw "$DIFF_FILE" pr diff "$PR_NUMBER" --repo "$REPO"; then
624675
# the end of this file strips before it measures.
625676
strip_block_tags < "$CTX" > "${RUNNER_TEMP}/fit-ctx"
626677
strip_block_tags < "$DIFF_FILE" > "${RUNNER_TEMP}/fit-diff"
627-
DIFF_ALLOWANCE=$((CTX_MAX_BYTES - $(escaped_bytes "${RUNNER_TEMP}/fit-ctx") - CONVO_MAX_BYTES))
678+
DIFF_ALLOWANCE=$((CTX_MAX_BYTES - $(escaped_bytes "${RUNNER_TEMP}/fit-ctx") - CONVO_BYTES))
628679
DIFF_ESCAPED=$(escaped_bytes "${RUNNER_TEMP}/fit-diff")
680+
# Set in the omission branch below and read after the group command. `{ ... } >> file` is
681+
# a group, not a subshell, so the assignment survives -- which is the point: spelling the
682+
# condition a second time thirty lines down leaves the two free to drift, and the drift is
683+
# silent in exactly the direction that matters. The block would render the omission text
684+
# while the annotation said nothing, or the reverse, which is the invisible failure this
685+
# annotation exists to end.
686+
DIFF_OMITTED=0
629687
{
630688
echo
631689
echo "## Full diff"
@@ -651,6 +709,7 @@ if fetch_raw "$DIFF_FILE" pr diff "$PR_NUMBER" --repo "$REPO"; then
651709
# attempts over those two weeks. The since-diff above keeps its prefix precisely
652710
# because it has no such escape -- `gh api .../compare` is not allowlisted, so
653711
# trading its prefix for a notice would trade partial information for none.
712+
DIFF_OMITTED=1
654713
echo "(${NOTICE_OMITTED}; ${DIFF_LINES} lines)"
655714
echo
656715
echo "The patch is NOT below. Nothing has been shown to you and nothing has been"
@@ -665,8 +724,7 @@ if fetch_raw "$DIFF_FILE" pr diff "$PR_NUMBER" --repo "$REPO"; then
665724
echo " - Say in your review that the diff was omitted and which files you read."
666725
fi
667726
} >> "$CTX"
668-
if [ "$DIFF_LINES" -gt 0 ] \
669-
&& { [ "$DIFF_LINES" -gt "$FULL_DIFF_MAX" ] || [ "$DIFF_ESCAPED" -gt "$DIFF_ALLOWANCE" ]; }; then
727+
if [ "$DIFF_OMITTED" -eq 1 ]; then
670728
# A notice, not a warning, for the same reason the byte cap below uses one: a
671729
# generated-file PR reaches this legitimately and a warning that cried regression on
672730
# every one of them would stop being read. It exists so an operator reading a thin
@@ -680,25 +738,15 @@ else
680738
{ echo; echo "## Full diff"; echo "Could not read the diff; run gh pr diff."; } >> "$CTX"
681739
fi
682740

683-
# Issue comments, not the pull comments above: the PR conversation is a separate
684-
# endpoint from the inline review threads, and only the threads were ever passed.
685-
ISSUE_COMMENTS_JQ='[.[][]] | if length == 0 then "No PR conversation comments." else sort_by(.created_at) | map("--- \(.user.login) at \(.created_at)\n\((.body // "")[0:3000])") | join("\n") end'
686-
if CONVO_JSON=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate); then
687-
CONVO=$(printf '%s' "$CONVO_JSON" | jq -s -r "$ISSUE_COMMENTS_JQ" 2>/dev/null) \
688-
|| CONVO="Could not parse PR conversation comments."
689-
else
690-
echo "::warning::Could not read PR conversation comments."
691-
CONVO="Could not read PR conversation comments."
692-
fi
693-
# Capped in its own file rather than appended straight to $CTX, so the cap is on this block
694-
# and not on the whole context: appending first and capping after is the tail cut, which is
695-
# what put this block's heading off the end of the prompt on www.hotdata.dev#332. Stripped
696-
# before the cap for the reason strip_block_tags always runs first -- the substitution grows
697-
# the text, so a cap on the unstripped file bounds a smaller string than the one emitted.
698-
CONVO_FILE="${RUNNER_TEMP}/pr-conversation.md"
699-
printf '%s\n' "$CONVO" | strip_block_tags > "$CONVO_FILE"
700-
cap_file_escaped "$CONVO_FILE" "$CONVO_MAX_BYTES" \
701-
"${NOTICE_CONVO}; read the rest with gh pr view"
741+
# Written last, prepared above the full diff. The ordering argument for writing it last is
742+
# unchanged -- it is the block the reviewer can most afford to lose. But the diff's fit
743+
# decision has to subtract what this block will actually weigh, and a reserve of
744+
# CONVO_MAX_BYTES is not that: the common case is "No PR conversation comments.", 29 bytes,
745+
# and reserving an eighth of the budget against it hands back around 1,200 patch lines that
746+
# nothing will spend. That over-reserve was cheap while the shortfall cost the diff a
747+
# prefix; now it costs the whole block, so it would convert directly into omissions on pull
748+
# requests whose diff would have fit. Fetching here and measuring the capped file makes the
749+
# reserve exact. This endpoint does not depend on the diff, so nothing else moves.
702750
{ echo; echo "## PR conversation"; cat "$CONVO_FILE"; } >> "$CTX"
703751

704752
# CTX_MAX_BYTES is derived above, beside the threads cap it is computed from, because the

0 commit comments

Comments
 (0)