Skip to content
Merged
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
69 changes: 69 additions & 0 deletions .claude/skills/codetrial-odds/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
name: codetrial-odds
description: >
List every open CodeTrial pull request with its merge confidence, size and
who it waits on, and what moved since the last run. Read-only.
disable-model-invocation: true
---

# How close each open pull request is to merging

A shortcut for one question the triage skill already answers: "which open PRs
could merge, and how sure is that". It runs only when typed as
`/codetrial-odds`, so it never competes with
[codetrial-triage](../codetrial-triage/SKILL.md) for a request described in
words; the grading rules stay in one place, [Rank by size and merge
confidence](../codetrial-triage/pulls.md#rank-by-size-and-merge-confidence),
and this file only says how to present them. It posts, approves and resolves
nothing.

## Take the snapshot

[snapshot.sh](snapshot.sh) runs the sweep against the last snapshot, which
fills `moved` and `gone`, and replaces it only with a whole sweep. When it
fails, stop and say so: a report from the old snapshot would read as today's.
Then read the fields the report needs from the snapshot file:

```sh
snap=$(.claude/skills/codetrial-odds/snapshot.sh) &&
jq '{viewer, maintainers_known, unknown, moved, gone, prerequisites,
overlap, collide, collide_unread,
status: [.status[] | {pr, title, author, author_is_maintainer, size,
mergeable, decision, grade, waits_on, waits_on_all, why,
files_truncated, checks_truncated: .checks.truncated}],
truncated: [.prs[] | select(.reviews_truncated or .comments_truncated
or .threads_truncated or .closes_truncated) | .number]}' "$snap"
```

Report the open count, and every PR in `unknown`, `truncated`, or with
`files_truncated` or `checks_truncated` set, since its grade rests on less
than the others'. When `maintainers_known` is false, `author_is_maintainer` is
null and a read-only collaborator's comment may have counted as a request; say
so.

## Present it

Group the PRs by `waits_on`, maintainer first, since those are the ones a merge
decision can act on today; within a group keep the sweep's order, smallest
first, and say the size is total lines split into source and tests. Give each
PR its grade, title and the `why` in plain words, and when `waits_on_all` names
more than `waits_on`, say who else still owes a move. Say that `high` with no
review yet means ready for a first review, not approved. Mark a PR whose
`author_is_maintainer` is true as a maintainer's own, and one by `viewer` as
the reader's own, which needs another reviewer.

End with the merge order the snapshot records, `prerequisites` and `collide`
(with each collision's path and function), read as
[pulls.md](../codetrial-triage/pulls.md#similar-pull-requests) says, and name
`collide_unread` PRs as not compared. Say nothing about order the snapshot
does not record. `overlap` sets no order: list its pairs apart, as two PRs
closing one issue.

Then what moved, read as the end of [Rank by size and merge
confidence](../codetrial-triage/pulls.md#rank-by-size-and-merge-confidence)
says. On a first run `moved` and `gone` are null: say this run is the first
baseline.

The grade reads only what GitHub records. Reading a PR's non-test diff can
lower it, never raise it without a named reason; do that only for the PRs the
user asks about, and say which grades rest on a diff read.
33 changes: 33 additions & 0 deletions .claude/skills/codetrial-odds/snapshot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/sh
# Refresh the /codetrial-odds snapshot: run pr-sweep.sh against the last one,
# and replace it only with a whole sweep, so a failed or interrupted run keeps
# the old baseline. It lives in the git directory beside the triage review
# ledger, so it survives sessions and is never tracked, one per repository,
# since the sweep matches PRs by number alone.
#
# Usage: snapshot.sh [repo] (default sysprog21/codetrial)
# Prints the snapshot's path; exits 1, printing nothing, when the sweep
# failed. ODDS_SWEEP names the pr-sweep.sh to run, for tests.

set -eu

repo=${1:-sysprog21/codetrial}
sweep=${ODDS_SWEEP:-$(dirname "$0")/../codetrial-triage/pr-sweep.sh}
dir="$(git rev-parse --git-common-dir)/codetrial-triage"
mkdir -p "$dir"
last=$dir/pr-sweep-$(printf '%s' "$repo" | tr / -).json

# An empty stale-days leaves pr-sweep.sh its own default.
set -- "$repo" ""
if [ -s "$last" ]; then
set -- "$@" "$last"
fi
tmp=$(mktemp "$dir/pr-sweep.XXXXXX")
trap 'rm -f "$tmp"' EXIT
if sh "$sweep" "$@" > "$tmp" && jq -e '.status | arrays' "$tmp" > /dev/null; then
mv "$tmp" "$last"
echo "$last"
else
echo "snapshot.sh: the sweep failed; the last snapshot is kept" >&2
exit 1
fi
115 changes: 66 additions & 49 deletions .claude/skills/codetrial-triage/post-inline.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
#!/bin/sh
# Post a sweep review's inline comments, one per finding, at the head they were
# read at. review-prs.md describes the draft and why a sweep posts it without a
# confirmation.
# Post a sweep review's inline comments at the head they were read at, as one
# review so the author gets one notification, not one per finding. review-prs.md
# describes the draft and why a sweep posts it without a confirmation.
#
# Usage: post-inline.sh <pr> <draft.json> [repo]
# Prints each posted comment's URL, and on stderr each finding it
# dropped and why. Exits 3 when the head moved, checked before anything
# is read and again before every post, so a push during the reads posts
# nothing; 1 when GitHub refused a comment; 2 on bad arguments.
# Prints the review's URL, or each comment's when it fell back to one
# post per finding, and on stderr each finding it dropped and why.
# Exits 3 when the head moved, checked before anything is read and
# again before every post, so a push during the reads posts nothing; 1
# when GitHub refused a comment; 2 on bad arguments.
#
# The draft is {sha, comments: [{path, line, start_line?, body}], summary}. Two
# kinds of finding are dropped rather than posted. One outside the diff, which
Expand Down Expand Up @@ -66,17 +67,11 @@ sh "$(dirname "$0")/../codetrial-verify/review-anchors.sh" "$pr" \
exit 1
}

# Each hunk's new-side span, one tab-separated "path first last" per row. Header
# handling is review-anchors.sh's; a hunk adding nothing on the right has no
# span.
# Each hunk's new-side span, one tab-separated "path first last" per row; a hunk
# adding nothing on the right has no span.
diff=$(gh pr diff "$pr" --repo "$repo")
printf '%s\n' "$diff" | awk '
function header_path(line, p) {
p = substr(line, 5)
sub(/\t.*$/, "", p)
if (p ~ /^".*"$/) p = substr(p, 2, length(p) - 2)
return p == "/dev/null" ? p : substr(p, 3)
}
printf '%s\n' "$diff" \
| awk "$(cat "$(dirname "$0")/../codetrial-verify/diff-path.awk")"'
/^diff --git / { header = 1; path = ""; next }
header && /^\+\+\+ / { path = header_path($0); next }
/^@@ / && path != "/dev/null" {
Expand All @@ -87,46 +82,68 @@ printf '%s\n' "$diff" | awk '
}
' > "$work/hunks"

gh api "repos/$repo/pulls/$pr/comments" --paginate > "$work/threads.json"

jq -n --slurpfile anchored "$work/anchored.json" \
--slurpfile threads "$work/threads.json" --arg sha "$sha" --arg me "$me" \
--rawfile hunks "$work/hunks" --slurpfile payload "$work/payload.json" '
[$hunks | split("\n")[] | select(length > 0) | split("\t")
| {path: .[0], first: (.[1] | tonumber), last: (.[2] | tonumber)}] as $spans
| def span: {path, first: (.start_line // .line), last: .line};
def overlaps($a; $b): $a.path == $b.path
and $a.first <= $b.last and $b.first <= $a.last;
($threads | add // [] | map(select(.commit_id == $sha))) as $here
| [$here[] | select(.user.login == "cubic-dev-ai[bot]" and .line != null
and .side == "RIGHT") | span] as $cubic
| def posted: . as $c | any($here[]; .user.login == $me
and .path == $c.path and .line == $c.line and .body == $c.body);
def claimed: span as $s
| any($spans[]; . as $h | overlaps($h; $s) and any($cubic[]; overlaps($h; .)));
($anchored[0].comments) as $inline
| def anchored: . as $c | any($inline[]; .path == $c.path and .line == $c.line
and .start_line == $c.start_line);
$payload[0].comments[]
| if anchored | not then {drop: "outside the diff"} + .
elif posted then {drop: "already posted at this head"} + .
elif claimed then {drop: "in a hunk cubic already commented on"} + .
else . end
' > "$work/sorted.json"
# Sorts the findings against the review threads as they stand, leaving those to
# post in $work/post. Run again after a refused review, since a review that
# landed with its answer lost reads as refused, and its findings are then
# already posted at this head; a second sort only ever drops more.
sort_findings()
{
gh api "repos/$repo/pulls/$pr/comments" --paginate \
> "$work/threads.json" < /dev/null
jq -n --slurpfile anchored "$work/anchored.json" \
--slurpfile threads "$work/threads.json" --arg sha "$sha" --arg me "$me" \
--rawfile hunks "$work/hunks" --slurpfile payload "$work/payload.json" '
[$hunks | split("\n")[] | select(length > 0) | split("\t")
| {path: .[0], first: (.[1] | tonumber), last: (.[2] | tonumber)}] as $spans
| def span: {path, first: (.start_line // .line), last: .line};
def overlaps($a; $b): $a.path == $b.path
and $a.first <= $b.last and $b.first <= $a.last;
($threads | add // [] | map(select(.commit_id == $sha))) as $here
| [$here[] | select(.user.login == "cubic-dev-ai[bot]" and .line != null
and .side == "RIGHT") | span] as $cubic
| def posted: . as $c | any($here[]; .user.login == $me
and .path == $c.path and .line == $c.line and .body == $c.body);
def claimed: span as $s
| any($spans[]; . as $h | overlaps($h; $s) and any($cubic[]; overlaps($h; .)));
($anchored[0].comments) as $inline
| def anchored: . as $c | any($inline[]; .path == $c.path and .line == $c.line
and .start_line == $c.start_line);
$payload[0].comments[]
| if anchored | not then {drop: "outside the diff"} + .
elif posted then {drop: "already posted at this head"} + .
elif claimed then {drop: "in a hunk cubic already commented on"} + .
else . end
' > "$work/sorted.json"
jq -c 'select(.drop | not)' "$work/sorted.json" > "$work/post"
}
sort_findings

# GitHub creates a review whole or not at all, so one refused comment refuses
# them all; only then is each posted on its own, so the rest still land.
status=0
if [ -s "$work/post" ]; then
same_head
jq -s --arg sha "$sha" '{commit_id: $sha, event: "COMMENT", body: "",
comments: .}' "$work/post" > "$work/review.json"
if gh api "repos/$repo/pulls/$pr/reviews" --input "$work/review.json" \
--jq .html_url < /dev/null 2> "$work/refused"; then
: > "$work/post"
else
why=$(head -1 "$work/refused")
echo "post-inline.sh: #$pr refused the review${why:+ ($why)};" \
"posting each finding" >&2
sort_findings
fi
fi
jq -r 'select(.drop) | "post-inline.sh: #'"$pr"' dropped \(.path):\(.line), \(.drop)"' \
"$work/sorted.json" >&2
jq -c --arg sha "$sha" 'select(.drop | not) | . + {commit_id: $sha}' \
"$work/sorted.json" > "$work/post"

# One at a time, so a refused comment does not stop the rest.
status=0
while IFS= read -r comment; do

# Before every write: a push mid-way leaves what already landed, which a
# rerun at the new head reads as not current, and posts nothing more.
same_head
printf '%s\n' "$comment" > "$work/one.json"
printf '%s\n' "$comment" | jq --arg sha "$sha" '. + {commit_id: $sha}' \
> "$work/one.json"
gh api "repos/$repo/pulls/$pr/comments" --input "$work/one.json" \
--jq .html_url < /dev/null || {
echo "post-inline.sh: #$pr refused $(jq -r '"\(.path):\(.line)"' "$work/one.json")" >&2
Expand Down
Loading