cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
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.
2026-09-20 17:15:16 +02:00

347 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 "$@"