This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
openclaw-skills/skills/pr-reviewer/scripts/pr-review.sh
T

567 lines
18 KiB
Bash

#!/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 <PR#> # Review a specific PR (force re-review)
# pr-review.sh post <PR#> # 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 <PR#>|post <PR#>|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