Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
347 lines
15 KiB
Bash
Executable File
347 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# Compact PR status for agents. Prints only what needs a decision.
|
||
#
|
||
# pr-digest [PR] one-shot digest
|
||
# pr-digest --watch [PR] poll until checks settle, then digest
|
||
# pr-digest --all [PR] re-print findings already shown once
|
||
# pr-digest --logs JOB_ID failing CI log, stripped of timestamp noise
|
||
# pr-digest --reply ID BODY reply to a review comment thread
|
||
#
|
||
# Exit: 0 green · 10 check failures · 11 open comments · 12 still pending
|
||
# 20 local/remote out of sync · 1 error
|
||
set -uo pipefail
|
||
|
||
POLL=${PR_DIGEST_POLL:-30}
|
||
DEADLINE=${PR_DIGEST_DEADLINE:-3600}
|
||
BODY_CHARS=${PR_DIGEST_BODY_CHARS:-600}
|
||
SEEN_DIR=${PR_DIGEST_SEEN_DIR:-$HOME/.cache/pr-digest}
|
||
# How many empty polls to allow before accepting that a PR has no check runs.
|
||
EMPTY_POLLS=${PR_DIGEST_EMPTY_POLLS:-3}
|
||
SHOW_ALL=0
|
||
|
||
die() { echo "pr-digest: $*" >&2; exit 1; }
|
||
|
||
# A failed API call must never look like a clean PR, so every fetch is checked.
|
||
# `die` here exits only the command substitution, so every caller carries its
|
||
# own `|| die` for the abort to reach the script.
|
||
# Keep stderr out of the payload — mixing it in corrupts JSON for jq.
|
||
api() {
|
||
local out
|
||
if ! out=$(gh api "$@" 2>/dev/null); then
|
||
die "gh api request failed (not retried)"
|
||
fi
|
||
printf '%s' "$out"
|
||
}
|
||
|
||
check_runs() {
|
||
api --paginate "repos/$1/commits/$2/check-runs?per_page=100" \
|
||
--jq '.check_runs[] | {name, status, conclusion, html_url}' | jq -s .
|
||
}
|
||
|
||
commit_statuses() {
|
||
api --paginate "repos/$1/commits/$2/status?per_page=100" \
|
||
--jq '.statuses[] | {context, state}' | jq -s .
|
||
}
|
||
|
||
logs() {
|
||
local job=$1 keep out
|
||
keep='^[0-9]+\) |^ *(Error|TimeoutError|AssertionError):|^ *(Locator|Expected|Received|Timeout|Call log):|^ *- (Expect|waiting for|locator resolved)|^ *>? *[0-9]+ \| |^ *\^|^ *at .*\.(spec|test)\.[jt]sx?:[0-9]|^ *[0-9]+ (failed|passed|flaky|skipped)|^ *(✘|✗|×) |^##\[error\]'
|
||
out=$(gh run view --job "$job" --log-failed 2>/dev/null |
|
||
perl -pe 's/^[^\t]*\t[^\t]*\t\S+\s//' |
|
||
grep -v '^\[WebServer\]' |
|
||
grep -E "$keep" |
|
||
perl -pe 's/\e\[[0-9;]*m//g' |
|
||
awk '!/^##\[error\]/ || !seen[$0]++' |
|
||
tail -n "${PR_DIGEST_LOG_LINES:-80}")
|
||
if [ -n "$out" ]; then
|
||
echo "$out"
|
||
return 0
|
||
fi
|
||
out=$(gh run view --job "$job" --log-failed 2>/dev/null |
|
||
perl -pe 's/^[^\t]*\t[^\t]*\t\S+\s//' |
|
||
grep -vE '^\s*$|^\[WebServer\]' | tail -n 40)
|
||
if [ -n "$out" ]; then
|
||
echo "$out"
|
||
return 0
|
||
fi
|
||
die "no failed log for job $job"
|
||
}
|
||
|
||
# The reply endpoint needs the pull number, which the comment itself carries.
|
||
reply() {
|
||
local repo id=$1 body=$2 pr
|
||
repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || die "not a GitHub repo"
|
||
pr=$(api "repos/$repo/pulls/comments/$id" --jq '.pull_request_url | split("/") | last')
|
||
[ -n "$pr" ] || die "could not resolve the pull request for comment $id"
|
||
api "repos/$repo/pulls/$pr/comments/$id/replies" -f body="$body" --jq '"replied \(.id)"'
|
||
}
|
||
|
||
digest() {
|
||
local pr=$1 repo=$2 viewer=$3
|
||
|
||
local meta head state mergeable branch
|
||
meta=$(gh pr view "$pr" --repo "$repo" \
|
||
--json headRefOid,state,mergeable,headRefName,reviewDecision) || die "no PR $pr"
|
||
head=$(jq -r .headRefOid <<<"$meta")
|
||
state=$(jq -r .state <<<"$meta")
|
||
mergeable=$(jq -r .mergeable <<<"$meta")
|
||
branch=$(jq -r .headRefName <<<"$meta")
|
||
local decision; decision=$(jq -r '.reviewDecision // ""' <<<"$meta")
|
||
|
||
echo "PR $pr · $branch · $state · $mergeable${decision:+ · $decision}"
|
||
[ "$state" = "OPEN" ] || { echo "VERDICT $state"; return 0; }
|
||
|
||
# Sync only matters when this checkout is on the PR's branch; inspecting
|
||
# someone else's PR from elsewhere is not a mismatch worth reporting. An
|
||
# upstream that will not resolve — a shallow clone, no tracking ref — is
|
||
# unknown rather than wrong, and must not mask the rest of the verdict.
|
||
local sync=0 local_sha upstream_sha
|
||
if [ "$(git branch --show-current 2>/dev/null)" = "$branch" ]; then
|
||
local_sha=$(git rev-parse HEAD 2>/dev/null || echo "")
|
||
upstream_sha=$(git rev-parse '@{upstream}' 2>/dev/null || echo "")
|
||
if { [ -n "$local_sha" ] && [ "$local_sha" != "$head" ]; } ||
|
||
{ [ -n "$upstream_sha" ] && [ "$upstream_sha" != "$head" ]; }; then
|
||
echo "OUT OF SYNC local=${local_sha:0:8} upstream=${upstream_sha:0:8} pr=${head:0:8}"
|
||
sync=20
|
||
fi
|
||
fi
|
||
|
||
local runs statuses pending failed
|
||
runs=$(check_runs "$repo" "$head") || die "could not read check runs"
|
||
statuses=$(commit_statuses "$repo" "$head") || die "could not read commit statuses"
|
||
|
||
# Legacy commit statuses gate a PR exactly like check runs do, so they have to
|
||
# reach the verdict rather than only being printed.
|
||
pending=$(( $(jq '[.[] | select(.status != "completed")] | length' <<<"$runs") +
|
||
$(jq '[.[] | select(.state == "pending")] | length' <<<"$statuses") ))
|
||
failed=$(( $(jq '[.[] | select(.conclusion != null and (.conclusion | test("^(success|neutral|skipped)$") | not))] | length' <<<"$runs") +
|
||
$(jq '[.[] | select(.state | test("^(failure|error)$"))] | length' <<<"$statuses") ))
|
||
|
||
jq -r '"checks " + (if length == 0 then "none"
|
||
else ([.[].conclusion // "pending"] | group_by(.) | map("\(length) \(.[0])") | join(" · ")) end)' <<<"$runs"
|
||
jq -r '.[] | select(.state != "success") | "STATUS \(.context) \(.state)"' <<<"$statuses"
|
||
|
||
# Report the failed step and skipped successors without inferring whether
|
||
# tests ran: a test assertion failure can also skip later report steps.
|
||
if [ "$failed" -gt 0 ]; then
|
||
while IFS=$'\t' read -r name conclusion url; do
|
||
local job step_info=""
|
||
job=${url##*/job/}
|
||
if [[ $job =~ ^[0-9]+$ ]]; then
|
||
step_info=$(gh api "repos/$repo/actions/jobs/$job" --jq '
|
||
(.steps // []) as $s
|
||
| ([$s[] | select(.conclusion == "failure" or .conclusion == "cancelled")] | first) as $bad
|
||
| if $bad == null then ""
|
||
else " · step \"\($bad.name)\" \($bad.conclusion)"
|
||
+ (if ([$s[] | select(.number > $bad.number and .conclusion == "skipped")] | length) > 0
|
||
then " (later steps skipped)" else "" end)
|
||
end' 2>/dev/null)
|
||
fi
|
||
echo "FAIL $name [$conclusion]$step_info"
|
||
echo " job=$job $url"
|
||
done < <(jq -r '.[] | select(.conclusion != null and (.conclusion | test("^(success|neutral|skipped)$") | not)) | [.name,.conclusion,.html_url] | @tsv' <<<"$runs")
|
||
fi
|
||
|
||
# Review bots pad bodies with collapsed sections, HTML markers and badge
|
||
# images; the prose around them is the whole finding. Remove each <details>
|
||
# block from the inside out — truncating at the first one drops the prose
|
||
# that follows it, and cutting to the last one drops the prose between.
|
||
local clean_def='
|
||
def clean:
|
||
gsub("<!--[\\s\\S]*?-->"; "")
|
||
| reduce range(0; 6) as $_ (.;
|
||
sub("<details>(?:(?!<details>)[\\s\\S])*?</details>"; ""))
|
||
| gsub("<img[^>]*alt=\"(?<a>[^\"]*)\"[^>]*>"; "\(.a)")
|
||
| gsub("<[^>]+>"; "") | gsub("\\s+"; " ")
|
||
| explode
|
||
| map(select(. >= 32 and (. < 127 or . > 159)))
|
||
| implode
|
||
| sub("^ +"; "") | sub(" +$"; "") | .[0:$n];'
|
||
|
||
# Deploy tables, rate-limit notices, screenshot galleries, and sticky bot
|
||
# summaries are status chrome — not findings that need a reply. Actionable
|
||
# bot conversation comments are not noise and must block green.
|
||
local noise_def='
|
||
def noise:
|
||
.user.type == "Bot" and (
|
||
(.user.login == "vercel[bot]" and (.body | startswith("[vc]: ")))
|
||
or (.user.login == "coderabbitai[bot]" and (.body |
|
||
startswith("<!-- This is an auto-generated comment: summarize by coderabbit.ai -->")
|
||
or startswith("<!-- This is an auto-generated comment: rate limited by coderabbit.ai -->")))
|
||
or (.user.login == "greptile-apps[bot]" and (.body | startswith("<h3>Greptile Summary</h3>")))
|
||
or (.user.login == "github-actions[bot]" and (.body |
|
||
startswith("<!-- rakazo-playwright-screenshots -->\n### Playwright screenshots")))
|
||
);'
|
||
|
||
# Compare complete URL tokens, never substrings or bare comment IDs.
|
||
local link_def='
|
||
def links($url):
|
||
[scan("https?://[^\\s<>\\\"()\\[\\]]+")] | map(sub("[.,;:!?]+$"; "")) | index($url) != null;'
|
||
|
||
# Inline review threads. One stays open until *you* have replied to it; a bot
|
||
# follow-up confirming your fix must not mark it handled.
|
||
local inline open
|
||
inline=$(api --paginate "repos/$repo/pulls/$pr/comments?per_page=100" | jq -s 'add // []') \
|
||
|| die "could not read review comments"
|
||
open=$(jq --arg me "$viewer" --argjson n "$BODY_CHARS" "
|
||
$clean_def
|
||
[.[] | select(.in_reply_to_id != null and .user.login == \$me)] as \$mine
|
||
| [.[] | select(.in_reply_to_id == null and .user.login != \$me)
|
||
| . as \$root
|
||
| select([\$mine[] | select(.in_reply_to_id == \$root.id
|
||
and .created_at > \$root.updated_at)] | length == 0)
|
||
| {key: \"\(.id):\(.updated_at)\", id, author: .user.login, path,
|
||
line: (.line // .original_line), body: (.body | clean)}]" <<<"$inline")
|
||
|
||
# Conversation comments have no threads. One stays open until *you* post a
|
||
# later conversation comment that quotes its permalink; any later post of
|
||
# yours is not enough, and a bot confirming a fix must not close it. Compare
|
||
# against updated_at so an edit after your reply reopens the finding.
|
||
local convo noise_n convo_open
|
||
convo=$(api --paginate "repos/$repo/issues/$pr/comments?per_page=100" | jq -s 'add // []') \
|
||
|| die "could not read conversation comments"
|
||
noise_n=$(jq "
|
||
$noise_def
|
||
[.[] | select(noise)] | length" <<<"$convo")
|
||
convo_open=$(jq --arg me "$viewer" --argjson n "$BODY_CHARS" "
|
||
$clean_def
|
||
$noise_def
|
||
$link_def
|
||
[.[] | select(.user.login == \$me)
|
||
| {id, created_at, body, html_url}] as \$mine
|
||
| [.[]
|
||
| select(.user.login != \$me)
|
||
| select(noise | not)
|
||
| . as \$c
|
||
| select(
|
||
[\$mine[]
|
||
| select(.created_at > \$c.updated_at
|
||
and (.body | links(\$c.html_url)))]
|
||
| length == 0)
|
||
| {key: \"\(.id):\(.updated_at)\", id, author: .user.login,
|
||
path: \"(conversation)\", line: 0, body: (.body | clean)}]" <<<"$convo")
|
||
|
||
# A human can request changes in a review summary with no line comment and no
|
||
# conversation comment attached, so that surface has to be read too.
|
||
local reviews review_open
|
||
reviews=$(api --paginate "repos/$repo/pulls/$pr/reviews?per_page=100" | jq -s 'add // []') \
|
||
|| die "could not read reviews"
|
||
review_open=$(jq --arg me "$viewer" --argjson n "$BODY_CHARS" "
|
||
$clean_def
|
||
group_by(.user.login)
|
||
| map(sort_by(.submitted_at, .id)
|
||
| map(select(.state == \"APPROVED\" or .state == \"CHANGES_REQUESTED\" or .state == \"DISMISSED\"))
|
||
| last)
|
||
| [.[] | select(. != null and .user.login != \$me)
|
||
| select(.state == \"CHANGES_REQUESTED\" and (.body // \"\") != \"\")
|
||
| {key: \"r\(.id):\(.submitted_at)\", id, author: .user.login,
|
||
path: \"(review \(.state))\", line: 0, body: (.body | clean)}]" <<<"$reviews")
|
||
|
||
open=$(jq -s 'add' <<<"$open$convo_open$review_open")
|
||
local open_n seen_file
|
||
open_n=$(jq length <<<"$open")
|
||
echo "comments $(jq length <<<"$inline") inline · $(jq length <<<"$convo") conversation ($noise_n noise) · $(jq length <<<"$reviews") reviews · $open_n awaiting your reply"
|
||
[ "$decision" = "CHANGES_REQUESTED" ] && echo "REVIEW changes requested"
|
||
|
||
if [ "$open_n" -gt 0 ]; then
|
||
# Show a finding in full once. On later cycles it stays listed — nothing is
|
||
# hidden — but as a single line, so re-observing an unfixed PR stays cheap.
|
||
seen_file="$SEEN_DIR/${repo//\//-}-$pr"
|
||
mkdir -p "$SEEN_DIR"; touch "$seen_file"
|
||
if [ "$SHOW_ALL" = 1 ]; then
|
||
jq -r '.[] | "\n[\(.id)] \(.author) \(.path):\(.line)\n \(.body)"' <<<"$open"
|
||
else
|
||
jq -r --rawfile seen "$seen_file" '
|
||
($seen | split("\n")) as $s
|
||
| .[] | .key as $k
|
||
| if ($s | index($k)) then
|
||
"[\(.id)] \(.author) \(.path):\(.line) — still open (shown earlier)"
|
||
else "\n[\(.id)] \(.author) \(.path):\(.line)\n \(.body)" end' <<<"$open"
|
||
fi
|
||
jq -r '.[].key' <<<"$open" >> "$seen_file"
|
||
sort -u -o "$seen_file" "$seen_file"
|
||
fi
|
||
|
||
# A verdict describes one commit. If the head moved while we were reading,
|
||
# everything above may already be stale. A failed re-read is an error, not
|
||
# an out-of-sync signal — callers must not push to "reconcile" a blind miss.
|
||
local head_now
|
||
head_now=$(gh pr view "$pr" --repo "$repo" --json headRefOid --jq .headRefOid) \
|
||
|| die "could not re-read PR $pr head"
|
||
if [ "$head_now" != "$head" ]; then
|
||
echo "VERDICT out-of-sync (head moved during this observation)"
|
||
return 20
|
||
fi
|
||
|
||
if [ "$sync" -ne 0 ]; then echo "VERDICT out-of-sync"; return 20; fi
|
||
if [ "$failed" -gt 0 ]; then echo "VERDICT failures"; return 10; fi
|
||
if [ "$open_n" -gt 0 ] || [ "$decision" = "CHANGES_REQUESTED" ]; then
|
||
echo "VERDICT open-comments"; return 11
|
||
fi
|
||
if [ "$pending" -gt 0 ]; then echo "VERDICT pending ($pending)"; return 12; fi
|
||
echo "VERDICT green"
|
||
return 0
|
||
}
|
||
|
||
main() {
|
||
local watch=0 pr=""
|
||
while [ $# -gt 0 ]; do
|
||
case $1 in
|
||
--watch) watch=1 ;;
|
||
--all) SHOW_ALL=1 ;;
|
||
--logs)
|
||
shift
|
||
command -v perl >/dev/null || die "perl not found"
|
||
logs "${1:?job id}"
|
||
exit $?
|
||
;;
|
||
--reply) shift; reply "${1:?comment id}" "${2:?reply body}"; exit $? ;;
|
||
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
|
||
*) pr=$1 ;;
|
||
esac
|
||
shift
|
||
done
|
||
|
||
command -v gh >/dev/null || die "gh not found"
|
||
command -v jq >/dev/null || die "jq not found"
|
||
|
||
local repo viewer
|
||
repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || die "not a GitHub repo"
|
||
# Prefer GraphQL: integration tokens often cannot GET /user, and a failed
|
||
# REST call still writes its error body to stdout — composing it with
|
||
# `$(a || b)` would concatenate that body onto the real login.
|
||
viewer=$(gh api graphql -f query='query { viewer { login } }' --jq .data.viewer.login 2>/dev/null) \
|
||
|| viewer=$(gh api user --jq .login 2>/dev/null) \
|
||
|| die "could not resolve authenticated user"
|
||
case $viewer in ''|null|\{*) die "could not resolve authenticated user" ;; esac
|
||
[ -n "$pr" ] || pr=$(gh pr view --json number --jq .number 2>/dev/null) || die "no PR for this branch"
|
||
|
||
if [ "$watch" -eq 1 ]; then
|
||
local started=$SECONDS head runs statuses empty=0 left
|
||
while :; do
|
||
head=$(gh pr view "$pr" --repo "$repo" --json headRefOid --jq .headRefOid) \
|
||
|| die "could not read PR head"
|
||
runs=$(check_runs "$repo" "$head") || die "could not read check runs"
|
||
statuses=$(commit_statuses "$repo" "$head") || die "could not read commit statuses"
|
||
if [ "$(jq '[.[] | select(.status != "completed")] | length' <<<"$runs")" = "0" ] &&
|
||
[ "$(jq '[.[] | select(.state == "pending")] | length' <<<"$statuses")" = "0" ]; then
|
||
# Nothing outstanding. Wait a few polls before believing a PR that has
|
||
# reported nothing at all, in case checks are still registering.
|
||
if [ "$(jq 'length' <<<"$runs")" != "0" ] ||
|
||
[ "$(jq 'length' <<<"$statuses")" != "0" ] ||
|
||
[ "$empty" -ge "$EMPTY_POLLS" ]; then
|
||
break
|
||
fi
|
||
empty=$((empty + 1))
|
||
fi
|
||
left=$((DEADLINE - (SECONDS - started)))
|
||
[ "$left" -le 0 ] && break
|
||
sleep "$(( POLL < left ? POLL : left ))"
|
||
done
|
||
fi
|
||
|
||
digest "$pr" "$repo" "$viewer"
|
||
}
|
||
|
||
main "$@"
|