name: AI Code Reviewer # Reviews are opt-in per PR: add the ai_code_review label to request one # (the label is auto-removed after the run so re-adding it re-triggers). # The automatic opened/synchronize/ready_for_review triggers were removed — # reviewing every push burned reviewer-API spend on PRs nobody asked to have # reviewed, and a billing outage (HTTP 402) then showed a red check on every # PR in the repo. # # Residual risk accepted: the diff text goes into the LLM prompt, so a # hostile diff can try to steer the posted comment or labels (prompt # injection). The maintainer-applied label gate plus the advisory-only # decision (FAIL_ON_REQUESTED_CHANGES defaults to false) bound that # blast radius, and it exists for internal PRs too. on: # zizmor: ignore[dangerous-triggers] — pull_request_target is required so # the review also works on fork PRs: fork-triggered pull_request runs get # no secrets (OPENROUTER_API_KEY) and a read-only token, so the reviewer # could neither call the model nor post its comment. This is safe under # two invariants — KEEP BOTH when editing this file: # 1. Only the ai_code_review label triggers a run, and only users with # triage permission can add labels: every run on fork code is an # explicit maintainer action, never attacker-initiated. # 2. Nothing from the PR head is ever executed. The checkout is the # trusted base branch (pull_request_target default), so # .github/scripts/combine-ai-reviews.sh runs from base; the PR head # is fetched as *data only* to compute the diff. pull_request_target: types: [labeled] # Per-PR concurrency with cancel-in-progress: re-adding the label while a # review is still running cancels the now-stale run. Keyed on the PR number # so runs for *different* PRs never cancel each other — that repo-wide # cancellation was the problem in the earlier attempt (#3554, reverted # #3599), not per-PR cancellation. # # The labeled trigger fires for *every* label added to the PR, but the job's # `if` only proceeds when the label is ai_code_review. Without the run_id # suffix below, a skip-only labeled event would still enter this group, # cancel a real in-flight review, and then skip itself — leaving the PR with # no review at all. Routing those into a unique group keyed on run_id means # they cancel nothing; the `if` discards them quietly. concurrency: group: >- ai-code-review-${{ github.event.pull_request.number }}${{ (github.event.action == 'labeled' && github.event.label.name != 'ai_code_review') && format('-skip-{0}', github.run_id) || '' }} cancel-in-progress: true permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions jobs: comprehensive-review: name: AI Code Review runs-on: ubuntu-latest timeout-minutes: 30 environment: ci # Only the ai_code_review label triggers a review (works on drafts too); # every other labeled event is discarded here. if: github.event.label.name == 'ai_code_review' permissions: contents: read pull-requests: write issues: write steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit # SECURITY: this checks out the trusted BASE branch (the # pull_request_target default), which is what makes running # .github/scripts/combine-ai-reviews.sh below safe on fork PRs. # Never add `ref: ${{ github.event.pull_request.head.sha }}` here — # that would execute fork-controlled scripts with secrets in env. - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y jq - name: Get PR diff env: BASE_REF: ${{ github.event.pull_request.base.ref }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | # The PR head — possibly fork code — is fetched as *data only* # through the base repo's refs/pull/N/head mirror (present for # fork and same-repo PRs alike); it is never checked out and # nothing from it is executed. Env vars prevent template # injection from malicious branch names. git fetch origin "$BASE_REF" "+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head" git diff "origin/$BASE_REF...origin/pr-head" --no-color > diff.txt - name: Download AI reviewer script run: | curl -fsSL https://raw.githubusercontent.com/LearningCircuit/Friendly-AI-Reviewer/main/ai-reviewer.sh -o ai-reviewer.sh chmod +x ai-reviewer.sh - name: AI Code Review id: ai-review env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} # Comma-separated list of models. Each model produces one independent # review, presented as "Reviewer 1", "Reviewer 2", ... in a single # combined comment. Falls back to the shared AI_MODEL (also used by # release.yml, which must stay a single model) when unset, so a # one-entry list reproduces the original single-review behavior. AI_REVIEW_MODELS: ${{ vars.AI_REVIEW_MODELS || vars.AI_MODEL || 'moonshotai/kimi-k2-thinking' }} AI_TEMPERATURE: ${{ vars.AI_TEMPERATURE || '0.1' }} AI_MAX_TOKENS: ${{ vars.AI_MAX_TOKENS || '64000' }} MAX_DIFF_SIZE: ${{ vars.MAX_DIFF_SIZE || '800000' }} EXCLUDE_FILE_PATTERNS: ${{ vars.EXCLUDE_FILE_PATTERNS || '*.lock,*.min.js,*.min.css,package-lock.json,yarn.lock' }} STRUCTURED_OUTPUT: ${{ vars.STRUCTURED_OUTPUT || 'true' }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO_FULL_NAME: ${{ github.repository }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} FAIL_ON_REQUESTED_CHANGES: ${{ vars.FAIL_ON_REQUESTED_CHANGES || 'false' }} DEBUG_MODE: ${{ vars.DEBUG_MODE || 'false' }} run: | echo "Running AI code review..." # Parse the comma-separated model list. Each entry becomes one # independent reviewer ("Reviewer 1", "Reviewer 2", ...). A single # entry reproduces the original one-review behavior. # `read` returns non-zero at EOF; the here-string always appends a # trailing newline so it returns 0 here, but `|| true` keeps a future # edge case from tripping `set -e`. An empty/whitespace AI_REVIEW_MODELS # yields only empty entries, which the filter below drops, correctly # falling through to the "No models configured" error. IFS=',' read -ra RAW_MODELS <<< "$AI_REVIEW_MODELS" || true MODELS=() for m in "${RAW_MODELS[@]}"; do # Trim surrounding whitespace with parameter expansion. (xargs would # choke on a model id containing a quote and word-collapse internal # spaces — model ids shouldn't have either, but this is robust.) m="${m#"${m%%[![:space:]]*}"}" # strip leading whitespace m="${m%"${m##*[![:space:]]}"}" # strip trailing whitespace [ -n "$m" ] && MODELS+=("$m") done if [ "${#MODELS[@]}" -eq 0 ]; then echo "❌ No models configured in AI_REVIEW_MODELS" exit 1 fi echo "Configured ${#MODELS[@]} reviewer model(s)" # Launch every model concurrently so the job's wall-clock is the # slowest single review, not the sum of all of them. Each reviewer # writes its JSON response and its diagnostics to its own index-keyed # files, so the "Reviewer N" numbering stays stable and the parallel # output never interleaves. AI_MODEL is overridden per call; all other # settings (temperature, token limits, diff size, context fetching) # are shared. pids=() for i in "${!MODELS[@]}"; do AI_MODEL="${MODELS[i]}" bash ai-reviewer.sh < diff.txt \ > "resp_$i.json" 2> "err_$i.log" & pids[i]=$! done # Record each reviewer's exit code to code_ (a failing model must # not abort the others, so disable errexit around wait). for i in "${!MODELS[@]}"; do set +e wait "${pids[i]}" echo $? > "code_$i" set -e done # Assemble the combined comment with the extracted, unit-tested helper # (.github/scripts/combine-ai-reviews.sh). It reads resp_.json / # code_ from the working dir and writes the comment body, label set, # decision, and success count back out. Keeping the assembly in a # script is what lets tests/ci/test_combine_ai_reviews.py cover the # parsing, footer-stripping, label union, and pass/fail logic without # any network or GitHub API access. bash .github/scripts/combine-ai-reviews.sh "$PWD" "${MODELS[@]}" COMMENT_BODY="$(cat comment_body.md)" LABELS="$(cat labels.txt)" DECISION="$(cat decision.txt)" SUCCESS_COUNT="$(cat success_count.txt)" # Post (or update) the reviews as a single sticky PR comment. On each # push we edit the same comment in place instead of stacking a new one # per push. The hidden marker identifies our comment specifically, so # we never touch comments from humans or other bots. (Review comments # made before this change lack the marker and are left as-is.) # A commit-sha line lets reviewers tell which push the (edited-in-place) # comment reflects, since GitHub only shows a vague "edited" marker. # This literal MUST match the marker the helper script prepends to the # comment body. STICKY_MARKER="" # STICKY_MARKER is interpolated into the jq program below, so it must # stay free of `"` and `\`. gh api's --jq takes no --arg, so we can't # pass it as a jq variable; keeping the marker a literal constant is # what keeps this safe. The filter yields a single id (we maintain # exactly one sticky comment), so no post-filtering is needed. EXISTING_COMMENT_ID=$(gh api "repos/$REPO_FULL_NAME/issues/$PR_NUMBER/comments" \ --paginate \ --jq "[.[] | select(.body | contains(\"$STICKY_MARKER\"))] | last | .id // empty") if [ -n "$EXISTING_COMMENT_ID" ]; then echo "Updating existing AI review comment ($EXISTING_COMMENT_ID)" printf '%s' "$COMMENT_BODY" | gh api \ "repos/$REPO_FULL_NAME/issues/comments/$EXISTING_COMMENT_ID" \ --method PATCH -F body=@- >/dev/null else echo "Posting new AI review comment" printf '%s' "$COMMENT_BODY" | gh pr comment "$PR_NUMBER" --repo "$REPO_FULL_NAME" -F - fi # Add labels to PR. Iterate line-by-line (labels.txt is one label per # line) rather than word-splitting, so a multi-word label such as # "good first issue" is treated as a single label, not three. echo "Labels to add: $LABELS" while IFS= read -r label; do [ -n "$label" ] || continue echo "Adding label: $label" if gh label create "$label" --color "0366d6" --description "Auto-created by AI reviewer" 2>/dev/null; then echo "Created new label: $label" else echo "Label already exists: $label" fi if gh issue edit "$PR_NUMBER" --add-label "$label" 2>/dev/null; then echo "Successfully added label: $label" else echo "Failed to add label: $label" fi done <<< "$LABELS" # Handle workflow decision (after posting review and labels) echo "AI decision: $DECISION" # Store the decision as a step output for the downstream # "Fail Workflow if Requested" step. $GITHUB_OUTPUT is the modern # mechanism — values are scoped to steps..outputs. and # cannot re-enter the runner's process environment, eliminating the # $GITHUB_ENV injection class (zizmor github-env audit #8017). # # Defense-in-depth: an allowlist before the write blocks newline # injection. If a manipulated model response put a newline in # $DECISION, the bare echo above could write an additional output # key. Outputs can't reach the process environment so impact is # minimal, but the case statement closes the door cheaply and # replaces the lost-on-rework intent of #4985. case "$DECISION" in pass|fail) ;; *) DECISION="fail" ;; esac echo "DECISION=$DECISION" >> "$GITHUB_OUTPUT" # Remove the ai_code_review label so re-adding it re-triggers a review. echo "Removing ai_code_review label to allow easy re-triggering" if gh issue edit "$PR_NUMBER" --remove-label "ai_code_review" 2>/dev/null; then echo "Successfully removed ai_code_review label" else echo "Failed to remove ai_code_review label (may have been already removed)" fi # If every reviewer failed, the posted comment carries only failure # notes — surface that as a red workflow run rather than a silent pass, # matching the original single-review behavior on an unusable response. if [ "$SUCCESS_COUNT" -eq 0 ]; then echo "❌ All ${#MODELS[@]} reviewer(s) failed to produce a usable review." exit 1 fi echo "✅ AI review completed successfully ($SUCCESS_COUNT/${#MODELS[@]} reviewers)" - name: Fail Workflow if Requested if: env.FAIL_ON_REQUESTED_CHANGES == 'true' && steps.ai-review.outputs.DECISION == 'fail' run: | echo "❌ AI requested changes and FAIL_ON_REQUESTED_CHANGES is enabled. Failing workflow." echo "The review has been posted above. Please address the requested changes." exit 1 - name: Cleanup if: always() run: | rm -f diff.txt # Per-reviewer response/log/exit-code files from the parallel fan-out, # plus the assembly helper's output files. rm -f resp_*.json err_*.log code_* comment_body.md labels.txt decision.txt success_count.txt # Only remove ai_response.txt if it exists (only created in debug mode) if [ -f "ai_response.txt" ]; then rm -f ai_response.txt fi