#!/usr/bin/env bash # pr-review.sh — Automated GitHub PR code review # # Fetches open PRs, analyzes diffs for security/error/style issues, # runs local lint, and generates structured review reports. # # Usage: # pr-review.sh check # Review all unreviewed open PRs # pr-review.sh review # Review a specific PR (force re-review) # pr-review.sh post # Post review as a GitHub PR comment # pr-review.sh status # Show review state for all open PRs # pr-review.sh list-unreviewed # List PRs needing review (for automation) # # Environment: # PR_REVIEW_REPO — owner/repo (default: auto-detect from gh) # PR_REVIEW_DIR — local checkout for lint (default: git root) # PR_REVIEW_STATE — state file (default: ./data/pr-reviews.json) # PR_REVIEW_OUTDIR — reports dir (default: ./data/pr-reviews/) set -euo pipefail # ── Configuration ──────────────────────────────────────────────────────────── REPO="${PR_REVIEW_REPO:-}" LOCAL_DIR="${PR_REVIEW_DIR:-}" STATE_FILE="${PR_REVIEW_STATE:-./data/pr-reviews.json}" REVIEWS_DIR="${PR_REVIEW_OUTDIR:-./data/pr-reviews}" # Auto-detect repo if not set if [ -z "$REPO" ]; then REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || echo "") if [ -z "$REPO" ]; then echo "Error: Could not detect repo. Set PR_REVIEW_REPO=owner/repo" >&2 exit 1 fi fi # Auto-detect local dir if not set if [ -z "$LOCAL_DIR" ]; then LOCAL_DIR=$(git rev-parse --show-toplevel 2>/dev/null || echo "") fi mkdir -p "$REVIEWS_DIR" [ -f "$STATE_FILE" ] || echo '{}' > "$STATE_FILE" # ── Helpers ────────────────────────────────────────────────────────────────── log() { echo "[pr-review] $*" >&2; } get_open_prs() { gh pr list --repo "$REPO" --state open \ --json number,title,author,createdAt,headRefName,additions,deletions,changedFiles,labels,baseRefName \ 2>/dev/null || echo '[]' } get_pr_diff() { gh pr diff "$1" --repo "$REPO" 2>/dev/null || echo "" } get_pr_files() { gh pr view "$1" --repo "$REPO" --json files --jq '.files[].path' 2>/dev/null || echo "" } get_pr_commits() { gh pr view "$1" --repo "$REPO" --json commits \ --jq '.commits[] | "\(.oid[:8]) \(.messageHeadline)"' 2>/dev/null || echo "" } is_reviewed() { local pr_num="$1" local head_sha head_sha=$(gh pr view "$pr_num" --repo "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "") python3 -c " import json with open('$STATE_FILE') as f: state = json.load(f) pr = state.get(str($pr_num), {}) if pr.get('head_sha') == '$head_sha' and pr.get('status') == 'reviewed': print('yes') else: print('no') " 2>/dev/null || echo "no" } update_state() { python3 -c " import json, time with open('$STATE_FILE') as f: state = json.load(f) state[str($1)] = { 'head_sha': '$2', 'status': '$3', 'reviewed_at': int(time.time()), 'report': '$REVIEWS_DIR/${1}.md' } with open('$STATE_FILE', 'w') as f: json.dump(state, f, indent=2) " } # ── Analysis ───────────────────────────────────────────────────────────────── categorize_files() { python3 -c " import sys, json from collections import defaultdict cats = defaultdict(list) for line in sys.stdin: f = line.strip() if not f: continue ext = f.rsplit('.', 1)[-1] if '.' in f else '' if ext == 'go': cats['go'].append(f) elif ext == 'py': cats['python'].append(f) elif ext in ('ts', 'tsx', 'js', 'jsx'): cats['frontend'].append(f) elif ext in ('yml', 'yaml', 'toml', 'json', 'env'): cats['config'].append(f) elif ext in ('md', 'txt', 'rst'): cats['docs'].append(f) elif ext == 'sql': cats['sql'].append(f) elif 'Dockerfile' in f or f == 'docker-compose.yml': cats['docker'].append(f) elif f.startswith('.github/'): cats['ci'].append(f) else: cats['other'].append(f) print(json.dumps(dict(cats))) " } analyze_diff() { python3 << 'PYEOF' import sys, json, re diff_text = sys.stdin.read() findings = [] secret_patterns = [ (r'(?i)(password|passwd|secret|api[_-]?key|token|auth)\s*[:=]\s*["\x27][^"\x27]{8,}["\x27]', 'SECURITY', 'Possible hardcoded credential/secret'), (r'(?i)AWS[_A-Z]*KEY\s*[:=]', 'SECURITY', 'Possible hardcoded AWS key'), (r'(?i)-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY', 'SECURITY', 'Private key in source code'), ] go_patterns = [ (r'^\+.*,\s*_\s*:?=.*\(', 'ERROR_HANDLING', 'Discarded error return value (Go)'), (r'^\+.*\.Close\(\)\s*$', 'ERROR_HANDLING', 'Unchecked Close() — consider defer with error check'), (r'^\+.*panic\(', 'RISK', 'Direct panic() call — consider returning error'), (r'^\+.*fmt\.Print', 'STYLE', 'fmt.Print in production code — use structured logging'), (r'^\+.*os\.Exit\(', 'RISK', 'Direct os.Exit() — consider returning error'), ] python_patterns = [ (r'^\+.*except\s*:', 'ERROR_HANDLING', 'Bare except clause — catches everything including SystemExit'), (r'^\+.*except Exception:', 'ERROR_HANDLING', 'Broad except Exception — consider specific errors'), (r'^\+.*print\(', 'STYLE', 'print() in production code — use logging module'), (r'^\+.*# type: ignore', 'TYPING', 'Type ignore comment — document why'), ] js_patterns = [ (r'^\+.*console\.log\(', 'STYLE', 'console.log in production code'), (r'^\+.*debugger', 'STYLE', 'Debugger statement — remove before merge'), (r'^\+.*process\.exit\(', 'RISK', 'Direct process.exit() — consider throwing'), (r'^\+.*eval\(', 'SECURITY', 'eval() usage — potential code injection'), (r'^\+.*any\b', 'TYPING', 'TypeScript `any` type — consider specific type'), ] general_patterns = [ (r'^\+.*TODO', 'TODO', 'TODO marker — track or address'), (r'^\+.*FIXME', 'TODO', 'FIXME marker — should address before merge'), (r'^\+.*HACK', 'TODO', 'HACK marker — needs cleanup'), (r'^\+.*XXX', 'TODO', 'XXX marker — needs attention'), (r'^\+.{200,}', 'STYLE', 'Very long line (>200 chars)'), ] lines = diff_text.split('\n') current_file = None line_num = 0 for line in lines: m = re.match(r'^\+\+\+ b/(.*)', line) if m: current_file = m.group(1) continue m = re.match(r'^@@ -\d+(?:,\d+)? \+(\d+)', line) if m: line_num = int(m.group(1)) continue if line.startswith('+') and not line.startswith('+++'): line_num += 1 all_p = secret_patterns + general_patterns if current_file: if current_file.endswith('.go'): all_p += go_patterns elif current_file.endswith('.py'): all_p += python_patterns elif current_file.endswith(('.js', '.jsx', '.ts', '.tsx')): all_p += js_patterns for pat, cat, msg in all_p: if re.search(pat, line): findings.append({ 'file': current_file or 'unknown', 'line': line_num, 'category': cat, 'message': msg, 'context': line[1:].strip()[:120] }) elif not line.startswith('-'): line_num += 1 print(json.dumps(findings)) PYEOF } run_local_lint() { local files="$1" local results="" if [ -z "$LOCAL_DIR" ] || [ ! -d "$LOCAL_DIR" ]; then return fi # Go lint local go_files go_files=$(echo "$files" | grep '\.go$' || true) if [ -n "$go_files" ] && command -v golangci-lint &>/dev/null; then local go_dirs go_dirs=$(echo "$go_files" | sed -n 's|\(.*\)/[^/]*\.go$|\1|p' | sort -u) for dir in $go_dirs; do local full="$LOCAL_DIR/$dir" if [ -d "$full" ]; then local out out=$(cd "$full" && golangci-lint run --timeout 2m --new-from-rev=HEAD~1 2>&1 || true) if [ -n "$out" ]; then results="${results}\n### golangci-lint ($dir)\n\`\`\`\n${out}\n\`\`\`\n" fi fi done fi # Python lint local py_files py_files=$(echo "$files" | grep '\.py$' || true) if [ -n "$py_files" ] && command -v ruff &>/dev/null; then local py_paths py_paths=$(echo "$py_files" | sed "s|^|$LOCAL_DIR/|" | tr '\n' ' ') local ruff_cfg="" [ -f "$LOCAL_DIR/pyproject.toml" ] && ruff_cfg="--config $LOCAL_DIR/pyproject.toml" local out out=$(ruff check $ruff_cfg $py_paths 2>&1 || true) if [ -n "$out" ] && ! echo "$out" | grep -q "^All checks passed"; then results="${results}\n### ruff\n\`\`\`\n${out}\n\`\`\`\n" fi fi echo -e "$results" } check_test_coverage() { python3 -c " import sys files = '''$1'''.strip().split('\n') src, tests = [], [] for f in files: f = f.strip() if not f: continue if f.endswith('_test.go') or 'test_' in f.split('/')[-1] or f.endswith('_test.py') or f.endswith('.test.ts') or f.endswith('.test.js') or f.endswith('.spec.ts') or f.endswith('.spec.js'): tests.append(f) elif f.endswith(('.go', '.py', '.ts', '.tsx', '.js', '.jsx')): src.append(f) missing = [] for s in src: has_test = False s_dir = '/'.join(s.split('/')[:-1]) s_name = s.split('/')[-1].rsplit('.', 1)[0] for t in tests: t_dir = '/'.join(t.split('/')[:-1]) if t_dir == s_dir or f'test_{s_name}' in t or f'{s_name}_test' in t or f'{s_name}.test' in t or f'{s_name}.spec' in t: has_test = True break skip = any(k in s for k in ['__init__', 'main.go', 'main.py', 'config', 'types', 'models', 'schema', 'index.ts', 'index.js']) if not has_test and not skip: missing.append(s) if missing: print('Files without corresponding test changes:') for f in missing: print(f' - {f}') else: print('Test coverage looks adequate for changed files.') " } # ── Report Generation ──────────────────────────────────────────────────────── generate_report() { local pr_num="$1" local force="${2:-false}" if [ "$force" != "true" ]; then local reviewed reviewed=$(is_reviewed "$pr_num") if [ "$reviewed" = "yes" ]; then log "PR #$pr_num already reviewed at current HEAD. Use 'review' to force." return 0 fi fi log "Reviewing PR #$pr_num..." local pr_info pr_info=$(gh pr view "$pr_num" --repo "$REPO" \ --json title,author,headRefName,headRefOid,baseRefName,additions,deletions,body,createdAt,labels 2>/dev/null) local title author branch head_sha additions deletions body created title=$(echo "$pr_info" | python3 -c "import json,sys; print(json.load(sys.stdin)['title'])") author=$(echo "$pr_info" | python3 -c "import json,sys; print(json.load(sys.stdin)['author']['login'])") branch=$(echo "$pr_info" | python3 -c "import json,sys; print(json.load(sys.stdin)['headRefName'])") head_sha=$(echo "$pr_info" | python3 -c "import json,sys; print(json.load(sys.stdin)['headRefOid'])") additions=$(echo "$pr_info" | python3 -c "import json,sys; print(json.load(sys.stdin)['additions'])") deletions=$(echo "$pr_info" | python3 -c "import json,sys; print(json.load(sys.stdin)['deletions'])") body=$(echo "$pr_info" | python3 -c "import json,sys; print(json.load(sys.stdin).get('body','') or '')") created=$(echo "$pr_info" | python3 -c "import json,sys; print(json.load(sys.stdin)['createdAt'])") local files diff commits files=$(get_pr_files "$pr_num") diff=$(get_pr_diff "$pr_num") commits=$(get_pr_commits "$pr_num") local categories categories=$(echo "$files" | categorize_files) local findings_file findings_file=$(mktemp) echo "$diff" | analyze_diff > "$findings_file" trap "rm -f '$findings_file'" EXIT local test_coverage test_coverage=$(check_test_coverage "$files") local lint_results="" lint_results=$(run_local_lint "$files" 2>/dev/null || echo "") local findings_summary findings_summary=$(python3 -c " import json from collections import Counter with open('$findings_file') as f: findings = json.load(f) counts = Counter(f['category'] for f in findings) if counts: for cat, count in sorted(counts.items()): icon = {'SECURITY':'🔴','ERROR_HANDLING':'🟡','RISK':'🟠','STYLE':'🔵','TODO':'📝','TYPING':'🟣'}.get(cat,'⚪') print(f'{icon} {cat}: {count}') else: print('✅ No issues found in diff analysis') ") local report_file="$REVIEWS_DIR/${pr_num}.md" cat > "$report_file" << REPORT # PR #${pr_num} Review: ${title} **Author:** ${author} **Branch:** \`${branch}\` **HEAD:** \`${head_sha:0:8}\` **Created:** ${created} **Changes:** +${additions} / -${deletions} **Reviewed:** $(date -Iseconds) ## Description ${body:-_No description provided._} ## Commits \`\`\` ${commits} \`\`\` ## Changed Files $(echo "$categories" | python3 -c " import json, sys cats = json.load(sys.stdin) icons = {'go':'🔷','python':'🐍','frontend':'🌐','ci':'⚙️','config':'📦','docs':'📝','docker':'🐳','sql':'💾','other':'📄'} for cat, files in sorted(cats.items()): print(f'### {icons.get(cat,\"📄\")} {cat.title()} ({len(files)} files)') for f in files: print(f'- \`{f}\`') print() ") ## Automated Analysis ### Diff Findings ${findings_summary} $(python3 -c " import json with open('$findings_file') as f: findings = json.load(f) if findings: print('| File | Line | Category | Finding | Context |') print('|------|------|----------|---------|---------|') for f in findings[:50]: ctx = f['context'].replace('|', '\\\\|') print(f'| \`{f[\"file\"].split(\"/\")[-1]}\` | {f[\"line\"]} | {f[\"category\"]} | {f[\"message\"]} | \`{ctx[:60]}\` |') ") ### Test Coverage ${test_coverage} $(if [ -n "$lint_results" ]; then echo "### Local Lint Results"; echo ""; echo "$lint_results"; else echo "### Local Lint"; echo ""; echo "_Skipped (repo not checked out locally or linters not found)._"; fi) ## Summary $(python3 -c " import json with open('$findings_file') as f: findings = json.load(f) sec = [f for f in findings if f['category'] == 'SECURITY'] err = [f for f in findings if f['category'] in ('ERROR_HANDLING', 'RISK')] sty = [f for f in findings if f['category'] in ('STYLE', 'TODO', 'TYPING')] if sec: print('🔴 **SECURITY CONCERNS** — Review security findings before merging.') elif err: print('🟡 **NEEDS ATTENTION** — Error handling / risk items to review.') elif sty: print('🔵 **MINOR STYLE NOTES** — Looks good overall, minor suggestions above.') else: print('✅ **LOOKS GOOD** — No automated issues found. Ready for human review.') ") --- _Automated PR review • $(date '+%Y-%m-%d %H:%M')_ REPORT update_state "$pr_num" "$head_sha" "reviewed" log "Report saved to $report_file" echo "$report_file" } # ── Commands ───────────────────────────────────────────────────────────────── cmd_check() { local prs prs=$(get_open_prs) local count count=$(echo "$prs" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))") if [ "$count" = "0" ]; then log "No open PRs." echo '{"reviewed": 0, "skipped": 0, "total": 0}' return 0 fi local reviewed=0 skipped=0 local pr_nums pr_nums=$(echo "$prs" | python3 -c "import json,sys; [print(p['number']) for p in json.load(sys.stdin)]") while IFS= read -r pr_num; do [ -z "$pr_num" ] && continue local already already=$(is_reviewed "$pr_num") if [ "$already" = "yes" ]; then skipped=$((skipped + 1)) log "PR #$pr_num: already reviewed, skipping." else generate_report "$pr_num" "false" reviewed=$((reviewed + 1)) fi done <<< "$pr_nums" echo "{\"reviewed\": $reviewed, \"skipped\": $skipped, \"total\": $count}" } cmd_review() { local pr_num="${1:?PR number required}" generate_report "$pr_num" "true" } cmd_post() { local pr_num="${1:?PR number required}" local report_file="$REVIEWS_DIR/${pr_num}.md" if [ ! -f "$report_file" ]; then log "No review report for PR #$pr_num. Run 'review $pr_num' first." exit 1 fi gh pr comment "$pr_num" --repo "$REPO" --body-file "$report_file" log "Review posted to PR #$pr_num" } cmd_status() { local prs prs=$(get_open_prs) python3 -c " import json, sys, time prs = json.loads(sys.stdin.read()) try: with open('$STATE_FILE') as f: state = json.load(f) except: state = {} if not prs: print('No open PRs.') sys.exit(0) print(f'Open PRs: {len(prs)}') print() for pr in prs: num = str(pr['number']) s = state.get(num, {}) status = s.get('status', 'unreviewed') icon = '✅' if status == 'reviewed' else '⏳' print(f'{icon} PR #{num}: {pr[\"title\"]} ({pr[\"author\"][\"login\"]})') print(f' +{pr[\"additions\"]}/-{pr[\"deletions\"]} | {pr[\"headRefName\"]}') if s: age = int(time.time()) - s.get('reviewed_at', 0) print(f' Reviewed {age // 3600}h ago | SHA: {s.get(\"head_sha\", \"?\")[:8]}') print() " <<< "$prs" } cmd_list_unreviewed() { local prs prs=$(get_open_prs) local pr_nums pr_nums=$(echo "$prs" | python3 -c "import json,sys; [print(p['number']) for p in json.load(sys.stdin)]" 2>/dev/null) while IFS= read -r pr_num; do [ -z "$pr_num" ] && continue local already already=$(is_reviewed "$pr_num") if [ "$already" != "yes" ]; then echo "$pr_num" fi done <<< "$pr_nums" } # ── Main ───────────────────────────────────────────────────────────────────── case "${1:-}" in check) cmd_check ;; review) cmd_review "${2:-}" ;; post) cmd_post "${2:-}" ;; status) cmd_status ;; list-unreviewed) cmd_list_unreviewed ;; *) echo "Usage: pr-review.sh {check|review |post |status|list-unreviewed}" echo "" echo "Environment:" echo " PR_REVIEW_REPO owner/repo (default: auto-detect)" echo " PR_REVIEW_DIR local checkout for lint (default: git root)" echo " PR_REVIEW_STATE state file (default: ./data/pr-reviews.json)" echo " PR_REVIEW_OUTDIR reports dir (default: ./data/pr-reviews/)" exit 1 ;; esac