Build & push image / build-and-push (pull_request) Successful in 3s
Eight findings, all real, and the sharpest ones are about this PR's own fixes. GADFLY_API_KEY was treated as a universal substitute in the pre-flight. It is not: resolveModel reads it only AFTER the `baseURL == ""` early return, so on the registry path — the documented primary path — a qwen/kimi built-in reads its own variable and GADFLY_API_KEY is never consulted. A mis-set GADFLY_API_KEY therefore passed pre-flight and 401'd five times anyway, which is precisely the failure this check exists to prevent. It now only substitutes when GADFLY_BASE_URL is also set. `openai-compatible` was missing from the pre-flight table while both switches accept it as an OPENAI_API_KEY alias, so that one spelling still fell through to the cryptic five-failure mode. endpointProviderNames — the constant I introduced *to stop* the two error messages drifting — omitted the `gemini` alias both switches accept. It now lists every accepted spelling. And the case list itself was still duplicated across both switches plus the test that pins them: three copies of the thing whose duplication started this. Both switches now call isOpenAICompatProvider over one shared slice, and endpointProvider's doc comment points at endpointProviderNames instead of carrying a fourth hand-written copy. scripts/preflight_test.sh moves into the repo (20 cases, up from 17, covering openai-compatible and both GADFLY_API_KEY directions). It carries a drift guard that diffs its copy of the provider table against run.sh's and aborts if they differ — break-checked by deleting an arm from run.sh, which fails it loudly. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
281 lines
14 KiB
Bash
281 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
# Adversarial PR review runner.
|
|
#
|
|
# Fetches a PR's unified diff + metadata from Gitea, asks ONE model to review it
|
|
# adversarially, then upserts the result as a single labeled PR comment (so
|
|
# re-runs on new commits update the comment in place instead of stacking dupes).
|
|
#
|
|
# The ollama lane is AGENTIC: it runs the cmd/gadfly Go binary, which drives a
|
|
# tool-using agent (majordomo + Ollama Cloud) over the PR's checked-out repo so
|
|
# the model can read_file/grep/etc. to VERIFY findings instead of guessing from
|
|
# the diff alone. The antigravity lane stays a one-shot `agy` call (agy has its
|
|
# own file tools).
|
|
#
|
|
# Required env:
|
|
# GITEA_API e.g. https://gitea.stevedudenhoeffer.com/api/v1/repos/steve/mort
|
|
# GITEA_TOKEN token with repo write access (posts the comment)
|
|
# PR pull request index/number
|
|
# PROVIDER "ollama" | "antigravity"
|
|
# MODEL model id (e.g. qwen3-coder:480b-cloud, gemini-3-pro)
|
|
#
|
|
# Provider-specific env:
|
|
# ollama: OLLAMA_CLOUD_API_KEY, GADFLY_BIN (path to the built reviewer),
|
|
# GADFLY_REPO_DIR (checked-out repo; default: this script's repo)
|
|
# antigravity: `agy` on PATH with credentials already seeded (~/.gemini)
|
|
#
|
|
# claude-code engine: when MODEL is "claude-code" or "claude-code/<model>" the
|
|
# binary shells out to the bundled `claude` CLI instead of a majordomo model.
|
|
# Its auth (CLAUDE_CODE_OAUTH_TOKEN, else ANTHROPIC_API_KEY) and GADFLY_CLAUDE_*
|
|
# tuning are read straight from the inherited environment — same as the other
|
|
# provider keys (OPENAI_API_KEY, …) — so no extra wiring is needed here.
|
|
#
|
|
# opencode engine: when MODEL is "opencode" or "opencode/<model>" the binary
|
|
# shells out to the bundled `opencode` CLI, driving an ollama-cloud model (for
|
|
# benchmarking against the majordomo path on the same model). Its auth reuses
|
|
# OLLAMA_CLOUD_API_KEY (mapped to OLLAMA_API_KEY below, same as the ollama-cloud
|
|
# path) and GADFLY_OPENCODE_* tuning is read from the inherited environment — so
|
|
# no extra wiring is needed here either.
|
|
#
|
|
# Optional:
|
|
# MAX_DIFF_CHARS diff truncation cap for the prompt (default 60000)
|
|
# GADFLY_STATUS_FILE per-model JSON path for the live status board (set by
|
|
# entrypoint.sh; empty/unset disables status publishing)
|
|
#
|
|
# This script is advisory: it never fails the job for review content. It exits
|
|
# non-zero only on a usage/configuration error.
|
|
set -uo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
MAX_DIFF_CHARS="${MAX_DIFF_CHARS:-60000}"
|
|
|
|
: "${GITEA_API:?GITEA_API required}"
|
|
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
|
|
: "${PR:?PR required}"
|
|
: "${PROVIDER:?PROVIDER required}"
|
|
: "${MODEL:?MODEL required}"
|
|
|
|
MARKER="<!-- gadfly-review:${PROVIDER}:${MODEL} -->"
|
|
say() { echo "[gadfly-review:${PROVIDER}:${MODEL}] $*" >&2; }
|
|
|
|
# When the swarm is consolidating (GADFLY_CONSOLIDATE=1, set by entrypoint.sh for
|
|
# a multi-model run), this model does NOT post its own comment — it writes its
|
|
# findings to GADFLY_FINDINGS_OUT and a single cross-model consensus comment is
|
|
# posted after the whole swarm finishes. Live progress still shows on the status
|
|
# board. Default 0 (post a per-model comment, the standalone behavior).
|
|
CONSOLIDATE="${GADFLY_CONSOLIDATE:-0}"
|
|
|
|
# Display the model's ACTUAL backend: the provider segment of the spec
|
|
# ("m1pro/qwen3.6:35b-mlx" -> "m1pro"); a bare id uses GADFLY_PROVIDER (default
|
|
# ollama-cloud). This is what the comment header shows, not the run.sh lane.
|
|
case "$MODEL" in
|
|
*/*) MODEL_PROVIDER="${MODEL%%/*}" ;;
|
|
*) MODEL_PROVIDER="${GADFLY_PROVIDER:-ollama-cloud}" ;;
|
|
esac
|
|
|
|
# jq is required for payload building / response parsing; install if missing.
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
say "jq not found; attempting install"
|
|
{ apt-get update -qq && apt-get install -y -qq jq; } >/dev/null 2>&1 \
|
|
|| { sudo apt-get update -qq && sudo apt-get install -y -qq jq; } >/dev/null 2>&1 \
|
|
|| { say "could not install jq"; exit 1; }
|
|
fi
|
|
|
|
# curl timeouts: Gitea API calls are quick. Word-split on purpose so the flags
|
|
# expand as separate args. (The LLM call's own deadline lives in the reviewer
|
|
# binary / agy, not here.)
|
|
API_TIMEOUT="--connect-timeout 20 --max-time 30"
|
|
|
|
# upsert_comment BODY — create or update (by MARKER) this model's single comment.
|
|
upsert_comment() {
|
|
local body="$1" post_body existing_id page=1 cmts
|
|
post_body="$(jq -n --arg b "$body" '{body:$b}')"
|
|
existing_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}" || echo '[]')"
|
|
[ "$(echo "$cmts" | jq 'length')" = "0" ] && break
|
|
existing_id="$(echo "$cmts" | jq -r --arg m "$MARKER" \
|
|
'.[] | select(.body != null and (.body | startswith($m))) | .id' | head -n1)"
|
|
[ -n "$existing_id" ] && break
|
|
page=$((page+1))
|
|
done
|
|
if [ -n "$existing_id" ]; then
|
|
curl $API_TIMEOUT -sS -X PATCH -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
|
|
"${GITEA_API}/issues/comments/${existing_id}" -d "$post_body" >/dev/null
|
|
else
|
|
curl $API_TIMEOUT -sS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
|
|
"${GITEA_API}/issues/${PR}/comments" -d "$post_body" >/dev/null
|
|
fi
|
|
}
|
|
|
|
# fmt_duration SECONDS -> "1m 23s" / "45s"
|
|
fmt_duration() { if [ "$1" -ge 60 ]; then echo "$(($1/60))m $(($1%60))s"; else echo "$1s"; fi; }
|
|
|
|
# --- fetch PR context -------------------------------------------------------
|
|
say "fetching PR #${PR} context"
|
|
DIFF="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API}/pulls/${PR}.diff" || true)"
|
|
META="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API}/pulls/${PR}" || echo '{}')"
|
|
TITLE="$(echo "$META" | jq -r '.title // ""')"
|
|
BODY="$(echo "$META" | jq -r '.body // ""')"
|
|
|
|
if [ -z "$DIFF" ]; then
|
|
say "empty diff; nothing to review"
|
|
exit 0
|
|
fi
|
|
|
|
# Keep the FULL diff for the agentic (ollama) reviewer — it can pull the whole
|
|
# thing via the get_diff tool and embeds a truncated copy in the prompt itself.
|
|
# The truncated copy below is only for the one-shot antigravity prompt.
|
|
FULL_DIFF="$DIFF"
|
|
TRUNC_NOTE=""
|
|
if [ "${#DIFF}" -gt "$MAX_DIFF_CHARS" ]; then
|
|
DIFF="${DIFF:0:$MAX_DIFF_CHARS}"
|
|
TRUNC_NOTE=$'\n\n[NOTE: diff truncated to '"${MAX_DIFF_CHARS}"' chars for length; review the rest manually.]'
|
|
fi
|
|
|
|
SYS="$(cat "${SCRIPT_DIR}/system-prompt.txt")"
|
|
USR="$(printf 'PR #%s: %s\n\nDescription:\n%s\n\nUnified diff to review:\n```diff\n%s\n```%s' \
|
|
"$PR" "$TITLE" "$BODY" "$DIFF" "$TRUNC_NOTE")"
|
|
|
|
# --- announce start (placeholder comment) -----------------------------------
|
|
START_TS="$(date +%s)"
|
|
say "starting review with ${MODEL}"
|
|
# Skip the per-model placeholder when consolidating (the consensus comment is
|
|
# posted later; live progress is on the status board).
|
|
[ "$CONSOLIDATE" = "1" ] || upsert_comment "$(printf '%s\n### 🪰 Gadfly review — `%s` (%s)\n\n⏳ Reviewing… this comment will update with findings and run time.' \
|
|
"$MARKER" "$MODEL" "$MODEL_PROVIDER")"
|
|
|
|
# --- call the model ---------------------------------------------------------
|
|
REVIEW=""
|
|
case "$PROVIDER" in
|
|
ollama)
|
|
# Agentic lane: hand off to the cmd/gadfly binary, which runs a tool-using
|
|
# agent over the checked-out repo so it can verify findings instead of
|
|
# guessing from the diff. The reviewer is majordomo-powered, so GADFLY_PROVIDER
|
|
# selects the backend (default ollama-cloud); local Ollama, OpenAI, Anthropic,
|
|
# Google and OpenAI/Ollama-compatible endpoints all work — see the README.
|
|
|
|
# Back-compat: map the consumer's OLLAMA_CLOUD_API_KEY secret onto the
|
|
# OLLAMA_API_KEY env the ollama-cloud provider reads.
|
|
if [ -n "${OLLAMA_CLOUD_API_KEY:-}" ] && [ -z "${OLLAMA_API_KEY:-}" ]; then
|
|
export OLLAMA_API_KEY="$OLLAMA_CLOUD_API_KEY"
|
|
fi
|
|
GADFLY_PROVIDER_EFF="$MODEL_PROVIDER"
|
|
|
|
# Pre-flight the credential for providers that need one. Why bother, when
|
|
# majordomo already fails closed with a 401: without this, a missing key
|
|
# surfaces as five identical per-lens agent failures that name no variable,
|
|
# and the operator has to read a stack trace to learn which secret they
|
|
# forgot to forward. GADFLY_API_KEY overrides any entry.
|
|
#
|
|
# A provider is absent from this table for one of TWO different reasons —
|
|
# do not assume the first one and add an arm:
|
|
# 1. It needs no key, or carries it in its endpoint/DSN: local ollama,
|
|
# llama-swap, foreman.
|
|
# 2. It needs a key but has more than one acceptable variable, so a
|
|
# single-variable check would skip a correctly-configured run.
|
|
# **google** is this case: it accepts GOOGLE_API_KEY *or*
|
|
# GEMINI_API_KEY. Adding `google) KEY_ENV="GOOGLE_API_KEY"` here would
|
|
# silently skip every reviewer configured with GEMINI_API_KEY. If you
|
|
# want google pre-flighted, the check has to accept either variable,
|
|
# not the table's one-name shape.
|
|
KEY_ENV=""; KEY_HINT=""
|
|
case "$GADFLY_PROVIDER_EFF" in
|
|
ollama-cloud) KEY_ENV="OLLAMA_API_KEY"; KEY_HINT="OLLAMA_CLOUD_API_KEY" ;;
|
|
qwen) KEY_ENV="QWEN_API_KEY"; KEY_HINT="QWEN_API_KEY" ;;
|
|
kimi) KEY_ENV="KIMI_API_KEY"; KEY_HINT="KIMI_API_KEY" ;;
|
|
openai|openai-compatible) KEY_ENV="OPENAI_API_KEY"; KEY_HINT="OPENAI_API_KEY" ;;
|
|
anthropic) KEY_ENV="ANTHROPIC_API_KEY"; KEY_HINT="ANTHROPIC_API_KEY" ;;
|
|
esac
|
|
# GADFLY_API_KEY substitutes for the provider's own variable ONLY on the
|
|
# endpoint-override path: resolveModel reads it after the `baseURL == ""`
|
|
# early return, so with GADFLY_BASE_URL unset the built-in reads its own
|
|
# env var and GADFLY_API_KEY is never consulted. Treating it as a universal
|
|
# substitute made a mis-set GADFLY_API_KEY pass pre-flight and then 401 five
|
|
# times anyway — the exact failure this check exists to prevent.
|
|
KEY_OK=0
|
|
[ -n "$KEY_ENV" ] && [ -n "${!KEY_ENV:-}" ] && KEY_OK=1 # indirect expansion (bash)
|
|
[ -n "${GADFLY_BASE_URL:-}" ] && [ -n "${GADFLY_API_KEY:-}" ] && KEY_OK=1
|
|
if [ -n "$KEY_ENV" ] && [ "$KEY_OK" -eq 0 ]; then
|
|
REVIEW="⚠️ No API key configured for provider \`${GADFLY_PROVIDER_EFF}\` (set \`${KEY_HINT}\`, or \`GADFLY_API_KEY\` together with \`GADFLY_BASE_URL\`); this reviewer was skipped."
|
|
else
|
|
BIN="${GADFLY_BIN:-gadfly}"
|
|
if ! command -v "$BIN" >/dev/null 2>&1 && [ ! -x "$BIN" ]; then
|
|
REVIEW="⚠️ Agentic reviewer binary not found (\`GADFLY_BIN=${BIN}\`); the workflow build step may have failed."
|
|
else
|
|
REPO_DIR="${GADFLY_REPO_DIR:-$(cd "${SCRIPT_DIR}/../../.." && pwd)}"
|
|
DIFF_FILE="$(mktemp)"
|
|
ERR_FILE="${DIFF_FILE}.err"
|
|
printf '%s' "$FULL_DIFF" > "$DIFF_FILE"
|
|
# GADFLY_PROVIDER / GADFLY_BASE_URL / GADFLY_API_KEY and provider key
|
|
# envs (OPENAI_API_KEY, …) are inherited from the process environment.
|
|
REVIEW="$(
|
|
GADFLY_MODEL="$MODEL" \
|
|
GADFLY_REPO_DIR="$REPO_DIR" \
|
|
GADFLY_DIFF_FILE="$DIFF_FILE" \
|
|
GADFLY_SYSTEM_FILE="${SCRIPT_DIR}/system-prompt.txt" \
|
|
GADFLY_TITLE="$TITLE" \
|
|
GADFLY_BODY="$BODY" \
|
|
GADFLY_MAX_DIFF_CHARS="${GADFLY_MAX_DIFF_CHARS:-$MAX_DIFF_CHARS}" \
|
|
GADFLY_STATUS_FILE="${GADFLY_STATUS_FILE:-}" \
|
|
GADFLY_FINDINGS_OUT="${GADFLY_FINDINGS_OUT:-}" \
|
|
"$BIN" 2>"$ERR_FILE"
|
|
)"
|
|
rc=$?
|
|
if [ "$rc" -ne 0 ] || [ -z "$REVIEW" ]; then
|
|
REVIEW="⚠️ Agentic reviewer for \`${MODEL}\` failed (exit ${rc}):
|
|
\`\`\`
|
|
$(tail -c 1500 "$ERR_FILE" 2>/dev/null)
|
|
\`\`\`"
|
|
fi
|
|
rm -f "$DIFF_FILE" "$ERR_FILE"
|
|
fi
|
|
fi
|
|
;;
|
|
antigravity)
|
|
if ! command -v agy >/dev/null 2>&1; then
|
|
REVIEW="⚠️ Antigravity CLI (\`agy\`) not found on PATH."
|
|
else
|
|
FULL="$(printf '%s\n\n%s' "$SYS" "$USR")"
|
|
if ! REVIEW="$(agy -p "$FULL" --model "$MODEL" 2>agy.err)"; then
|
|
REVIEW="⚠️ Antigravity CLI failed:
|
|
\`\`\`
|
|
$(tail -c 1500 agy.err 2>/dev/null)
|
|
\`\`\`"
|
|
fi
|
|
[ -z "$REVIEW" ] && REVIEW="⚠️ Antigravity CLI returned no output (auth/quota?)."
|
|
fi
|
|
;;
|
|
*)
|
|
say "unknown provider: ${PROVIDER}"; exit 1 ;;
|
|
esac
|
|
|
|
# --- assemble + post final comment (with run time) --------------------------
|
|
ELAPSED="$(( $(date +%s) - START_TS ))"
|
|
DUR="$(fmt_duration "$ELAPSED")"
|
|
|
|
# Consolidating: the binary writes its findings file on success. If it failed or
|
|
# was skipped (no file, or an empty one), write a stub so this model still shows
|
|
# up in the consensus (as failed) and an all-models-fail run still posts a
|
|
# comment — never silently drop a model or the whole review.
|
|
if [ "$CONSOLIDATE" = "1" ] && [ -n "${GADFLY_FINDINGS_OUT:-}" ] && [ ! -s "${GADFLY_FINDINGS_OUT}" ]; then
|
|
jq -n --arg model "$MODEL" --arg provider "$MODEL_PROVIDER" --arg md "$REVIEW" \
|
|
'{model:$model, provider:$provider, verdict:"reviewer failed", errored:true, markdown:$md, findings:[]}' \
|
|
> "${GADFLY_FINDINGS_OUT}" 2>/dev/null || true
|
|
fi
|
|
# When consolidating, the binary has written this model's findings to
|
|
# GADFLY_FINDINGS_OUT; the consensus comment is posted by entrypoint.sh after the
|
|
# whole swarm finishes, so this model posts no comment of its own.
|
|
if [ "$CONSOLIDATE" = "1" ]; then
|
|
say "done in ${DUR} (consolidated; no per-model comment)"
|
|
else
|
|
# An optional one-line notice (e.g. entrypoint's huge-PR downshift advisory),
|
|
# shown under the header so a shallower review is self-explaining.
|
|
NOTICE_BLOCK=""
|
|
[ -n "${GADFLY_NOTICE:-}" ] && NOTICE_BLOCK="> ${GADFLY_NOTICE}"$'\n\n'
|
|
COMMENT="$(printf '%s\n### 🪰 Gadfly review — `%s` (%s)\n\n%s%s\n\n<sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in %s</sub>' \
|
|
"$MARKER" "$MODEL" "$MODEL_PROVIDER" "$NOTICE_BLOCK" "$REVIEW" "$DUR")"
|
|
upsert_comment "$COMMENT"
|
|
say "done in ${DUR}"
|
|
fi
|