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