Flux source URL, flux-git rotator verify repo, review-bot REPOS, pipeline API, clone and push URLs, the heating-can source label and the review-bot and kuma-sync Go module paths. Merge right after the transfer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
194 lines
9.4 KiB
Bash
Executable File
194 lines
9.4 KiB
Bash
Executable File
#!/bin/sh
|
|
# PR review bot — the ONE implementation, run by every reviewed repo's
|
|
# .woodpecker/pr-review.yml stub (konairius/homelab, shelly-manager,
|
|
# authentik-samba-bridge, homeassistant). Edit it here only; the stubs hold no
|
|
# logic. Design notes (event-driven trigger, dedup marker, tokens, model
|
|
# selection, posture) are in the stub and in prompt.md.
|
|
#
|
|
# ---- Resolve the single triggering PR --------------------------------
|
|
# Woodpecker hands us the repo (CI_REPO, canonical owner/repo) and the PR
|
|
# number (CI_COMMIT_PULL_REQUEST, a pull_request-event built-in). Build
|
|
# /tmp/pr-review/worklist.jsonl with 0 or 1 entries; an empty file makes
|
|
# the next block a no-op (the agent never starts).
|
|
set -eu
|
|
API="https://gitea.renner.uno/api/v1"
|
|
AUTH="Authorization: token $REVIEW_TOKEN"
|
|
SLUG="$CI_REPO"
|
|
NUM="${CI_COMMIT_PULL_REQUEST:-}"
|
|
REVIEW_BOT_LOGIN="review-bot"
|
|
mkdir -p /tmp/pr-review
|
|
: > /tmp/pr-review/worklist.jsonl
|
|
|
|
if [ -z "$NUM" ]; then
|
|
echo "No PR number in CI env (event=${CI_PIPELINE_EVENT:-?}) — nothing to review."
|
|
exit 0
|
|
fi
|
|
echo "Triggered for $SLUG#$NUM (event ${CI_PIPELINE_EVENT:-?})."
|
|
|
|
if ! curl -sf -H "$AUTH" "$API/repos/$SLUG/pulls/$NUM" > /tmp/pr-review/pr.json; then
|
|
echo "Cannot fetch $SLUG#$NUM (review-bot not a collaborator / PR gone?) — skipping."
|
|
exit 0
|
|
fi
|
|
STATE=$(jq -r '.state' /tmp/pr-review/pr.json)
|
|
DRAFT=$(jq -r '.draft // false' /tmp/pr-review/pr.json)
|
|
TITLE=$(jq -r '.title' /tmp/pr-review/pr.json)
|
|
HEAD=$(jq -r '.head.sha' /tmp/pr-review/pr.json)
|
|
SHORT=$(printf '%s' "$HEAD" | cut -c1-8)
|
|
|
|
# Skip closed/merged, drafts, and WIP-titled PRs — not ready for review.
|
|
case "$STATE" in open) ;; *) echo "#$NUM is $STATE — skip."; exit 0;; esac
|
|
case "$DRAFT" in true) echo "#$NUM draft — skip."; exit 0;; esac
|
|
case "$TITLE" in [Ww][Ii][Pp]:*|[Ww][Ii][Pp]\ *) echo "#$NUM WIP — skip."; exit 0;; esac
|
|
|
|
# Dedup: already reviewed at THIS head sha? (guards re-runs / restarts)
|
|
REVIEWED=$(curl -sf -H "$AUTH" "$API/repos/$SLUG/pulls/$NUM/reviews?limit=50" \
|
|
| jq -r --arg u "$REVIEW_BOT_LOGIN" --arg m "pr-review:sha=$HEAD" \
|
|
'[.[] | select(.user.login == $u) | select(.body | contains($m))] | length' \
|
|
|| echo 0)
|
|
if [ "${REVIEWED:-0}" -gt 0 ]; then
|
|
echo "#$NUM already reviewed at $SHORT — skip."
|
|
exit 0
|
|
fi
|
|
|
|
jq -c --arg slug "$SLUG" \
|
|
'{slug: $slug, number: .number, head: .head.sha, base: .base.sha,
|
|
title: .title, author: .user.login}' \
|
|
/tmp/pr-review/pr.json >> /tmp/pr-review/worklist.jsonl
|
|
echo "#$NUM enqueued (head $SHORT)."
|
|
# ---- Clone, pick model, review, post ---------------------------------
|
|
set -eu
|
|
[ -s /tmp/pr-review/worklist.jsonl ] || { echo "Nothing to review."; exit 0; }
|
|
[ -n "${MISTRAL_API_KEY:-}" ] || { echo "ERROR: MISTRAL_API_KEY is not set — grant the mistral_api_key secret in this repo's .woodpecker/pr-review.yml stub."; exit 1; }
|
|
REVIEW_FAILED=0
|
|
API="https://gitea.renner.uno/api/v1"
|
|
AUTH="Authorization: token $REVIEW_TOKEN"
|
|
WS="$PWD" # workspace = this repo, checked out at the PR head by Woodpecker
|
|
MAX_DIFF_LINES=20000
|
|
|
|
# This script, the prompts and scripts/ci/ all come from the homelab@master
|
|
# clone the per-repo stub made (see .woodpecker/pr-review.yml) — never from the
|
|
# PR checkout, so a PR cannot change the rubric or the helper scripts it is
|
|
# reviewed with.
|
|
PROMPTS="$(cd "$(dirname "$0")" && pwd)"
|
|
HOMELAB="$(cd "$PROMPTS/../../.." && pwd)" # repo root holding ci/ AND scripts/ci/
|
|
echo "Using prompts from $PROMPTS"
|
|
|
|
gitea_post() { # method url datafile ; echoes+checks HTTP
|
|
code=$(curl -s -o /tmp/pr-review/resp.json -w '%{http_code}' \
|
|
-X "$1" -H "$AUTH" -H "Content-Type: application/json" \
|
|
--data @"$3" "$2")
|
|
echo "$1 $2 -> HTTP $code"
|
|
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
|
|
echo "Gitea API error body:"; cat /tmp/pr-review/resp.json; echo; return 1
|
|
fi
|
|
}
|
|
|
|
while IFS= read -r ITEM; do
|
|
SLUG=$(printf '%s' "$ITEM" | jq -r '.slug')
|
|
NUM=$(printf '%s' "$ITEM" | jq -r '.number')
|
|
HEAD=$(printf '%s' "$ITEM" | jq -r '.head')
|
|
TITLE=$(printf '%s' "$ITEM" | jq -r '.title')
|
|
SHORT=$(printf '%s' "$HEAD" | cut -c1-8)
|
|
echo "=== Reviewing $SLUG#$NUM ($SHORT) ==="
|
|
D="/tmp/pr-review/$NUM"
|
|
rm -rf "$D"; mkdir -p "$D"
|
|
|
|
# PR metadata + unified diff (authenticated; private repos need it).
|
|
curl -sf -H "$AUTH" "$API/repos/$SLUG/pulls/$NUM" > "$D/pr.json"
|
|
curl -sf -H "$AUTH" "$API/repos/$SLUG/pulls/$NUM.diff" > "$D/diff.full.patch" \
|
|
|| { echo "Could not fetch diff for $SLUG#$NUM — skipping."; continue; }
|
|
FILES=$(grep -c '^diff --git ' "$D/diff.full.patch" || true)
|
|
ADDED=$(grep -c '^+[^+]' "$D/diff.full.patch" || true)
|
|
DELETED=$(grep -c '^-[^-]' "$D/diff.full.patch" || true)
|
|
TOTAL=$(wc -l < "$D/diff.full.patch")
|
|
if [ "$TOTAL" -gt "$MAX_DIFF_LINES" ]; then
|
|
head -n "$MAX_DIFF_LINES" "$D/diff.full.patch" > "$D/diff.patch"
|
|
printf '\n... [diff truncated at %s of %s lines] ...\n' \
|
|
"$MAX_DIFF_LINES" "$TOTAL" >> "$D/diff.patch"
|
|
else
|
|
cp "$D/diff.full.patch" "$D/diff.patch"
|
|
fi
|
|
printf 'repo: %s\nPR: #%s\nfiles_changed: %s\nlines_added: %s\nlines_deleted: %s\ndiff_total_lines: %s\n' \
|
|
"$SLUG" "$NUM" "$FILES" "$ADDED" "$DELETED" "$TOTAL" > "$D/stats.txt"
|
|
|
|
# No manual clone of the target repo: this runs on a pull_request
|
|
# event, so Woodpecker's built-in clone already checked THIS repo out
|
|
# at the PR head in $WS — the agents (which cd into $WS) read it
|
|
# directly for context. The old cron model had to clone each target
|
|
# repo by hand (its workspace was homelab@master), and that clone
|
|
# embedded REVIEW_TOKEN in the URL, persisting a write-scoped PAT in
|
|
# work/target/.git/config — readable by the review agent whose report
|
|
# is posted publicly. Per-repo pull_request triggering makes the manual
|
|
# clone redundant AND keeps the token off disk (it now lives only in
|
|
# the ephemeral curl Authorization headers used for the API calls).
|
|
|
|
# Claude CLI path, disabled: the Mistral agent reads the repo through the Gitea connector.
|
|
# # Inputs the agents read live at ./.pr-review (symlink into $D).
|
|
# rm -f "$WS/.pr-review"; ln -s "$D" "$WS/.pr-review"
|
|
#
|
|
# # Cross-repo context for the review agent: shallow, UNAUTHENTICATED clones of
|
|
# # the other fleet repos at ./.pr-review/context/<name>.
|
|
# CTX="$D/context"; mkdir -p "$CTX"
|
|
# for REPO in konairius/homelab konairius/homeassistant konairius/shelly-manager konairius/authentik-samba-bridge; do
|
|
# [ "$REPO" = "$SLUG" ] && continue
|
|
# GIT_TERMINAL_PROMPT=0 git clone --quiet --depth 1 "https://gitea.renner.uno/$REPO" "$CTX/${REPO#*/}" 2>/dev/null \
|
|
# || echo "No public context clone of $REPO (skipped)."
|
|
# done
|
|
#
|
|
# # Model: newest from the live Anthropic catalog (needs CLAUDE_CODE_OAUTH_TOKEN).
|
|
# MODEL=$(env -u REVIEW_TOKEN sh "$HOMELAB/scripts/ci/pick-model.sh" \
|
|
# "careful, high-stakes code review of a pull request: verify claims against the code and external documentation, find real defects, avoid false positives; always pick the most capable and newest model available, regardless of PR size" || true)
|
|
# if [ -n "$MODEL" ]; then echo "Review model: $MODEL"; else echo "Review model: CLI default (catalog/picker unavailable)"; fi
|
|
#
|
|
# # Review with the chosen model.
|
|
# ( cd "$WS" && cat "$PROMPTS/prompt.md" | env -u REVIEW_TOKEN claude ${MODEL:+--model $MODEL} \
|
|
# --permission-mode acceptEdits \
|
|
# --allowedTools "Read,Glob,Grep,Write,WebFetch,WebSearch" \
|
|
# --settings "$PROMPTS/settings.json" \
|
|
# -p ) || echo "(review agent exited non-zero)"
|
|
|
|
# --- Review with a Mistral agent (read-only Gitea connector, see mistral-review.sh) ---
|
|
MODEL="${MISTRAL_MODEL:-mistral-large-4-0}"
|
|
echo "Review model: $MODEL"
|
|
if ! sh "$PROMPTS/mistral-review.sh" "$D" "$SLUG" "$NUM" "$HEAD"; then
|
|
echo "Mistral review failed for $SLUG#$NUM."
|
|
REVIEW_FAILED=1
|
|
continue
|
|
fi
|
|
|
|
if [ ! -s "$D/report.md" ]; then
|
|
echo "Agent wrote no report for $SLUG#$NUM — skipping post."
|
|
REVIEW_FAILED=1
|
|
continue
|
|
fi
|
|
# Review event, derived from the report's own severity sections (not trusted
|
|
# from verdict.json): any red (Blocking) -> REQUEST_CHANGES; no red, yellow
|
|
# (Suggestions) or Unverified section on a non-empty diff -> APPROVE; else
|
|
# COMMENT. An empty diff or a report that names no section never approves.
|
|
if grep -q '^## 🔴' "$D/report.md"; then
|
|
EVENT=REQUEST_CHANGES
|
|
elif [ "$TOTAL" -gt 0 ] && ! grep -q -e '^## 🟡' -e '^## ⚪' "$D/report.md" \
|
|
&& grep -q '^## Recommendation' "$D/report.md"; then
|
|
EVENT=APPROVE
|
|
else
|
|
EVENT=COMMENT
|
|
fi
|
|
|
|
# Assemble the review body: header + report + marker footer.
|
|
{
|
|
printf '🤖 **Automated review** — model `%s`, commit `%s`.\n\n' \
|
|
"${MODEL:-CLI default}" "${HEAD}"
|
|
printf '> Automated review by the `review-bot` pipeline: it approves only when it found nothing red or yellow, and a human still merges. Push new commits to get a fresh review.\n\n---\n\n'
|
|
cat "$D/report.md"
|
|
printf '\n\n<!-- pr-review:sha=%s -->\n' "$HEAD"
|
|
} > "$D/body.md"
|
|
jq -n --rawfile b "$D/body.md" --arg e "$EVENT" --arg c "$HEAD" \
|
|
'{body: $b, event: $e, commit_id: $c}' > "$D/review.json"
|
|
gitea_post POST "$API/repos/$SLUG/pulls/$NUM/reviews" "$D/review.json" \
|
|
&& echo "Posted $EVENT review on $SLUG#$NUM." \
|
|
|| { echo "FAILED to post review on $SLUG#$NUM (continuing)."; REVIEW_FAILED=1; }
|
|
# rm -f "$WS/.pr-review" # Claude CLI path, disabled
|
|
done < /tmp/pr-review/worklist.jsonl
|
|
echo "PR review run complete."
|
|
[ "$REVIEW_FAILED" = 0 ] || exit 1
|