|
| 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