gstack/bin/gstack-pr-triage

138 lines
4.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-pr-triage — fetch and classify open community PRs for /autoplan
#
# Outputs structured JSON: PR metadata, classification, file conflicts,
# and suggested merge order. Feeds directly into /autoplan's merge planning.
#
# Usage:
# gstack-pr-triage # all open community PRs
# gstack-pr-triage 154 155 156 # specific PR numbers
# gstack-pr-triage --category fix # filter by classification
#
# Output: JSON to stdout (pipe to jq, feed to /autoplan)
set -euo pipefail
TMPDIR="${TMPDIR:-/tmp}"
PRS_FILE=$(mktemp "$TMPDIR/gstack-pr-triage.XXXXXX")
trap 'rm -f "$PRS_FILE"' EXIT
FILTER_CATEGORY=""
PR_NUMBERS=()
while [ $# -gt 0 ]; do
case "$1" in
--category) FILTER_CATEGORY="${2:-}"; shift 2 ;;
--category=*) FILTER_CATEGORY="${1#*=}"; shift ;;
[0-9]*) PR_NUMBERS+=("$1"); shift ;;
*) shift ;;
esac
done
OWNER=$(gh repo view --json owner -q '.owner.login' 2>/dev/null || echo "")
if [ ${#PR_NUMBERS[@]} -gt 0 ]; then
echo "[" > "$PRS_FILE"
FIRST=true
for num in "${PR_NUMBERS[@]}"; do
$FIRST || echo "," >> "$PRS_FILE"
FIRST=false
gh pr view "$num" --json number,title,author,files,additions,deletions,headRefName,labels 2>/dev/null >> "$PRS_FILE" || echo '{}' >> "$PRS_FILE"
done
echo "]" >> "$PRS_FILE"
else
gh pr list --state open --limit 50 --json number,title,author,files,additions,deletions,headRefName,labels 2>/dev/null > "$PRS_FILE" || echo "[]" > "$PRS_FILE"
fi
python3 - "$PRS_FILE" "$OWNER" "$FILTER_CATEGORY" << 'PYEOF'
import json, sys
from collections import defaultdict
prs_file, owner, filter_cat = sys.argv[1], sys.argv[2], sys.argv[3]
with open(prs_file) as f:
prs = json.load(f)
def classify(pr):
title = pr.get('title', '').lower()
files = [f.get('path', '') for f in pr.get('files', [])]
additions = pr.get('additions', 0)
deletions = pr.get('deletions', 0)
total = additions + deletions
if any(k in title for k in ['security', 'cve', 'vuln', 'inject', 'redact', 'sanitize']):
cat = 'security'
elif any(k in title for k in ['fix', 'bug', 'patch', 'hotfix']):
cat = 'fix'
elif any(k in title for k in ['test', 'spec', 'coverage', 'eval']):
cat = 'test'
elif any(k in title for k in ['doc', 'readme', 'changelog']):
cat = 'docs'
elif any(k in title for k in ['feat', 'add', 'new', 'support']):
cat = 'feature'
elif any(k in title for k in ['refactor', 'clean', 'improve']):
cat = 'refactor'
else:
cat = 'other'
size = 'XS' if total <= 50 else 'S' if total <= 200 else 'M' if total <= 500 else 'L' if total <= 1500 else 'XL'
risk = 'low'
risk_reasons = []
high_risk = ['server.ts', 'browser-manager.ts', 'cli.ts', 'setup', 'package.json']
risky = [f for f in files if any(p in f for p in high_risk)]
if risky:
risk = 'high'
risk_reasons.append(f'modifies core: {", ".join(risky[:3])}')
if size in ('L', 'XL'):
risk = max(risk, 'medium', key=lambda x: ['low','medium','high'].index(x))
risk_reasons.append(f'large change ({total} lines)')
has_tests = any('test' in f.lower() for f in files)
if not has_tests and cat == 'feature':
risk_reasons.append('no tests')
return {
'category': cat, 'size': size, 'risk': risk,
'risk_reasons': risk_reasons, 'has_tests': has_tests,
'files': files, 'lines_changed': total,
}
results = []
file_owners = defaultdict(list)
for pr in prs:
author = pr.get('author', {}).get('login', '')
if author == owner:
continue
a = classify(pr)
if filter_cat and a['category'] != filter_cat:
continue
for f in a['files']:
file_owners[f].append(pr.get('number'))
results.append({
'number': pr.get('number'), 'title': pr.get('title'),
'author': author, 'branch': pr.get('headRefName'),
'category': a['category'], 'size': a['size'],
'risk': a['risk'], 'risk_reasons': a['risk_reasons'],
'has_tests': a['has_tests'], 'lines_changed': a['lines_changed'],
'file_count': len(a['files']), 'files': a['files'][:10],
})
conflicts = [{'file': f, 'prs': nums} for f, nums in file_owners.items() if len(nums) > 1]
PRIORITY = {'security': 0, 'fix': 1, 'test': 2, 'docs': 3, 'refactor': 4, 'feature': 5, 'other': 6}
results.sort(key=lambda r: (PRIORITY.get(r['category'], 99), r['lines_changed']))
output = {
'total_prs': len(results),
'by_category': {},
'conflicts': conflicts,
'merge_order': [r['number'] for r in results],
'prs': results,
}
for r in results:
output['by_category'][r['category']] = output['by_category'].get(r['category'], 0) + 1
print(json.dumps(output, indent=2))
PYEOF