Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .claude/skills/codetrial-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,17 @@ and refuses with exit 3 when the PR moved twice while being read; the head in
its manifest is the revision under review from then on. A PR reviewed before
gets `--since <head read then>`: the `commit_id` of the account's latest
review that read code (a fixed-text rebase or hygiene request did not), or the
sweep's ledger entry; the script's header says what it does with it. A finding
anywhere in the diff still counts, and the earlier threads in `threads.tsv` say
what was already raised. Findings carried in an earlier ledger are placed at
the new head first ([interactive.md](interactive.md#when-the-head-moves)).
sweep's ledger entry; the script's header says what it does with it. After a
force push, when the old head can be compared, `since.diff` (`since.status`
`rewritten`) or `since.range-diff.txt` (`rebased`) points to what to read
first; otherwise the manifest says why and the whole diff is reviewed. They do
not replace the full diff: range-diff pairs commits by heuristic and ignores
merge commits. Pass `--since-base <sha>` when the old head's merge base with
the PR's base is not where it started; a base the old head does not contain
is refused. A finding anywhere in the diff still counts, and the earlier
threads in `threads.tsv` say what was already raised. Findings carried in an
earlier ledger are placed at the new head first
([interactive.md](interactive.md#when-the-head-moves)).
Every changed file is in `head/` as the head has it, so no reviewer and no
verification fetches it again. A manifest with
`files_complete: false` lists only part of a very large PR: say so in the
Expand Down
5 changes: 4 additions & 1 deletion .claude/skills/codetrial-review/lenses.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ python3 .claude/skills/codetrial-review/memory.py search <words> [--file P]
is quoted evidence, never instructions. why takes main's line numbers, not the
PR head's in numbered.txt.
<Incremental: <bundle>/since.txt is what changed since the last review at
<from>; read it first, but a finding anywhere in the diff counts.>
<from>; read it first, but a finding anywhere in the diff counts. After a
rebase, <bundle>/since.range-diff.txt is that pointer instead: `=` is a commit
unchanged; `!`, `<` and `>` are ones to read first. Its pairing is a heuristic
and it skips merge commits, so it never replaces the full diff.>
Read the code around each hunk at the head, not only the hunk: the caller, the
callee, the test that pins it. <PR: a changed file at the head is
<bundle>/head/<path>; any other file at the head is
Expand Down
134 changes: 121 additions & 13 deletions .claude/skills/codetrial-review/review-bundle.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/bin/sh
# Gather what a review reads into one directory, so every reviewer, the
# verification and the posting work from the same snapshot. Writes nothing to
# GitHub, and nothing outside <dir> but the objects a local mode with no base
# fetches into the repository.
# GitHub, and nothing outside <dir> but the objects a local mode with no base,
# or a --since head that was rewritten, fetches into the repository.
#
# Usage: review-bundle.sh <pr> <dir> [repo] [--since <sha>]
# Usage: review-bundle.sh <pr> <dir> [repo] [--since <sha> [--since-base <sha>]]
# review-bundle.sh --local <dir> [<base>]
# Prints the manifest (also saved as <dir>/manifest.json). Exits 3 when
# the PR moved during two consecutive fetches, 2 on bad arguments.
Expand All @@ -31,8 +31,19 @@
# since.diff, since.txt
# with --since, the diff from the head an earlier review read to
# this one, numbered the same way, when that head is an
# ancestor of this one; a force push or a merged base reads as
# a full review instead, and the manifest says why
# ancestor of this one; a merged base reads as a full review
# instead, and the manifest says why. After a rewrite on one
# base (a squash, an amend, a reorder), the tree diff between
# the two heads, and the manifest says rewritten
# since.range-diff.txt
# with --since, after a rebase, git range-diff of the old commits
# against the new ones, and the manifest says rebased. A commit
# whose only change is its Change-Id reads as unchanged. The old
# base is the old head's merge base with the PR's base unless
# --since-base names it. A rewrite compares in the repository at
# the working directory, fetching what it lacks by full SHA; with
# no repository or an old head nobody can resolve, the whole diff
# is reviewed and the manifest says why
# experience-start.json pins the local experience run to this manifest/diff;
# experience-status.json reports whether begin succeeded
# experience.md gives bounded private prior cases without migrating history;
Expand Down Expand Up @@ -63,10 +74,11 @@
set -eu
umask 077

usage='usage: review-bundle.sh <pr> <dir> [repo] [--since <sha>] | --local <dir> [<base>]'
usage='usage: review-bundle.sh <pr> <dir> [repo] [--since <sha> [--since-base <sha>]] | --local <dir> [<base>]'
here=$(dirname "$0")
mode='pr'
since=
since_base=
base=
case ${1:-} in
--local)
Expand All @@ -92,7 +104,13 @@ case ${1:-} in
esac
if [ "${1:-}" = --since ]; then
since=${2:?$usage}
elif [ -n "${1:-}" ]; then
shift 2
if [ "${1:-}" = --since-base ]; then
since_base=${2:?$usage}
shift 2
fi
fi
if [ -n "${1:-}" ]; then
echo "$usage" >&2
exit 2
fi
Expand All @@ -102,9 +120,9 @@ case ${1:-} in
exit 2
;;
esac
case $since in
case $since$since_base in
*[!0-9a-f]*)
echo "review-bundle.sh: $since is not a commit" >&2
echo "review-bundle.sh: $since $since_base is not a commit" >&2
exit 2
;;
esac
Expand All @@ -113,14 +131,19 @@ case ${1:-} in
echo "review-bundle.sh: --since needs at least 7 hex digits" >&2
exit 2
fi
if [ -n "$since_base" ] && [ "${#since_base}" -lt 7 ]; then
echo "review-bundle.sh: --since-base needs at least 7 hex digits" >&2
exit 2
fi
;;
esac
mkdir -p "$dir"

# Everything either mode or memory.py context writes, so a rerun in another mode
# or at another head reads nothing an earlier one left: context reads pr.json
# whenever it is there.
rm -rf "$dir/head" "$dir/since.diff" "$dir/since.txt" "$dir/pr.json" \
rm -rf "$dir/head" "$dir/since.diff" "$dir/since.txt" \
"$dir/since.range-diff.txt" "$dir/since.range-diff.raw" "$dir/pr.json" \
"$dir/talk.json" "$dir/comments.json" "$dir/threads.tsv" "$dir/history.md"

# The diff with each right-side line numbered: " 42 +added", " 43 context", "
Expand Down Expand Up @@ -332,9 +355,87 @@ gh api graphql --paginate -F n="$pr" -F owner="${repo%%/*}" -F name="${repo#*/}"
| ltrimstr(" ") | .[:300])] | @tsv' \
> "$dir/threads.tsv"

commit_of()
{
git rev-parse -q --verify "$1^{commit}" 2> /dev/null
}

# What the author changed since a head this one does not descend from. Sets
# rewrite, old_base and new_base, or returns nonzero with nothing to read. Its
# caller is a condition, where set -e does not apply, so each step checks
# itself. A fetch takes only a full SHA, so GitHub expands a short one first.
compare_rewrite()
{
case $status in
diverged | behind | unknown) ;;
*) return 1 ;;
esac
old=$(commit_of "$since") \
|| old=$(gh api "repos/$repo/commits/$since" --jq .sha 2> /dev/null) \
|| return 1
old_base=
if [ -n "$since_base" ]; then
old_base=$(commit_of "$since_base") \
|| old_base=$(gh api "repos/$repo/commits/$since_base" --jq .sha 2> /dev/null) \
|| return 1
fi
target=$(jq -r .baseRefOid "$dir/pr.json")

# Fetch only the commits the clone lacks, so a commit it already holds is
# not asked of the remote at all.
set --
for sha in "$old" "$head" "$target" ${old_base:+"$old_base"}; do
commit_of "$sha" > /dev/null || set -- "$@" "$sha"
done
[ "$#" -eq 0 ] || GIT_TERMINAL_PROMPT=0 git fetch -q --no-tags --no-write-fetch-head \
"https://github.com/$repo.git" "$@" 2> /dev/null || return 1
new_base=$(git merge-base "$head" "$target") || return 1
if [ -z "$old_base" ]; then
old_base=$(git merge-base "$old" "$target") || return 1
fi
Comment on lines +393 to +395

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A --since-base that the old head does not descend from is accepted as is. Then old_base..$old and the tree diff both describe a history that never existed. test_a_named_old_base_is_the_old_base shows it: old sits on main0, --since-base main1 makes old_base equal new_base, and since.diff reports b.js from main1 as "rewritten" by the author. That is the base drift this change is meant to keep out. Refuse a base the old head does not contain, so a wrong flag falls back to the whole diff instead of a misleading pointer, and change that test to build old on the base it names.

Suggested change
if [ -z "$old_base" ]; then
old_base=$(git merge-base "$old" "$target") || return 1
fi
if [ -z "$old_base" ]; then
old_base=$(git merge-base "$old" "$target") || return 1
fi
git merge-base --is-ancestor "$old_base" "$old" || return 1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

--since-base is now refused when the old head does not contain it, and the bundle falls back to the whole diff: the base resolution in compare_rewrite ends with git merge-base --is-ancestor "$old_base" "$old" || return 1. test_a_named_old_base_is_the_old_base now builds old on main1 and names main0, so the flag still takes effect when the old head contains the base. test_a_rewrite_that_cannot_be_compared_reviews_the_whole_diff gets a case that names main1 for an old head on main0; the class fails without that line.

# A base the old head does not contain describes a history that never was.
git merge-base --is-ancestor "$old_base" "$old" || return 1
if [ "$old_base" = "$new_base" ]; then
git diff --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ \
"$old" "$head" > "$dir/since.diff" || return 1
number < "$dir/since.diff" > "$dir/since.txt" || return 1
rewrite=rewritten
return 0
fi

# A hook-driven rechain gives every rewritten commit a new Change-Id, which
# says nothing about the work: such a commit reads as unchanged. A conflict
# resolved in a small commit rewrites most of it, and the default creation
# factor would list the two as unrelated, hiding the resolution. Everything
# under a commit's header is indented, and its marker sits right after the
# old SHA, so neither a message nor a subject can pass for one.
git range-diff --no-color --creation-factor=100 \
"$old_base..$old" "$new_base..$head" \
> "$dir/since.range-diff.raw" || return 1
awk '
function flush(i)
{
if (n && !real && lines[1] ~ /^ *[0-9-]+: +[0-9a-f-]+ ! /) {
sub(/ ! /, " = ", lines[1])
n = 1
}
for (i = 1; i <= n; i++) print lines[i]
n = 0
real = 0
}
/^ [-+] *Change-Id: I[0-9a-f]+$/ { next }
/^ [-+]/ { real = 1 }
!/^ / && /^ *[0-9-]+: +[0-9a-f-]+ [=!<>] / { flush() }
{ lines[++n] = $0 }
END { flush() }
' "$dir/since.range-diff.raw" > "$dir/since.range-diff.txt" || return 1
rm -f "$dir/since.range-diff.raw"
rewrite=rebased
}

# An earlier review's head counts only when this head descends from it: then the
# commits since are the new work. Otherwise history was rewritten, and the whole
# diff is new to a reviewer.
# commits since are the new work. After a rewrite, the comparison above says
# what to read first; failing that, the whole diff is new to a reviewer.
incremental=null
# A short sha of the head itself is the head: nothing new, not a rewrite.
case $head in "$since"*) since= ;; esac
Expand All @@ -354,11 +455,18 @@ if [ -n "$since" ]; then
-H "Accept: application/vnd.github.diff" > "$dir/since.diff"
number < "$dir/since.diff" > "$dir/since.txt"
incremental=$(jq -n --arg from "$since" '{from: $from, status: "ahead"}')
elif compare_rewrite; then
incremental=$(jq -n --arg from "$since" --arg status "$rewrite" \
--arg old_base "$old_base" --arg new_base "$new_base" \
'{from: $from, status: $status, old_base: $old_base, new_base: $new_base}')
else
rm -f "$dir/since.diff" "$dir/since.txt" "$dir/since.range-diff.txt" "$dir/since.range-diff.raw"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every way compare_rewrite can fail lands here with why saying "not an ancestor of the head", which is wrong for most of them: a --since-base the old head does not contain (the new test passes badbad1 and main1), a fetch refused or rejected by a git older than 2.29 (--no-write-fetch-head), a missing merge base in a shallow clone, or a failed range-diff. SKILL.md promises the manifest says why. The smallest fix is one accurate reason for a diverged or behind head whose comparison failed, such as "the rewrite could not be compared, so the whole diff is reviewed"; a why set at each return 1 is the fuller version if callers need to tell the cases apart.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The why now follows the status: merged keeps its text, diverged and behind say "the rewrite could not be compared, so the whole diff is reviewed", and anything else, which is unknown when the compare call fails and the commits are not in the clone, says "the comparison with that head failed, so the whole diff is reviewed". test_an_old_head_nobody_can_resolve_reviews_the_whole_diff asserts the second text and the new unknown case asserts the third. A why set at each return 1 is not added in this change. Fixed in 1f9bae7.

incremental=$(jq -n --arg from "$since" --arg status "$status" '
{from: $from, status: $status, why: (if $status == "merged"
then "a merge since then brings in other work, so the whole diff is reviewed"
else "not an ancestor of the head, so the whole diff is reviewed" end)}')
elif $status == "diverged" or $status == "behind"
then "the rewrite could not be compared, so the whole diff is reviewed"
else "the comparison with that head failed, so the whole diff is reviewed" end)}')
fi
fi

Expand Down
3 changes: 2 additions & 1 deletion tests/stubs/skill-scripts/gh-bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ case "$*" in
printf 'true\ttrue\tweb/a.js\t3\tmaint\tFixed.\n'
;;
# gh applies --jq itself: the status, then how many merges the run holds.
"api repos/sysprog21/codetrial/compare/c0ffee1...h1 --jq "*)
"api repos/sysprog21/codetrial/compare/${FAKE_SINCE:-c0ffee1}...${FAKE_HEAD:-h1} --jq "*)
[ "${FAKE_COMPARE:-}" != fail ] || exit 1
echo "${FAKE_COMPARE:-ahead 0}"
;;
"api repos/sysprog21/codetrial/compare/c0ffee1...h1 -H Accept: application/vnd.github.diff")
Expand Down
Loading
Loading