fix(obsidian-kb): restore files and shorten description
This commit is contained in:
@@ -1,566 +0,0 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user