Skip to content

Instantly share code, notes, and snippets.

@joeywang
Created October 23, 2025 19:45
Show Gist options
  • Select an option

  • Save joeywang/26174d79827ea8ac6d1e933326983e65 to your computer and use it in GitHub Desktop.

Select an option

Save joeywang/26174d79827ea8ac6d1e933326983e65 to your computer and use it in GitHub Desktop.
# 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