Skip to content

Commit 625b24a

Browse files
committed
feat: add gstack-pr-triage — classify and order community PRs for /autoplan
Fetches open community PRs, classifies them (security/fix/test/docs/feature), assesses risk (core file changes, size, missing tests), detects file conflicts between PRs, and suggests merge order (security first, features last). Outputs structured JSON to stdout for /autoplan consumption. Usage: gstack-pr-triage # all open community PRs gstack-pr-triage 154 155 156 # specific PRs gstack-pr-triage --category fix # filter by type
1 parent 7fbf68b commit 625b24a

1 file changed

Lines changed: 137 additions & 0 deletions

File tree

bin/gstack-pr-triage

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env bash
2+
# gstack-pr-triage — fetch and classify open community PRs for /autoplan
3+
#
4+
# Outputs structured JSON: PR metadata, classification, file conflicts,
5+
# and suggested merge order. Feeds directly into /autoplan's merge planning.
6+
#
7+
# Usage:
8+
# gstack-pr-triage # all open community PRs
9+
# gstack-pr-triage 154 155 156 # specific PR numbers
10+
# gstack-pr-triage --category fix # filter by classification
11+
#
12+
# Output: JSON to stdout (pipe to jq, feed to /autoplan)
13+
set -euo pipefail
14+
15+
TMPDIR="${TMPDIR:-/tmp}"
16+
PRS_FILE=$(mktemp "$TMPDIR/gstack-pr-triage.XXXXXX")
17+
trap 'rm -f "$PRS_FILE"' EXIT
18+
19+
FILTER_CATEGORY=""
20+
PR_NUMBERS=()
21+
22+
while [ $# -gt 0 ]; do
23+
case "$1" in
24+
--category) FILTER_CATEGORY="${2:-}"; shift 2 ;;
25+
--category=*) FILTER_CATEGORY="${1#*=}"; shift ;;
26+
[0-9]*) PR_NUMBERS+=("$1"); shift ;;
27+
*) shift ;;
28+
esac
29+
done
30+
31+
OWNER=$(gh repo view --json owner -q '.owner.login' 2>/dev/null || echo "")
32+
33+
if [ ${#PR_NUMBERS[@]} -gt 0 ]; then
34+
echo "[" > "$PRS_FILE"
35+
FIRST=true
36+
for num in "${PR_NUMBERS[@]}"; do
37+
$FIRST || echo "," >> "$PRS_FILE"
38+
FIRST=false
39+
gh pr view "$num" --json number,title,author,files,additions,deletions,headRefName,labels 2>/dev/null >> "$PRS_FILE" || echo '{}' >> "$PRS_FILE"
40+
done
41+
echo "]" >> "$PRS_FILE"
42+
else
43+
gh pr list --state open --limit 50 --json number,title,author,files,additions,deletions,headRefName,labels 2>/dev/null > "$PRS_FILE" || echo "[]" > "$PRS_FILE"
44+
fi
45+
46+
python3 - "$PRS_FILE" "$OWNER" "$FILTER_CATEGORY" << 'PYEOF'
47+
import json, sys
48+
from collections import defaultdict
49+
50+
prs_file, owner, filter_cat = sys.argv[1], sys.argv[2], sys.argv[3]
51+
52+
with open(prs_file) as f:
53+
prs = json.load(f)
54+
55+
def classify(pr):
56+
title = pr.get('title', '').lower()
57+
files = [f.get('path', '') for f in pr.get('files', [])]
58+
additions = pr.get('additions', 0)
59+
deletions = pr.get('deletions', 0)
60+
total = additions + deletions
61+
62+
if any(k in title for k in ['security', 'cve', 'vuln', 'inject', 'redact', 'sanitize']):
63+
cat = 'security'
64+
elif any(k in title for k in ['fix', 'bug', 'patch', 'hotfix']):
65+
cat = 'fix'
66+
elif any(k in title for k in ['test', 'spec', 'coverage', 'eval']):
67+
cat = 'test'
68+
elif any(k in title for k in ['doc', 'readme', 'changelog']):
69+
cat = 'docs'
70+
elif any(k in title for k in ['feat', 'add', 'new', 'support']):
71+
cat = 'feature'
72+
elif any(k in title for k in ['refactor', 'clean', 'improve']):
73+
cat = 'refactor'
74+
else:
75+
cat = 'other'
76+
77+
size = 'XS' if total <= 50 else 'S' if total <= 200 else 'M' if total <= 500 else 'L' if total <= 1500 else 'XL'
78+
79+
risk = 'low'
80+
risk_reasons = []
81+
high_risk = ['server.ts', 'browser-manager.ts', 'cli.ts', 'setup', 'package.json']
82+
risky = [f for f in files if any(p in f for p in high_risk)]
83+
if risky:
84+
risk = 'high'
85+
risk_reasons.append(f'modifies core: {", ".join(risky[:3])}')
86+
if size in ('L', 'XL'):
87+
risk = max(risk, 'medium', key=lambda x: ['low','medium','high'].index(x))
88+
risk_reasons.append(f'large change ({total} lines)')
89+
90+
has_tests = any('test' in f.lower() for f in files)
91+
if not has_tests and cat == 'feature':
92+
risk_reasons.append('no tests')
93+
94+
return {
95+
'category': cat, 'size': size, 'risk': risk,
96+
'risk_reasons': risk_reasons, 'has_tests': has_tests,
97+
'files': files, 'lines_changed': total,
98+
}
99+
100+
results = []
101+
file_owners = defaultdict(list)
102+
103+
for pr in prs:
104+
author = pr.get('author', {}).get('login', '')
105+
if author == owner:
106+
continue
107+
a = classify(pr)
108+
if filter_cat and a['category'] != filter_cat:
109+
continue
110+
for f in a['files']:
111+
file_owners[f].append(pr.get('number'))
112+
results.append({
113+
'number': pr.get('number'), 'title': pr.get('title'),
114+
'author': author, 'branch': pr.get('headRefName'),
115+
'category': a['category'], 'size': a['size'],
116+
'risk': a['risk'], 'risk_reasons': a['risk_reasons'],
117+
'has_tests': a['has_tests'], 'lines_changed': a['lines_changed'],
118+
'file_count': len(a['files']), 'files': a['files'][:10],
119+
})
120+
121+
conflicts = [{'file': f, 'prs': nums} for f, nums in file_owners.items() if len(nums) > 1]
122+
123+
PRIORITY = {'security': 0, 'fix': 1, 'test': 2, 'docs': 3, 'refactor': 4, 'feature': 5, 'other': 6}
124+
results.sort(key=lambda r: (PRIORITY.get(r['category'], 99), r['lines_changed']))
125+
126+
output = {
127+
'total_prs': len(results),
128+
'by_category': {},
129+
'conflicts': conflicts,
130+
'merge_order': [r['number'] for r in results],
131+
'prs': results,
132+
}
133+
for r in results:
134+
output['by_category'][r['category']] = output['by_category'].get(r['category'], 0) + 1
135+
136+
print(json.dumps(output, indent=2))
137+
PYEOF

0 commit comments

Comments
 (0)