Skip to content

Commit cb5e52a

Browse files
committed
feat: gstack-risk-score — predict ship risk from git history
Computes 0-100 risk score for the current branch based on: - Fix ratio of changed files (historically buggy files score higher) - Bus factor (single-author files = knowledge risk) - Test coverage gap (source changes without test changes) - Core file modifications (server.ts, cli.ts, package.json) - Change size (>1000 lines = elevated risk) - Recent reverts on changed files Usage: gstack-risk-score # human-readable output gstack-risk-score --json # machine-readable for /ship integration
1 parent 7fbf68b commit cb5e52a

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

bin/gstack-risk-score

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#!/usr/bin/env bash
2+
# gstack-risk-score — predict ship risk from git history
3+
#
4+
# Computes a 0-100 risk score for the current branch based on:
5+
# - Fix ratio of changed files (high fix rate = risky)
6+
# - Bus factor (single-author files)
7+
# - Test coverage (source changes without test changes)
8+
# - File churn (frequently changed files = unstable)
9+
# - Historical revert rate
10+
#
11+
# Usage:
12+
# gstack-risk-score # score current branch
13+
# gstack-risk-score --json # machine-readable output
14+
#
15+
# Output: risk score + breakdown to stdout
16+
set -euo pipefail
17+
18+
JSON_MODE=""
19+
[ "${1:-}" = "--json" ] && JSON_MODE=1
20+
21+
# Detect base branch
22+
BASE=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || \
23+
gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || \
24+
echo "main")
25+
26+
# Get changed files
27+
CHANGED_FILES=$(git diff "$BASE"...HEAD --name-only 2>/dev/null | grep -v "^$" || true)
28+
if [ -z "$CHANGED_FILES" ]; then
29+
echo "No changes against $BASE."
30+
exit 0
31+
fi
32+
33+
python3 - "$BASE" << 'PYEOF'
34+
import subprocess, json, sys, os
35+
from collections import defaultdict
36+
37+
base = sys.argv[1]
38+
json_mode = os.environ.get('JSON_MODE', '')
39+
40+
def run(cmd):
41+
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
42+
return r.stdout.strip()
43+
44+
# Changed files
45+
changed = run(f'git diff {base}...HEAD --name-only').split('\n')
46+
changed = [f for f in changed if f.strip()]
47+
if not changed:
48+
print("No changes.")
49+
sys.exit(0)
50+
51+
source_files = [f for f in changed if not any(t in f.lower() for t in ['test', 'spec', '__test'])]
52+
test_files = [f for f in changed if any(t in f.lower() for t in ['test', 'spec', '__test'])]
53+
54+
risks = []
55+
score = 0 # start at 0, add risk points
56+
57+
# 1. Fix ratio per file (high fix ratio = historically buggy)
58+
file_fix_ratios = {}
59+
for f in source_files:
60+
total = run(f'git log --oneline --follow -- "{f}" 2>/dev/null | wc -l').strip()
61+
fixes = run(f'git log --oneline --follow --grep="fix" -i -- "{f}" 2>/dev/null | wc -l').strip()
62+
total = int(total) if total.isdigit() else 0
63+
fixes = int(fixes) if fixes.isdigit() else 0
64+
if total >= 5:
65+
ratio = round(fixes / total, 2)
66+
file_fix_ratios[f] = {'fixes': fixes, 'total': total, 'ratio': ratio}
67+
if ratio >= 0.5:
68+
score += 15
69+
risks.append({'type': 'high_fix_ratio', 'file': f, 'detail': f'{fixes}/{total} commits are fixes ({int(ratio*100)}%)', 'points': 15})
70+
elif ratio >= 0.3:
71+
score += 8
72+
risks.append({'type': 'moderate_fix_ratio', 'file': f, 'detail': f'{fixes}/{total} commits are fixes ({int(ratio*100)}%)', 'points': 8})
73+
74+
# 2. Bus factor (single author in last 6 months)
75+
for f in source_files:
76+
authors = run(f'git log --since="6 months ago" --format="%aN" -- "{f}" 2>/dev/null | sort -u | wc -l').strip()
77+
authors = int(authors) if authors.isdigit() else 0
78+
if authors == 1:
79+
author = run(f'git log --since="6 months ago" --format="%aN" -- "{f}" 2>/dev/null | head -1').strip()
80+
score += 10
81+
risks.append({'type': 'bus_factor_1', 'file': f, 'detail': f'only {author} in last 6 months', 'points': 10})
82+
83+
# 3. Test coverage gap
84+
if source_files and not test_files:
85+
score += 20
86+
risks.append({'type': 'no_tests', 'file': 'branch', 'detail': f'{len(source_files)} source files changed, 0 test files', 'points': 20})
87+
88+
# 4. Core file risk
89+
core_files = ['server.ts', 'browser-manager.ts', 'cli.ts', 'setup', 'package.json']
90+
for f in source_files:
91+
if any(c in f for c in core_files):
92+
score += 12
93+
risks.append({'type': 'core_file', 'file': f, 'detail': 'core infrastructure file', 'points': 12})
94+
95+
# 5. Large change size
96+
total_lines = run(f'git diff {base}...HEAD --shortstat')
97+
added = 0
98+
if total_lines:
99+
import re
100+
m = re.search(r'(\d+) insertion', total_lines)
101+
if m: added = int(m.group(1))
102+
m = re.search(r'(\d+) deletion', total_lines)
103+
d = int(m.group(1)) if m else 0
104+
total_churn = added + d
105+
if total_churn > 1000:
106+
score += 15
107+
risks.append({'type': 'large_change', 'file': 'branch', 'detail': f'{total_churn} lines changed', 'points': 15})
108+
elif total_churn > 500:
109+
score += 8
110+
risks.append({'type': 'medium_change', 'file': 'branch', 'detail': f'{total_churn} lines changed', 'points': 8})
111+
112+
# 6. Recent reverts on changed files
113+
for f in source_files[:5]: # cap to avoid slow queries
114+
reverts = run(f'git log --oneline --since="30 days ago" --grep="revert" -i -- "{f}" 2>/dev/null | wc -l').strip()
115+
reverts = int(reverts) if reverts.isdigit() else 0
116+
if reverts > 0:
117+
score += 15
118+
risks.append({'type': 'recent_revert', 'file': f, 'detail': f'{reverts} reverts in last 30 days', 'points': 15})
119+
120+
# Cap at 100
121+
score = min(score, 100)
122+
123+
# Risk level
124+
if score <= 20: level = 'low'
125+
elif score <= 40: level = 'moderate'
126+
elif score <= 60: level = 'elevated'
127+
elif score <= 80: level = 'high'
128+
else: level = 'critical'
129+
130+
output = {
131+
'score': score,
132+
'level': level,
133+
'base': base,
134+
'files_changed': len(changed),
135+
'source_files': len(source_files),
136+
'test_files': len(test_files),
137+
'risk_factors': risks,
138+
}
139+
140+
if json_mode:
141+
print(json.dumps(output, indent=2))
142+
else:
143+
print(f"SHIP RISK SCORE: {score}/100 ({level})")
144+
print()
145+
if risks:
146+
risks.sort(key=lambda r: r['points'], reverse=True)
147+
for r in risks:
148+
icon = '⚠' if r['points'] >= 10 else '○'
149+
print(f" {icon} {r['file']}")
150+
print(f" {r['detail']} (+{r['points']} risk)")
151+
else:
152+
print(" No risk factors detected.")
153+
print()
154+
if test_files:
155+
print(f" ✓ {len(test_files)} test files included")
156+
print(f"\nBranch: HEAD vs {base} ({len(changed)} files changed)")
157+
PYEOF

0 commit comments

Comments
 (0)