#!/usr/bin/env bash # # Combine multiple per-model AI code reviews into a single sticky PR comment. # # This is the pure-assembly half of the AI Code Reviewer workflow # (.github/workflows/ai-code-reviewer.yml), split out so the non-trivial parts — # header/footer stripping, "Reviewer N" section assembly, label union, and the # pass/fail aggregation — can be unit-tested without any network or GitHub API # access. The workflow runs the models (network) and writes each reviewer's raw # stdout to resp_.json and its exit code to code_; this script turns those # into the comment body, label set, decision, and success count. # # Usage: # combine-ai-reviews.sh [ ...] # # Reads, for i in 0..N-1 (N = number of model args): # /resp_.json - raw stdout from ai-reviewer.sh for reviewer i # /code_ - that reviewer's integer exit code # /err_.log - that reviewer's stderr (only shown in DEBUG) # The model names are used only for the per-reviewer log-group labels; the # posted comment stays anonymized ("Reviewer 1", "Reviewer 2", ...). # # Environment: # HEAD_SHA - head commit sha for the "Last reviewed at commit" line (opt) # DEBUG_MODE - "true" to echo raw responses + stderr to this script's stderr # # Writes (into ): # comment_body.md - the combined sticky-comment body # labels.txt - deduped labels, one per line (may be empty) # decision.txt - "pass" or "fail" # success_count.txt - number of reviewers that produced a usable review # # Logs ::group:: sections and debug output to stderr (kept off stdout so the # output files are the only contract). Exit status is 0 even when every # reviewer failed — the caller inspects success_count.txt and decides whether # to fail the workflow. A non-zero exit means a usage error only. set -euo pipefail WORK_DIR="${1:-}" if [ -z "$WORK_DIR" ] || [ ! -d "$WORK_DIR" ]; then echo "usage: $0 [ ...]" >&2 exit 2 fi shift MODELS=("$@") if [ "${#MODELS[@]}" -eq 0 ]; then echo "error: at least one model name is required" >&2 exit 2 fi DEBUG_MODE="${DEBUG_MODE:-false}" HEAD_SHA="${HEAD_SHA:-}" COMBINED_REVIEW="" ALL_LABELS="" ANY_FAIL="false" SUCCESS_COUNT=0 # Assemble the combined comment in model order. Reviewers are anonymized as # "Reviewer N" — the model -> number mapping appears only in the (debug) logs # below, so readers judge the feedback, not the model. for i in "${!MODELS[@]}"; do REVIEWER_NUM=$((i + 1)) EXIT_CODE="$(cat "$WORK_DIR/code_$i" 2>/dev/null || echo 1)" # A missing/empty/garbage exit-code file counts as a failed reviewer rather # than tripping the numeric comparison below (which would print "integer # expression expected" and then fall through to wrongly treating the reviewer # as a success). [[ "$EXIT_CODE" =~ ^[0-9]+$ ]] || EXIT_CODE=1 AI_RESPONSE="$(cat "$WORK_DIR/resp_$i.json" 2>/dev/null || echo "")" echo "::group::Reviewer #$REVIEWER_NUM (${MODELS[i]})" >&2 if [ "$DEBUG_MODE" = "true" ]; then { echo "=== RAW AI RESPONSE (reviewer #$REVIEWER_NUM, exit $EXIT_CODE) ===" echo "$AI_RESPONSE" echo "--- stderr ---" cat "$WORK_DIR/err_$i.log" 2>/dev/null || true echo "=== END RAW AI RESPONSE ===" } >&2 fi # One model failing (hard error, empty, or non-JSON) must not sink the others: # record a short failure note and keep going. The caller fails the workflow # only if *every* reviewer failed (success_count == 0). A reviewer that errors # out never sets a verdict, so it can never flip the aggregate decision to # "fail" — only a model that returns a valid "fail" verdict does. Sensitive raw # output is never written to the comment. # Require a JSON *object*: a bare `jq .` accepts any valid JSON, including a # top-level string/array/number that a refusing or misbehaving model might # emit ("I refuse to review this"). That would pass the gate and then crash # the `.review` access below under `set -e`, sinking *every* reviewer and # writing no output at all. `type == "object"` rejects non-objects so they # degrade to a per-reviewer failure note, preserving the others. if [ "$EXIT_CODE" -ne 0 ] || [ -z "$AI_RESPONSE" ] || ! echo "$AI_RESPONSE" | jq -e 'type == "object"' >/dev/null 2>&1; then echo "⚠️ Reviewer #$REVIEWER_NUM did not return a usable review (exit $EXIT_CODE)" >&2 REVIEW_BODY="_This reviewer could not complete its review (see workflow logs for details)._" LABELS="" else REVIEW_RAW=$(echo "$AI_RESPONSE" | jq -r '.review // "No review provided"') # Trim whitespace with tr (the verdict is a single token). The previous # `| xargs` aborted the entire script on an unbalanced quote in the value, # which—inside this per-reviewer loop—would sink every reviewer. DECISION=$(echo "$AI_RESPONSE" | jq -r '.fail_pass_workflow // "uncertain"' | tr -d '[:space:]') LABELS=$(echo "$AI_RESPONSE" | jq -r '.labels_added[]? // empty') SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) [ "$DECISION" = "fail" ] && ANY_FAIL="true" # Each review opens with a "## AI Code Review" H2 and closes with the # singular Friendly AI Reviewer footer. Under a "### 👤 Reviewer N" # subheading the H2 is redundant and the footer would repeat once per model, # so strip both here; a single (plural) footer is added to the combined # comment below. The footer match keys on the stable "Review by [Friendly AI # Reviewer]" prefix and the \s*\z tail absorbs trailing whitespace; if the # upstream markers ever change and aren't found, the text is left intact # (nothing is dropped). REVIEW_BODY=$(printf '%s' "$REVIEW_RAW" \ | perl -0pe 's/\A\s*##\s*AI Code Review\s*\n+//' \ | perl -0pe 's/\n*---\s*\n\*Review by \[Friendly AI Reviewer\][^\n]*\n?\s*\z//s') fi # Lead each section with a blank line so the "### Reviewer N" heading always # renders (command substitution strips trailing newlines, so the separator # must precede the heading, not follow the previous body). COMBINED_REVIEW=$(printf '%s\n\n### 👤 Reviewer %s\n\n%s' "$COMBINED_REVIEW" "$REVIEWER_NUM" "$REVIEW_BODY") [ -n "$LABELS" ] && ALL_LABELS=$(printf '%s\n%s' "$ALL_LABELS" "$LABELS") echo "::endgroup::" >&2 done # Aggregate labels (union across reviewers) and the pass/fail decision (request # changes if ANY reviewer did). LABELS=$(printf '%s\n' "$ALL_LABELS" | sed '/^[[:space:]]*$/d' | sort -u) if [ "$ANY_FAIL" = "true" ]; then DECISION="fail" else DECISION="pass" fi REVIEWER_WORD="reviewers" [ "${#MODELS[@]}" -eq 1 ] && REVIEWER_WORD="reviewer" STICKY_MARKER="" FOOTER="*Reviews by [Friendly AI Reviewer](https://github.com/LearningCircuit/Friendly-AI-Reviewer) - made with ❤️*" # COMBINED_REVIEW already starts with "\n\n", so it follows the title directly # (no extra newline) to yield exactly one blank line before "### Reviewer 1". # The backticks in `%s` are intentional literal Markdown (inline-code the SHA), # not a command substitution — single quotes keep them literal on purpose. # shellcheck disable=SC2016 COMMENT_BODY=$(printf '%s\n\n## 🤖 AI Code Review (%s %s)%s\n\n---\n%s\n\n_Last reviewed at commit `%s`_' \ "$STICKY_MARKER" "${#MODELS[@]}" "$REVIEWER_WORD" "$COMBINED_REVIEW" "$FOOTER" "${HEAD_SHA:0:7}") printf '%s' "$COMMENT_BODY" > "$WORK_DIR/comment_body.md" printf '%s' "$LABELS" > "$WORK_DIR/labels.txt" printf '%s' "$DECISION" > "$WORK_DIR/decision.txt" printf '%s' "$SUCCESS_COUNT" > "$WORK_DIR/success_count.txt"