mirror of https://github.com/garrytan/gstack.git
138 lines
4.7 KiB
Bash
Executable File
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
|