Created
October 23, 2025 19:45
-
-
Save joeywang/26174d79827ea8ac6d1e933326983e65 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # PR review helper with multi-provider support. | |
| # Highlights: | |
| # - Run one or MANY providers in a single command: -P gemini -P copilot -P openai | |
| # - Provider adapters for: gemini, qwen, codex, copilot (gh extension), openai (stdin), custom (stdin) | |
| # - Carries forward: include/exclude filters, chunking, merged Markdown, PR comments, quiet mode | |
| # | |
| # Requirements (install what you plan to use): | |
| # - gh (GitHub CLI) + authenticated | |
| # - Providers: | |
| # * gemini: $GEMINI_BIN (default: gemini) expects: gemini -p "<prompt @file>" | |
| # * qwen: $QWEN_BIN (default: qwen) expects: qwen -p "<prompt @file>" | |
| # * codex: $CODEX_BIN (default: codex) expects: codex -p "<prompt @file>" | |
| # * copilot: $COPILOT_BIN (default: gh) uses: gh copilot suggest -t "<prompt>" (reads patch from stdin) | |
| # * openai: $OPENAI_BIN (default: openai) uses: STDIN. Example cmd assumed: | |
| # openai chat.completions.create -m "${OPENAI_MODEL:-gpt-4o-mini}" -g user - | |
| # (Reads entire composed text from STDIN; adjust to your CLI if it differs.) | |
| # * custom: $AIRV_CUSTOM_CMD is a full shell command that reads from STDIN (e.g. "mycli --review --stdin") | |
| # | |
| # Notes: | |
| # - For STDIN adapters we send a composed text: prompt + PR context + patch/chunk content. | |
| # - For @file adapters we keep the previous behavior ("@<file>"). | |
| # - If your OpenAI/Copilot CLI differs, set AIRV_CUSTOM_CMD and use -P custom. | |
| # | |
| airv() { | |
| set -o pipefail | |
| # ---------- Defaults ---------- | |
| local REPO="" | |
| local PR_NUM="" | |
| local EXCLUDES=() | |
| local INCLUDES=() | |
| local SPLIT_SIZE="0" | |
| local ADD_CONTEXT="true" | |
| local KEEP_TMP="false" | |
| local COLOR="never" | |
| local OUTPUT_FILE="" # -O path.md | |
| local DO_COMMENT="false" # --comment | |
| local NO_STDOUT="false" # --no-stdout | |
| local PROVIDERS=() # -P provider (repeatable). If empty -> defaults to gemini. | |
| # Prompt system (overridable by positional prompt argument) | |
| local REVIEW_PROMPT='' # If empty, we'll compose from focus/preset. | |
| local FOCUS=() # --focus <topic> (repeatable) | |
| local PRESET="" # --preset <name> (e.g. safe, clean, perf, readable, full) | |
| local FORMAT="markdown" # --format markdown|checklist|json | |
| local STYLE="standard" # --style brief|standard|thorough | |
| # ---------- Binaries (overridable via env) ---------- | |
| local GEMINI_BIN="${GEMINI_BIN:-gemini}" | |
| local QWEN_BIN="${QWEN_BIN:-qwen}" | |
| local CODEX_BIN="${CODEX_BIN:-codex}" | |
| local COPILOT_BIN="${COPILOT_BIN:-gh}" | |
| local OPENAI_BIN="${OPENAI_BIN:-openai}" | |
| local OPENAI_MODEL="${OPENAI_MODEL:-gpt-4o-mini}" | |
| # For custom provider (stdin): | |
| # export AIRV_CUSTOM_CMD='myllm --read-stdin --task "code-review"' | |
| local AIRV_CUSTOM_CMD="${AIRV_CUSTOM_CMD:-}" | |
| usage() { | |
| cat <<'USAGE' | |
| Usage: | |
| airv [-r repo] [-x glob]... [-F glob]... [-S size] | |
| [-P provider]... [--list-providers] | |
| [--no-context] [--keep] [-O file.md] [--comment] [--no-stdout] | |
| [--focus area]... [--preset name] [--format kind] [--style level] | |
| [PR_NUMBER] ["Your review prompt"] | |
| Options: | |
| -r repo GitHub repo (owner/name). Defaults to current gh repo | |
| -x glob Exclude path glob (repeatable), e.g. -x 'dist/**' -x 'package-lock.json' | |
| -F glob Include-only path glob (repeatable). If any present, others are filtered out | |
| -S size Split patch into chunks (e.g. 900k). "0" = no split | |
| -P provider One of: gemini | qwen | codex | copilot | openai | custom | |
| (Repeatable; run multiple providers in one go) | |
| --list-providers List provider adapters and exit | |
| --no-context Do not prepend PR title/labels/body to the prompt | |
| --keep Keep temporary files (prints the temp dir) | |
| -O file.md Save a merged Markdown report to this path | |
| --comment Post the merged report back to the PR as comments (auto-split) | |
| --no-stdout Suppress provider output streaming to terminal | |
| --focus area Review focus (repeatable). Known: | |
| security, quality, readability, performance, testing, docs, | |
| api, error-handling, i18n, logging, solid, dry, kiss, | |
| architecture, deps, secrets | |
| --preset name Shortcut sets of focuses. Known: | |
| - safe => security, error-handling, logging, secrets, deps | |
| - clean => quality, readability, solid, dry, kiss, docs | |
| - perf => performance, quality | |
| - readable => readability, docs, kiss | |
| - full => security, quality, readability, performance, testing, docs, | |
| api, error-handling, logging, solid, dry, kiss, architecture, | |
| deps, secrets | |
| --format kind markdown | checklist | json (default: markdown) | |
| --style level brief | standard | thorough (default: standard) | |
| -h Help | |
| Examples: | |
| airv 123 | |
| airv -P gemini -P copilot -x 'dist/**' 456 "Security sweep: auth/authz, injection, secrets. Rate High/Med/Low with fixes." | |
| airv -P openai -S 900k -O reviews/pr-789.md --comment 789 "Blockers only; ≤10 bullets; include test gaps." | |
| AIRV_CUSTOM_CMD='myllm review --stdin' airv -P custom 101 "Edge cases + perf hotspots with code-level fixes." | |
| # Compose prompts with focuses/presets (no custom prompt string at the end): | |
| airv --preset safe 234 | |
| airv --focus security --focus solid --format checklist 235 | |
| airv --focus readability --focus dry --style brief 236 | |
| USAGE | |
| } | |
| # ---------- Helpers: test for tool ---------- | |
| _need() { command -v "$1" >/dev/null 2>&1; } | |
| # ---------- Provider capabilities ---------- | |
| # MODE_ATFILE : tool accepts "@<file>" in the prompt (gemini/qwen/codex) | |
| # MODE_STDIN : tool reads full composed text from stdin (copilot/openai/custom) | |
| _provider_mode() { | |
| case "$1" in | |
| gemini|qwen|codex) echo "ATFILE" ;; | |
| copilot|openai|custom) echo "STDIN" ;; | |
| *) echo "UNKNOWN" ;; | |
| esac | |
| } | |
| _provider_check() { | |
| case "$1" in | |
| gemini) _need "$GEMINI_BIN" ;; | |
| qwen) _need "$QWEN_BIN" ;; | |
| codex) _need "$CODEX_BIN" ;; | |
| copilot) _need "$COPILOT_BIN" && "$COPILOT_BIN" help copilot >/dev/null 2>&1 ;; | |
| openai) _need "$OPENAI_BIN" ;; | |
| custom) [[ -n "$AIRV_CUSTOM_CMD" ]] ;; | |
| *) return 1 ;; | |
| esac | |
| } | |
| _provider_cmd() { | |
| # Echo the shell snippet to run, reading from: | |
| # - For ATFILE: uses PROMPT string (including @<file>). | |
| # - For STDIN : reads composed text from STDIN. | |
| local provider="$1"; shift | |
| local prompt_atfile="$1"; shift # only used for ATFILE providers | |
| case "$provider" in | |
| gemini) echo "$GEMINI_BIN -p \"${prompt_atfile}\"" ;; | |
| qwen) echo "$QWEN_BIN -p \"${prompt_atfile}\"" ;; | |
| codex) echo "$CODEX_BIN -p \"${prompt_atfile}\"" ;; | |
| copilot) echo "$COPILOT_BIN copilot suggest -t \"\$AIRV_STDIN_TITLE\"" ;; # reads patch from stdin | |
| openai) # Assumes a stdin-friendly command. Adjust to your openai CLI. | |
| # Example uses 'user -' to take the entire stdin as a user message. | |
| echo "$OPENAI_BIN chat.completions.create -m \"$OPENAI_MODEL\" -g user -" | |
| ;; | |
| custom) # User-provided command that reads from stdin | |
| echo "$AIRV_CUSTOM_CMD" | |
| ;; | |
| *) return 1 ;; | |
| esac | |
| } | |
| _resolve_pr_num() { | |
| local repo_args=() | |
| [[ -n "$REPO" ]] && repo_args+=(--repo "$REPO") | |
| # Try: the PR associated with the current branch | |
| if pr="$(gh pr view "${repo_args[@]}" --json number --jq .number 2>/dev/null)"; then | |
| if [[ -n "$pr" && "$pr" != "null" ]]; then | |
| echo "$pr"; return 0 | |
| fi | |
| fi | |
| # Fallback: find an open PR where head == current branch name | |
| local branch | |
| branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || return 1 | |
| pr="$(gh pr list "${repo_args[@]}" \ | |
| --search "is:open head:${branch}" \ | |
| --json number --jq '.[0].number' 2>/dev/null)" || true | |
| if [[ -n "$pr" && "$pr" != "null" ]]; then | |
| echo "$pr"; return 0 | |
| fi | |
| return 1 | |
| } | |
| # ---------- Parse flags ---------- | |
| if [[ $# -eq 0 ]]; then usage; return 1; fi | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -h|--help) usage; return 0 ;; | |
| --list-providers) | |
| cat <<EOF | |
| Providers: | |
| gemini -> \$GEMINI_BIN (default: gemini) mode: @file | |
| qwen -> \$QWEN_BIN (default: qwen) mode: @file | |
| codex -> \$CODEX_BIN (default: codex) mode: @file | |
| copilot -> \$COPILOT_BIN (default: gh) mode: stdin (gh copilot suggest) | |
| openai -> \$OPENAI_BIN (default: openai) mode: stdin (adjust command if needed) | |
| custom -> \$AIRV_CUSTOM_CMD mode: stdin (set full command) | |
| EOF | |
| return 0 | |
| ;; | |
| -r) REPO="$2"; shift 2 ;; | |
| -x) EXCLUDES+=("$2"); shift 2 ;; | |
| -F) INCLUDES+=("$2"); shift 2 ;; | |
| -S) SPLIT_SIZE="$2"; shift 2 ;; | |
| -O) OUTPUT_FILE="$2"; shift 2 ;; | |
| -P) PROVIDERS+=("$2"); shift 2 ;; | |
| --no-context) ADD_CONTEXT="false"; shift ;; | |
| --keep) KEEP_TMP="true"; shift ;; | |
| --comment) DO_COMMENT="true"; shift ;; | |
| --no-stdout) NO_STDOUT="true"; shift ;; | |
| --focus) FOCUS+=("$(echo "$2" | tr '[:upper:]' '[:lower:]')"); shift 2 ;; | |
| --preset) PRESET="$(echo "$2" | tr '[:upper:]' '[:lower:]')"; shift 2 ;; | |
| --format) FORMAT="$(echo "$2" | tr '[:upper:]' '[:lower:]')"; shift 2 ;; | |
| --style) STYLE="$(echo "$2" | tr '[:upper:]' '[:lower:]')"; shift 2 ;; | |
| --) shift; break ;; | |
| -*) | |
| echo "Unknown option: $1"; usage; return 2 ;; | |
| *) break ;; | |
| esac | |
| done | |
| # Positional args: | |
| # [PR_NUMBER] ["Your review prompt"] | |
| # If the first positional is a number, treat it as PR; otherwise try to resolve from branch. | |
| if [[ -n "${1:-}" && "$1" =~ ^[0-9]+$ ]]; then | |
| PR_NUM="$1"; shift | |
| fi | |
| if [[ -n "${1:-}" ]]; then | |
| REVIEW_PROMPT="$1" # explicit custom prompt wins | |
| fi | |
| if [[ -z "$PR_NUM" ]]; then | |
| if PR_NUM="$(_resolve_pr_num)"; then | |
| echo "🔗 Resolved current-branch PR: #$PR_NUM" | |
| else | |
| echo "❌ Could not infer a PR for the current branch. Pass a PR number explicitly." | |
| return 1 | |
| fi | |
| fi | |
| if [[ -n "$1" ]]; then REVIEW_PROMPT="$1"; fi | |
| if [[ "${#PROVIDERS[@]}" -eq 0 ]]; then PROVIDERS=("gemini"); fi | |
| # Core deps | |
| if ! _need gh; then echo "❌ Error: 'gh' CLI is required."; return 1; fi | |
| # Check providers available | |
| for p in "${PROVIDERS[@]}"; do | |
| if ! _provider_check "$p"; then | |
| echo "❌ Provider '$p' is not available. Use --list-providers for setup details." | |
| return 1 | |
| fi | |
| done | |
| # ---------- Temp workspace ---------- | |
| # Ensure temporary files are created within the repo’s working directory | |
| local REPO_ROOT | |
| REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | |
| local TMPDIR="${REPO_ROOT}/.airv_tmp_$$" | |
| mkdir -p "$TMPDIR" || { echo "❌ Failed to create temp dir in repo"; return 1; } | |
| local PATCH_FILE="$TMPDIR/pr_${PR_NUM}.patch" | |
| local FILTERED_PATCH="$TMPDIR/pr_${PR_NUM}.filtered.patch" | |
| local CONTEXT_FILE="$TMPDIR/pr_${PR_NUM}.context.md" | |
| local REPORT_DIR="$TMPDIR/report" | |
| mkdir -p "$REPORT_DIR" | |
| _airv_cleanup() { | |
| if [[ "$KEEP_TMP" != "true" ]]; then | |
| rm -rf "$TMPDIR" 2>/dev/null || true | |
| else | |
| echo "🧪 Kept temp files in: $TMPDIR" | |
| fi | |
| } | |
| trap _airv_cleanup EXIT | |
| echo "🔎 Generating patch for PR #$PR_NUM..." | |
| local GH_ARGS=(pr diff "$PR_NUM" --patch --color="$COLOR") | |
| [[ -n "$REPO" ]] && GH_ARGS+=(--repo "$REPO") | |
| if ! gh "${GH_ARGS[@]}" > "$PATCH_FILE"; then | |
| echo "❌ Error: Could not generate patch. Check gh auth/repo/PR number." | |
| return 1 | |
| fi | |
| # Optional PR context | |
| if [[ "$ADD_CONTEXT" == "true" ]]; then | |
| local VIEW_ARGS=(pr view "$PR_NUM" --json title,number,author,labels,baseRefName,headRefName,body,url) | |
| [[ -n "$REPO" ]] && VIEW_ARGS+=(--repo "$REPO") | |
| if gh "${VIEW_ARGS[@]}" > "$TMPDIR/pr_${PR_NUM}.json" 2>/dev/null && command -v jq >/dev/null 2>&1; then | |
| jq -r '[ | |
| "# PR Context", | |
| ("**Title:** " + (.title // "")), | |
| ("**Number:** " + ( (.number|tostring) // "" )), | |
| ("**Author:** " + ( .author.login // "" )), | |
| ("**Labels:** " + ( ( .labels|map(.name)|join(", ") ) // "" )), | |
| ("**Base:** " + ( .baseRefName // "" )), | |
| ("**Head:** " + ( .headRefName // "" )), | |
| ("**URL:** " + ( .url // "" )), | |
| "", | |
| ( ( .body // "" ) | if . == "" then "" else "## PR Description\n" + . end ) | |
| ] | join("\n")' "$TMPDIR/pr_${PR_NUM}.json" > "$CONTEXT_FILE" 2>/dev/null || true | |
| fi | |
| fi | |
| # Filtering | |
| cp "$PATCH_FILE" "$FILTERED_PATCH" | |
| sed -i '' -e $'s/\x1b\\[[0-9;]*m//g' "$FILTERED_PATCH" 2>/dev/null || true | |
| _filter_patch() { | |
| local input="$1"; shift | |
| local includes=("$@") | |
| if [[ "${#includes[@]}" -eq 0 ]]; then cat "$input"; return 0; fi | |
| awk -v globs="$(printf "%s\n" "${includes[@]}")" ' | |
| function anymatch(path, n,i,pat,cmd,res){ | |
| n=split(globs, arr, "\n") | |
| for(i=1;i<=n;i++){ pat=arr[i]; cmd = "bash -c '\''[[ \"" path "\" == " pat " ]]'\''"; res=system(cmd); if(res==0){return 1} } | |
| return 0 | |
| } | |
| /^diff --git /{ | |
| if (infile) {print buffer; buffer=""; infile=0} | |
| fileA=$3; fileB=$4; sub(/^a\//,"",fileA); sub(/^b\//,"",fileB); | |
| keep=anymatch(fileA) || anymatch(fileB) | |
| } | |
| { if (keep) {buffer = buffer $0 ORS; infile=1} } | |
| END{ if (infile) print buffer } | |
| ' "$input" | |
| } | |
| if [[ "${#INCLUDES[@]}" -gt 0 ]]; then | |
| _filter_patch "$FILTERED_PATCH" "${INCLUDES[@]}" > "$FILTERED_PATCH.includes" | |
| mv "$FILTERED_PATCH.includes" "$FILTERED_PATCH" | |
| fi | |
| if [[ "${#EXCLUDES[@]}" -gt 0 ]]; then | |
| awk -v globs="$(printf "%s\n" "${EXCLUDES[@]}")" ' | |
| function anymatch(path, n,i,pat,cmd,res){ | |
| n=split(globs, arr, "\n") | |
| for(i=1;i<=n;i++){ pat=arr[i]; cmd = "bash -c '\''[[ \"" path "\" == " pat " ]]'\''"; res=system(cmd); if(res==0){return 1} } | |
| return 0 | |
| } | |
| /^diff --git /{ | |
| if (infile && !drop) {print buffer} | |
| buffer=""; drop=0; infile=0 | |
| fileA=$3; fileB=$4; sub(/^a\//,"",fileA); sub(/^b\//,"",fileB); | |
| if (anymatch(fileA) || anymatch(fileB)) {drop=1} else {drop=0} | |
| } | |
| { buffer = buffer $0 ORS; infile=1 } | |
| END{ if (infile && !drop) print buffer } | |
| ' "$FILTERED_PATCH" > "$FILTERED_PATCH.excludes" | |
| mv "$FILTERED_PATCH.excludes" "$FILTERED_PATCH" | |
| fi | |
| if [[ ! -s "$FILTERED_PATCH" ]]; then | |
| echo "ℹ️ Patch is empty after filters; nothing to review." | |
| return 0 | |
| fi | |
| # ---------- Prompt Composer ---------- | |
| _merge_preset_into_focus() { | |
| case "$PRESET" in | |
| safe) FOCUS+=(security error-handling logging secrets deps) ;; | |
| clean) FOCUS+=(quality readability solid dry kiss docs) ;; | |
| perf) FOCUS+=(performance quality) ;; | |
| readable) FOCUS+=(readability docs kiss) ;; | |
| full) FOCUS+=(security quality readability performance testing docs api error-handling logging solid dry kiss architecture deps secrets) ;; | |
| ""|*) : ;; | |
| esac | |
| # de-dup | |
| if (( ${#FOCUS[@]} )); then | |
| local tmp=() seen="" | |
| for f in "${FOCUS[@]}"; do | |
| [[ "$seen" =~ (^|,)$f(,|$) ]] || { tmp+=("$f"); seen="${seen:+$seen,}$f"; } | |
| done | |
| FOCUS=("${tmp[@]}") | |
| fi | |
| } | |
| _focus_block() { | |
| case "$1" in | |
| security) | |
| cat <<'EOF' | |
| ### Security | |
| - Auth/Z boundaries, least privilege, CSRF/XSS/SQLi/NoSQLi/SSRF, path traversal | |
| - Secrets/config in code; key/IV handling; crypto misuse | |
| - Unsafe deserialization; command/process/file/network injection | |
| - Insecure defaults; missing rate limits; open CORS; trust of client data | |
| - Supply chain: pinned versions; known CVEs; unsafe transitive deps | |
| EOF | |
| ;; | |
| quality) | |
| cat <<'EOF' | |
| ### Code Quality | |
| - Clear names, small functions, single-responsibility | |
| - Avoid duplication; remove dead code; consistent style & idioms | |
| - Adequate comments where non-obvious; avoid over-commenting | |
| - Strong typing/nullability; input validation and preconditions | |
| EOF | |
| ;; | |
| readability) | |
| cat <<'EOF' | |
| ### Readability | |
| - Straight-line control flow; avoid deep nesting | |
| - Cohesive modules; clear public API vs internal helpers | |
| - Consistent file/folder structure; clear diffs and commit messages | |
| EOF | |
| ;; | |
| performance) | |
| cat <<'EOF' | |
| ### Performance | |
| - Algorithmic complexity hotspots; N+1 I/O; unnecessary allocations | |
| - Caching opportunities and invalidation | |
| - Blocking calls on hot paths; pagination/streaming for large data | |
| EOF | |
| ;; | |
| testing) | |
| cat <<'EOF' | |
| ### Testing | |
| - Unit/integ coverage of new logic and edge cases | |
| - Repro steps for fixed bugs; regression tests added | |
| - Determinism; avoids flaky timing/external dependencies | |
| EOF | |
| ;; | |
| docs) | |
| cat <<'EOF' | |
| ### Documentation | |
| - Updated README/CHANGELOG/migrations; usage examples | |
| - Comments explain "why", not "what"; public APIs documented | |
| EOF | |
| ;; | |
| api) | |
| cat <<'EOF' | |
| ### API & Contracts | |
| - Backward compatibility; versioning | |
| - Input/output validation; errors are structured and actionable | |
| EOF | |
| ;; | |
| error-handling) | |
| cat <<'EOF' | |
| ### Error Handling | |
| - Fail fast vs graceful degrade appropriately | |
| - No swallowed errors; contextual wrapping; actionable messages | |
| EOF | |
| ;; | |
| logging) | |
| cat <<'EOF' | |
| ### Logging & Observability | |
| - Structured logs; levels appropriate; no PII in logs | |
| - Metrics/traces for critical paths; feature flags have telemetry | |
| EOF | |
| ;; | |
| i18n) | |
| cat <<'EOF' | |
| ### Internationalization | |
| - No hard-coded user-facing strings; pluralization/locale aware | |
| EOF | |
| ;; | |
| solid) | |
| cat <<'EOF' | |
| ### SOLID | |
| - SRP/ISP respected; DI over hard coupling; LSP preserved | |
| EOF | |
| ;; | |
| dry) | |
| cat <<'EOF' | |
| ### DRY | |
| - Extract shared logic; avoid copy-paste across modules | |
| EOF | |
| ;; | |
| kiss) | |
| cat <<'EOF' | |
| ### KISS | |
| - Prefer simple solutions; remove unnecessary abstractions/config | |
| EOF | |
| ;; | |
| architecture) | |
| cat <<'EOF' | |
| ### Architecture | |
| - Clear boundaries; side-effects isolated; layering respected | |
| - Data ownership; migration/story for rollout & rollback | |
| EOF | |
| ;; | |
| deps) | |
| cat <<'EOF' | |
| ### Dependencies | |
| - Minimal new deps; license compatibility; size/runtime impact | |
| - Lockfiles updated; reproducible builds | |
| EOF | |
| ;; | |
| secrets) | |
| cat <<'EOF' | |
| ### Secrets | |
| - No secrets/tokens in code, tests, logs, or examples | |
| - Uses secret manager or env; rotation strategy noted | |
| EOF | |
| ;; | |
| *) : ;; | |
| esac | |
| } | |
| _compose_prompt() { | |
| local style_hdr="" | |
| case "$STYLE" in | |
| brief) style_hdr="Keep it concise (≤10 bullets) focusing only on impactful issues." ;; | |
| thorough) style_hdr="Be exhaustive. Include rationale, risk, and precise code-level fix suggestions." ;; | |
| *) style_hdr="Be clear and actionable with concrete suggestions." ;; | |
| esac | |
| local fmt_hint="" | |
| case "$FORMAT" in | |
| checklist) fmt_hint="Output as a GitHub-flavored Markdown checklist. Each item: [ ] **Severity** — One sentence issue + fix." ;; | |
| json) fmt_hint="Output valid JSON: {\"findings\":[{\"focus\":\"...\",\"severity\":\"High|Medium|Low\",\"title\":\"...\",\"details\":\"...\",\"file\":\"path:line\",\"fix\":\"...\"}]}. No extra text." ;; | |
| *) fmt_hint="Output Markdown with sections and numbered findings. Include file paths and suggested fixes." ;; | |
| esac | |
| { | |
| echo "You are an expert code reviewer. Review the following Git patch." | |
| echo "$style_hdr" | |
| echo "$fmt_hint" | |
| echo | |
| echo "General rules:" | |
| echo "- Prioritize correctness, security, and maintainability." | |
| echo "- Call out missing tests and risky migrations." | |
| echo "- Provide **specific** suggestions (function/line, sample diff)." | |
| echo "- Rate each finding: **High / Medium / Low** severity." | |
| echo | |
| if (( ${#FOCUS[@]} )); then | |
| echo "Focus areas:" | |
| for f in "${FOCUS[@]}"; do | |
| _focus_block "$f" | |
| done | |
| fi | |
| echo | |
| echo "Return only the review, no preamble about capabilities." | |
| } | sed -e 's/[[:space:]]\+$//' | |
| } | |
| # If user didn't pass a custom prompt, compose one | |
| if [[ -z "$REVIEW_PROMPT" ]]; then | |
| _merge_preset_into_focus | |
| if (( ${#FOCUS[@]} == 0 )); then | |
| # sensible default if nothing specified | |
| FOCUS=(security quality readability testing solid dry) | |
| fi | |
| REVIEW_PROMPT="$(_compose_prompt)" | |
| fi | |
| # ---------- Composition helpers ---------- | |
| # For STDIN providers we create a composed text that includes prompt + optional context + patch/chunk content | |
| _compose_stdin_file() { | |
| local part_file="$1" | |
| local out="$2" | |
| { | |
| echo "$REVIEW_PROMPT" | |
| echo | |
| if [[ -f "$CONTEXT_FILE" ]]; then | |
| echo "---" | |
| cat "$CONTEXT_FILE" | |
| echo | |
| fi | |
| echo "---" | |
| echo "PATCH BEGIN" | |
| cat "$part_file" | |
| echo "PATCH END" | |
| } > "$out" | |
| } | |
| _run_adapter_once() { | |
| local provider="$1" | |
| local part_file="$2" | |
| local header="$3" | |
| local out_base="$4" # without extension; function writes $out_base.out, $out_base.out.md | |
| local mode; mode="$(_provider_mode "$provider")" | |
| local cmd prompt_atfile composed_txt | |
| if [[ "$mode" == "ATFILE" ]]; then | |
| prompt_atfile="$(printf "%s @%s" "$REVIEW_PROMPT" "$part_file")" | |
| cmd="$(_provider_cmd "$provider" "$prompt_atfile")" | |
| if [[ "$NO_STDOUT" == "true" ]]; then | |
| eval "$cmd" > "$out_base.out" | |
| else | |
| eval "$cmd" | tee "$out_base.out" | |
| fi | |
| elif [[ "$mode" == "STDIN" ]]; then | |
| composed_txt="$out_base.stdin.txt" | |
| _compose_stdin_file "$part_file" "$composed_txt" | |
| # Some providers (copilot) want a short "title"/topic; we pass the first line of the prompt via env. | |
| AIRV_STDIN_TITLE="$(printf 'PR #%s review' "$PR_NUM")" | |
| export AIRV_STDIN_TITLE | |
| cmd="$(_provider_cmd "$provider" "")" | |
| if [[ "$NO_STDOUT" == "true" ]]; then | |
| cat "$composed_txt" | eval "$cmd" > "$out_base.out" | |
| else | |
| cat "$composed_txt" | eval "$cmd" | tee "$out_base.out" | |
| fi | |
| else | |
| echo "❌ Unknown provider mode for '$provider'"; return 1 | |
| fi | |
| # Wrap output into a Markdown section | |
| printf "## %s — %s\n\n" "$header" "$provider" > "$out_base.out.md" | |
| cat "$out_base.out" >> "$out_base.out.md" | |
| printf "\n" >> "$out_base.out.md" | |
| } | |
| echo "✨ Reviewing with: ${PROVIDERS[*]}" | |
| local per_provider_sections=() # collect merged sections per provider | |
| # Split or single | |
| local PARTS=() | |
| if [[ "$SPLIT_SIZE" != "0" ]]; then | |
| split -b "$SPLIT_SIZE" "$FILTERED_PATCH" "$FILTERED_PATCH.part." || { | |
| echo "❌ Failed to split patch"; return 1; | |
| } | |
| PARTS=( "$FILTERED_PATCH".part.* ) | |
| else | |
| PARTS=( "$FILTERED_PATCH" ) | |
| fi | |
| # For each provider, run all chunks and merge per-provider first | |
| for provider in "${PROVIDERS[@]}"; do | |
| local provider_md="$REPORT_DIR/provider_${provider}.md" | |
| : > "$provider_md" | |
| local idx=1 | |
| for part in "${PARTS[@]}"; do | |
| local base="$REPORT_DIR/${provider}_chunk_${idx}" | |
| local label; label=$([[ "${#PARTS[@]}" -eq 1 ]] && echo "Full Patch" || echo "Chunk $idx") | |
| echo "➡️ [$provider] $label ($(wc -c < "$part") bytes)..." | |
| _run_adapter_once "$provider" "$part" "$label" "$base" || { | |
| echo "❌ Provider '$provider' failed on $label"; return 1; | |
| } | |
| cat "$base.out.md" >> "$provider_md" | |
| idx=$((idx+1)) | |
| done | |
| per_provider_sections+=("$provider_md") | |
| done | |
| # ---------- Build merged report ---------- | |
| local merged="$TMPDIR/merged_report.md" | |
| { | |
| echo "# Automated PR Review" | |
| echo | |
| echo "- PR #: $PR_NUM" | |
| [[ -n "$REPO" ]] && echo "- Repo: $REPO" | |
| echo "- Providers: ${PROVIDERS[*]}" | |
| echo "- Generated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")" | |
| echo | |
| if [[ -f "$CONTEXT_FILE" ]]; then | |
| echo "---" | |
| cat "$CONTEXT_FILE" | |
| echo | |
| echo "---" | |
| echo | |
| fi | |
| for sect in "${per_provider_sections[@]}"; do | |
| # Add a provider header | |
| provider_name="$(basename "$sect" .md | sed 's/provider_//')" | |
| echo "## Provider: ${provider_name#provider_}" | |
| echo | |
| cat "$sect" | |
| echo | |
| done | |
| } > "$merged" | |
| # Save to -O if provided | |
| if [[ -n "$OUTPUT_FILE" ]]; then | |
| mkdir -p "$(dirname "$OUTPUT_FILE")" 2>/dev/null || true | |
| cp "$merged" "$OUTPUT_FILE" | |
| echo "💾 Merged report saved to: $OUTPUT_FILE" | |
| fi | |
| # Optional: post back to PR | |
| if [[ "$DO_COMMENT" == "true" ]]; then | |
| echo "💬 Posting merged report to PR #$PR_NUM..." | |
| local GH_COMMENT_LIMIT=60000 | |
| local title_line="Automated PR Review" | |
| if [[ -f "$TMPDIR/pr_${PR_NUM}.json" ]] && command -v jq >/dev/null 2>&1; then | |
| local pr_title; pr_title="$(jq -r '.title // empty' "$TMPDIR/pr_${PR_NUM}.json")" | |
| [[ -n "$pr_title" ]] && title_line="Automated PR Review — ${pr_title}" | |
| fi | |
| awk -v limit="$GH_COMMENT_LIMIT" -v header="$title_line" ' | |
| BEGIN { | |
| part=1; len=0; file=sprintf("comment_%03d.md", part); | |
| print("# " header "\n") > file; | |
| len=length("# " header "\n"); | |
| } | |
| { | |
| line=$0 "\n"; | |
| if (len + length(line) > limit) { | |
| close(file); | |
| part++; | |
| file=sprintf("comment_%03d.md", part); | |
| print("# " header " (cont.)\n") > file; | |
| len=length("# " header " (cont.)\n"); | |
| } | |
| printf("%s", line) >> file; | |
| len += length(line); | |
| } | |
| ' "$merged" | |
| local parts=( "$TMPDIR"/comment_*.md ) | |
| local n=${#parts[@]} | |
| local i=1 | |
| for f in "${parts[@]}"; do | |
| local label="(part $i/$n)" | |
| if ! gh pr comment "$PR_NUM" ${REPO:+--repo "$REPO"} --body-file "$f" >/dev/null; then | |
| echo "❌ Failed to post comment $label from $f" | |
| return 1 | |
| fi | |
| echo "✅ Posted comment $label" | |
| i=$((i+1)) | |
| done | |
| fi | |
| echo "🧹 Done." | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment