567 lines
18 KiB
Bash
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
|