#!/usr/bin/env bash # Live status board for a Gadfly review. # # Each model process (the cmd/gadfly binary) publishes its per-lens progress to # $GADFLY_STATUS_DIR/.json as lenses go queued -> running -> finished. # This script polls that directory and upserts ONE consolidated PR comment that # aggregates every model's per-lens status, so a human (or an agent watching the # PR) can see the whole swarm's progress at a glance and know when it's done — # instead of staring at N separate "⏳ Reviewing…" placeholders. # # It is advisory and best-effort: a failed render/post is logged and retried on # the next tick; nothing here can fail the review or block a merge. It runs in # the background from entrypoint.sh and exits once the $GADFLY_STATUS_DIR/.done # sentinel appears (the entrypoint touches it after all model lanes finish), # after one final render. # # Required env: # GITEA_API https://HOST/api/v1/repos/OWNER/REPO # GITEA_TOKEN token with repo write access (posts the comment) # PR pull request number # GADFLY_STATUS_DIR directory holding the per-model .json files # Optional: # GADFLY_STATUS_POLL_SECS render/upsert interval (default 12) set -uo pipefail : "${GITEA_API:?GITEA_API required}" : "${GITEA_TOKEN:?GITEA_TOKEN required}" : "${PR:?PR required}" : "${GADFLY_STATUS_DIR:?GADFLY_STATUS_DIR required}" POLL="${GADFLY_STATUS_POLL_SECS:-12}" # Guard against a non-numeric poll interval: with `set -uo pipefail` (no set -e) # a bad `sleep "$POLL"` would fail silently and the `while :` loop would spin, # hammering the Gitea API. Coerce anything non-integer (or <1) back to 12. case "$POLL" in ''|*[!0-9]*) POLL=12 ;; esac [ "$POLL" -ge 1 ] 2>/dev/null || POLL=12 DONE_FILE="${GADFLY_STATUS_DIR}/.done" MARKER="" API_TIMEOUT="--connect-timeout 20 --max-time 30" BOARD_ID="" # cached comment id, so we PATCH in place instead of re-searching say() { echo "[gadfly-status-board] $*" >&2; } command -v jq >/dev/null 2>&1 || { say "jq not found; status board disabled"; exit 0; } # render_section FILE -> markdown for one model (its header + per-lens bullets). # Reads the JSON the binary writes; tolerates a half-written/missing file by # emitting nothing (jq exits non-zero -> caller skips it this tick). render_section() { jq -r ' def icon(state; errored): if state == "finished" then (if errored then "⚠️" else "✅" end) elif state == "running" then "🔄" else "⏸️" end; def lensline: "- " + icon(.state; (.errored // false)) + " **" + .name + "** — " + ( if .state == "finished" then (if (.errored // false) then "could not complete" else (.verdict // "done") end) elif .state == "running" then "running" else "queued" end ); ( [.lenses[] | select(.state == "finished")] | length ) as $fin | ( .lenses | length ) as $tot | ( if .done then "✅ done" elif $tot == 0 then "⏳ waiting to start" else "⏳ " + ($fin|tostring) + "/" + ($tot|tostring) + " lenses" end ) as $sum | "#### `" + .model + "` · " + .provider + " — " + $sum + "\n" + ( if $tot == 0 then "- ⏸️ _no lenses reported yet_" else ([.lenses[] | lensline] | join("\n")) end ) ' "$1" 2>/dev/null } # render_body -> the full consolidated comment body (marker + header + sections). render_body() { local f sections="" total=0 done=0 ts shopt -s nullglob local files=("${GADFLY_STATUS_DIR}"/*.json) shopt -u nullglob for f in "${files[@]}"; do local sec sec="$(render_section "$f")" || continue [ -z "$sec" ] && continue total=$((total + 1)) if [ "$(jq -r 'if .done then 1 else 0 end' "$f" 2>/dev/null)" = "1" ]; then done=$((done + 1)) fi sections="${sections}${sec}"$'\n\n' done ts="$(date -u '+%Y-%m-%d %H:%M:%SZ')" if [ "$total" -eq 0 ]; then sections="_Waiting for reviewers to start…_"$'\n' fi printf '%s\n## 🪰 Gadfly — live review status\n\n%d/%d reviewers finished · updated %s\n\n%s\nLive status board. Findings are posted in each model'\''s own comment. Advisory only — does not block merge.' \ "$MARKER" "$done" "$total" "$ts" "$sections" } # find_existing -> id of the board comment if it already exists (paginate by # marker). Used once, to recover the comment across container restarts. find_existing() { local page=1 cmts id while [ "$page" -le 10 ]; do cmts="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" \ "${GITEA_API}/issues/${PR}/comments?limit=50&page=${page}" 2>/dev/null || echo '[]')" [ "$(echo "$cmts" | jq 'length' 2>/dev/null || echo 0)" = "0" ] && break id="$(echo "$cmts" | jq -r --arg m "$MARKER" \ '.[] | select(.body != null and (.body | startswith($m))) | .id' 2>/dev/null | head -n1)" [ -n "$id" ] && { echo "$id"; return; } page=$((page + 1)) done echo "" } # upsert BODY — PATCH the cached/known board comment, else POST a new one and # cache its id. A failed PATCH (e.g. comment deleted) clears the cache so the # next tick re-discovers or re-creates it. upsert() { local body="$1" post_body resp post_body="$(jq -n --arg b "$body" '{body:$b}')" [ -z "$BOARD_ID" ] && BOARD_ID="$(find_existing)" if [ -n "$BOARD_ID" ]; then if ! curl $API_TIMEOUT -fsS -X PATCH -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \ "${GITEA_API}/issues/comments/${BOARD_ID}" -d "$post_body" >/dev/null 2>&1; then say "patch of comment ${BOARD_ID} failed; will re-discover" BOARD_ID="" fi else resp="$(curl $API_TIMEOUT -fsS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \ "${GITEA_API}/issues/${PR}/comments" -d "$post_body" 2>/dev/null || echo '{}')" BOARD_ID="$(echo "$resp" | jq -r '.id // ""' 2>/dev/null)" fi } say "starting (poll ${POLL}s, dir ${GADFLY_STATUS_DIR})" while :; do upsert "$(render_body)" [ -f "$DONE_FILE" ] && break sleep "$POLL" done say "done"