Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit1d6db4dunknown_key

feat: bus factor warnings + PR split suggestions — knowledge risk alerts and AI-guided PR decomposition

feat: bus factor warnings + PR split suggestions — knowledge risk alerts and AI-guided PR decomposition

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: 34e63b9
7 files changed+124881d6db4d9b5718a741286d28d447a6a232a627ce4
7 changed files+1248−8
Addeddrizzle/0097_bus_factor.sql+8−0View fileUnifiedSplit
1CREATE TABLE IF NOT EXISTS bus_factor_cache (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
4 analyzed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
5 at_risk_files JSONB NOT NULL DEFAULT '[]',
6 total_files_analyzed INTEGER NOT NULL DEFAULT 0,
7 UNIQUE(repository_id)
8);
Modifiedsrc/app.tsx+3−0View fileUnifiedSplit
191191import healthScoreRoutes from "./routes/health-score";
192192import hotFilesRoutes from "./routes/hot-files";
193193import debtMapRoutes from "./routes/debt-map";
194import busFactorRoutes from "./routes/bus-factor";
194195import developerProgramRoutes from "./routes/developer-program";
195196import shareRoutes from "./routes/share";
196197import incidentHookRoutes from "./routes/incident-hooks";
738739app.route("/", incidentHookRoutes);
739740// AI Technical Debt Map — /:owner/:repo/debt-map (visual debt graph + Claude analysis)
740741app.route("/", debtMapRoutes);
742// Bus Factor Analysis — /:owner/:repo/insights/bus-factor
743app.route("/", busFactorRoutes);
741744// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
742745// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
743746app.route("/", claudeDeployRoutes);
Modifiedsrc/db/schema.ts+17−0View fileUnifiedSplit
44994499
45004500export type RecurringPattern = typeof recurringPatterns.$inferSelect;
45014501export type NewRecurringPattern = typeof recurringPatterns.$inferInsert;
4502// ---------------------------------------------------------------------------
4503// Bus Factor Cache — migration 0088
4504// Stores per-repo knowledge concentration analysis (at-risk files where one
4505// author owns >75% of commits). Refreshed on demand; 7-day soft TTL.
4506// ---------------------------------------------------------------------------
4507export const busFactorCache = pgTable("bus_factor_cache", {
4508 id: uuid("id").primaryKey().defaultRandom(),
4509 repositoryId: uuid("repository_id")
4510 .notNull()
4511 .references(() => repositories.id, { onDelete: "cascade" })
4512 .unique(),
4513 analyzedAt: timestamp("analyzed_at", { withTimezone: true })
4514 .notNull()
4515 .defaultNow(),
4516 atRiskFiles: jsonb("at_risk_files").notNull().default([]),
4517 totalFilesAnalyzed: integer("total_files_analyzed").notNull().default(0),
4518});
Addedsrc/lib/bus-factor.ts+306−0View fileUnifiedSplit
1/**
2 * Bus Factor Analysis — detect files that only one person understands.
3 *
4 * Uses git log parsing to build a commit-author map per file, then flags
5 * files where one author has >75% of commits and total commits >= 3.
6 *
7 * Risk levels:
8 * critical — >90% single author, >=5 commits, modified in last 30 days
9 * high — >80% single author, >=4 commits
10 * medium — >75% single author, >=3 commits
11 *
12 * Results are cached in the `bus_factor_cache` table (7-day TTL).
13 * No AI calls — pure git log parsing.
14 */
15
16import { join } from "path";
17import { eq } from "drizzle-orm";
18import { db } from "../db";
19import { busFactorCache } from "../db/schema";
20import { config } from "./config";
21
22// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26export interface BusFactorFile {
27 path: string;
28 primaryAuthor: string; // username / display name of dominant author
29 primaryAuthorPct: number; // e.g. 87 (integer percent)
30 totalCommits: number;
31 lastModified: string; // ISO date string
32 risk: "critical" | "high" | "medium";
33}
34
35export interface BusFactorReport {
36 repoId: string;
37 analyzedAt: string;
38 atRiskFiles: BusFactorFile[]; // files with bus factor = 1
39 totalFilesAnalyzed: number;
40}
41
42// ---------------------------------------------------------------------------
43// Internal helpers
44// ---------------------------------------------------------------------------
45
46const CODE_EXTENSIONS = new Set([
47 ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
48 ".py", ".go", ".rs", ".java", ".rb", ".php",
49 ".c", ".cpp", ".cc", ".h", ".hpp",
50 ".cs", ".swift", ".kt", ".scala",
51 ".vue", ".svelte",
52 ".sh", ".bash", ".zsh",
53 ".sql",
54]);
55
56const SKIP_DIRS = ["node_modules", "dist", ".next", "build", "vendor", ".git", "coverage"];
57
58function isCodeFile(filePath: string): boolean {
59 // Skip generated / dependency directories
60 const parts = filePath.split("/");
61 if (parts.some((p) => SKIP_DIRS.includes(p))) return false;
62
63 const dotIdx = filePath.lastIndexOf(".");
64 if (dotIdx === -1) return false;
65 const ext = filePath.slice(dotIdx).toLowerCase();
66 return CODE_EXTENSIONS.has(ext);
67}
68
69function getRepoDir(owner: string, repo: string): string {
70 return join(config.gitReposPath, `${owner}/${repo}.git`);
71}
72
73async function spawnGit(args: string[], cwd: string): Promise<string> {
74 const proc = Bun.spawn(["git", "--git-dir", cwd, ...args], {
75 stdout: "pipe",
76 stderr: "pipe",
77 });
78 const out = await new Response(proc.stdout).text();
79 await proc.exited;
80 return out;
81}
82
83// ---------------------------------------------------------------------------
84// Core analysis
85// ---------------------------------------------------------------------------
86
87/**
88 * Parse `git log --name-only --format="%ae %an"` output into a map:
89 * Map<filePath, Map<authorIdentifier, commitCount>>
90 *
91 * Also returns a map of file → last modified date.
92 */
93function parseGitLog(raw: string): {
94 fileAuthorMap: Map<string, Map<string, number>>;
95 fileLastModified: Map<string, string>;
96} {
97 const fileAuthorMap = new Map<string, Map<string, number>>();
98 const fileLastModified = new Map<string, string>();
99
100 const lines = raw.split("\n");
101 let currentAuthor: string | null = null;
102 let currentDate: string | null = null;
103 let inFileList = false;
104
105 for (const line of lines) {
106 const trimmed = line.trim();
107 if (!trimmed) {
108 // blank line separator between commits
109 inFileList = false;
110 currentDate = null;
111 continue;
112 }
113
114 // Header line — format: "<email> <name> <date>"
115 // We use "%ae %an %ad" with --date=short
116 const headerMatch = trimmed.match(/^(\S+)\s+(.+?)\s+(\d{4}-\d{2}-\d{2})$/);
117 if (headerMatch) {
118 currentAuthor = headerMatch[2].trim(); // prefer display name
119 currentDate = headerMatch[3];
120 inFileList = true;
121 continue;
122 }
123
124 if (inFileList && currentAuthor) {
125 // This line is a file path
126 const filePath = trimmed;
127 if (!filePath || filePath.startsWith("diff") || filePath.startsWith("---")) continue;
128
129 if (!fileAuthorMap.has(filePath)) {
130 fileAuthorMap.set(filePath, new Map());
131 }
132 const authorMap = fileAuthorMap.get(filePath)!;
133 authorMap.set(currentAuthor, (authorMap.get(currentAuthor) ?? 0) + 1);
134
135 // Track most recent modification (git log is newest-first)
136 if (!fileLastModified.has(filePath) && currentDate) {
137 fileLastModified.set(filePath, currentDate);
138 }
139 }
140 }
141
142 return { fileAuthorMap, fileLastModified };
143}
144
145function computeRisk(
146 primaryPct: number,
147 totalCommits: number,
148 lastModified: string
149): "critical" | "high" | "medium" | null {
150 if (primaryPct <= 75 || totalCommits < 3) return null;
151
152 const daysSinceModified =
153 (Date.now() - new Date(lastModified).getTime()) / (1000 * 60 * 60 * 24);
154
155 if (primaryPct > 90 && totalCommits >= 5 && daysSinceModified <= 30) {
156 return "critical";
157 }
158 if (primaryPct > 80 && totalCommits >= 4) {
159 return "high";
160 }
161 return "medium";
162}
163
164// ---------------------------------------------------------------------------
165// Public API
166// ---------------------------------------------------------------------------
167
168export async function analyzeBusFactor(
169 repoId: string,
170 owner: string,
171 repo: string
172): Promise<BusFactorReport> {
173 const repoDir = getRepoDir(owner, repo);
174 const analyzedAt = new Date().toISOString();
175
176 // Fetch git log with file names in one pass — format: "email name date\nfile1\nfile2\n\n"
177 const raw = await spawnGit(
178 [
179 "log",
180 "--name-only",
181 "--format=%ae %an %ad",
182 "--date=short",
183 "--diff-filter=ACMR",
184 "-n",
185 "5000",
186 ],
187 repoDir
188 );
189
190 const { fileAuthorMap, fileLastModified } = parseGitLog(raw);
191
192 const atRiskFiles: BusFactorFile[] = [];
193 let totalFilesAnalyzed = 0;
194
195 for (const [filePath, authorMap] of fileAuthorMap) {
196 if (!isCodeFile(filePath)) continue;
197 totalFilesAnalyzed++;
198
199 const totalCommits = Array.from(authorMap.values()).reduce((a, b) => a + b, 0);
200 if (totalCommits < 3) continue;
201
202 // Find dominant author
203 let primaryAuthor = "";
204 let primaryCount = 0;
205 for (const [author, count] of authorMap) {
206 if (count > primaryCount) {
207 primaryCount = count;
208 primaryAuthor = author;
209 }
210 }
211
212 const primaryAuthorPct = Math.round((primaryCount / totalCommits) * 100);
213 const lastModified = fileLastModified.get(filePath) ?? new Date().toISOString().slice(0, 10);
214 const risk = computeRisk(primaryAuthorPct, totalCommits, lastModified);
215
216 if (risk) {
217 atRiskFiles.push({
218 path: filePath,
219 primaryAuthor,
220 primaryAuthorPct,
221 totalCommits,
222 lastModified,
223 risk,
224 });
225 }
226
227 if (atRiskFiles.length >= 50) break;
228 }
229
230 // Sort: critical first, then high, then medium
231 const riskOrder = { critical: 0, high: 1, medium: 2 };
232 atRiskFiles.sort((a, b) => riskOrder[a.risk] - riskOrder[b.risk]);
233
234 const report: BusFactorReport = {
235 repoId,
236 analyzedAt,
237 atRiskFiles,
238 totalFilesAnalyzed,
239 };
240
241 // Upsert into cache
242 try {
243 await db
244 .insert(busFactorCache)
245 .values({
246 repositoryId: repoId,
247 analyzedAt: new Date(analyzedAt),
248 atRiskFiles: atRiskFiles as unknown as object,
249 totalFilesAnalyzed,
250 })
251 .onConflictDoUpdate({
252 target: busFactorCache.repositoryId,
253 set: {
254 analyzedAt: new Date(analyzedAt),
255 atRiskFiles: atRiskFiles as unknown as object,
256 totalFilesAnalyzed,
257 },
258 });
259 } catch {
260 // Cache write failure is non-blocking
261 }
262
263 return report;
264}
265
266/**
267 * Return cached at-risk files that overlap with `changedFiles`.
268 * If the cache is older than 7 days, trigger a background re-analysis.
269 */
270export async function getBusFactorWarning(
271 repoId: string,
272 owner: string,
273 repo: string,
274 changedFiles: string[]
275): Promise<BusFactorFile[]> {
276 if (changedFiles.length === 0) return [];
277
278 try {
279 const rows = await db
280 .select()
281 .from(busFactorCache)
282 .where(eq(busFactorCache.repositoryId, repoId))
283 .limit(1);
284
285 if (rows.length === 0) {
286 // No cache yet — trigger background analysis and return empty
287 analyzeBusFactor(repoId, owner, repo).catch(() => {});
288 return [];
289 }
290
291 const cached = rows[0];
292 const ageMs = Date.now() - cached.analyzedAt.getTime();
293 const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
294
295 if (ageMs > sevenDaysMs) {
296 // Stale — refresh in background
297 analyzeBusFactor(repoId, owner, repo).catch(() => {});
298 }
299
300 const atRiskFiles = cached.atRiskFiles as BusFactorFile[];
301 const changedSet = new Set(changedFiles);
302 return atRiskFiles.filter((f) => changedSet.has(f.path));
303 } catch {
304 return [];
305 }
306}
Addedsrc/lib/pr-splitter.ts+214−0View fileUnifiedSplit
1/**
2 * PR Split Suggestions — AI-powered guidance for decomposing large PRs.
3 *
4 * When a PR has >400 lines changed, Claude Sonnet is asked to suggest how
5 * to split it into 2-4 smaller, independently-mergeable PRs grouped by
6 * logical concern (schema / API / UI / tests etc.).
7 *
8 * Results are cached in memory for 1 hour per PR — the AI call is expensive
9 * and the diff doesn't change between page loads.
10 *
11 * Returns `null` when:
12 * - PR has <=400 changed lines
13 * - AI is unavailable (no ANTHROPIC_API_KEY)
14 * - Claude returns fewer than 2 suggestions
15 * - Any error occurs (always degrades gracefully)
16 */
17
18import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
19import { join } from "path";
20import { config } from "./config";
21
22// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26export interface SplitPr {
27 title: string;
28 rationale: string;
29 files: string[];
30 estimatedLines: number;
31 suggestedBranch: string;
32}
33
34export interface SplitSuggestion {
35 originalPrTitle: string;
36 totalFiles: number;
37 totalLines: number;
38 suggestedPrs: SplitPr[];
39 mergeOrder: string[];
40}
41
42// ---------------------------------------------------------------------------
43// In-memory cache (1h TTL per prId)
44// ---------------------------------------------------------------------------
45
46interface CacheEntry {
47 suggestion: SplitSuggestion | null;
48 cachedAt: number;
49}
50
51const _cache = new Map<string, CacheEntry>();
52const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
53
54function getCached(prId: string): SplitSuggestion | null | undefined {
55 const entry = _cache.get(prId);
56 if (!entry) return undefined; // cache miss
57 if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
58 _cache.delete(prId);
59 return undefined;
60 }
61 return entry.suggestion;
62}
63
64function setCached(prId: string, suggestion: SplitSuggestion | null): void {
65 _cache.set(prId, { suggestion, cachedAt: Date.now() });
66}
67
68// ---------------------------------------------------------------------------
69// Diff stat parsing
70// ---------------------------------------------------------------------------
71
72interface FileStatLine {
73 path: string;
74 added: number;
75 deleted: number;
76 total: number;
77}
78
79function parseNumstat(raw: string): FileStatLine[] {
80 return raw
81 .trim()
82 .split("\n")
83 .filter(Boolean)
84 .map((line) => {
85 const parts = line.split("\t");
86 if (parts.length < 3) return null;
87 const added = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
88 const deleted = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
89 return { path: parts[2], added, deleted, total: added + deleted };
90 })
91 .filter((x): x is FileStatLine => x !== null);
92}
93
94function getRepoDir(owner: string, repo: string): string {
95 return join(config.gitReposPath, `${owner}/${repo}.git`);
96}
97
98async function getDiffStat(
99 owner: string,
100 repo: string,
101 baseBranch: string,
102 headBranch: string
103): Promise<FileStatLine[]> {
104 const repoDir = getRepoDir(owner, repo);
105 const proc = Bun.spawn(
106 ["git", "--git-dir", repoDir, "diff", "--numstat", `${baseBranch}...${headBranch}`],
107 { stdout: "pipe", stderr: "pipe" }
108 );
109 const raw = await new Response(proc.stdout).text();
110 await proc.exited;
111 return parseNumstat(raw);
112}
113
114// ---------------------------------------------------------------------------
115// Public API
116// ---------------------------------------------------------------------------
117
118/**
119 * Suggest how to split a large PR.
120 * Returns null when the PR is small, AI is unavailable, or any error occurs.
121 */
122export async function suggestPrSplit(
123 prId: string,
124 prTitle: string,
125 ownerName: string,
126 repoName: string,
127 baseBranch: string,
128 headBranch: string
129): Promise<SplitSuggestion | null> {
130 // Check cache first
131 const cached = getCached(prId);
132 if (cached !== undefined) return cached;
133
134 try {
135 const fileStats = await getDiffStat(ownerName, repoName, baseBranch, headBranch);
136 const totalLines = fileStats.reduce((s, f) => s + f.total, 0);
137 const totalFiles = fileStats.length;
138
139 if (totalLines < 400) {
140 setCached(prId, null);
141 return null;
142 }
143
144 if (!isAiAvailable()) {
145 setCached(prId, null);
146 return null;
147 }
148
149 const fileList = fileStats
150 .map((f) => `${f.path} +${f.added} -${f.deleted}`)
151 .join("\n");
152
153 const prompt = `This PR is too large to review effectively (${totalLines} lines across ${totalFiles} files).
154Suggest how to split it into 2-4 smaller PRs that can be reviewed and merged independently.
155
156PR title: ${prTitle}
157Files changed:
158${fileList}
159
160Return JSON with this exact shape (no extra keys, no prose outside the JSON block):
161{
162 "suggestedPrs": [
163 {
164 "title": "...",
165 "rationale": "...",
166 "files": ["..."],
167 "estimatedLines": N,
168 "suggestedBranch": "..."
169 }
170 ],
171 "mergeOrder": ["PR title 1", "PR title 2"]
172}
173
174Rules:
175- Group by logical concern (schema changes together, API layer together, UI together)
176- Each suggested PR should be independently mergeable
177- Suggest merge order to minimise conflicts
178- suggestedBranch should be kebab-case derived from the PR title (e.g. feat/auth-schema-only)
179- Return between 2 and 4 suggested PRs`;
180
181 const anthropic = getAnthropic();
182 const message = await anthropic.messages.create({
183 model: MODEL_SONNET,
184 max_tokens: 1024,
185 messages: [{ role: "user", content: prompt }],
186 });
187
188 const text = extractText(message);
189 const parsed = parseJsonResponse<{
190 suggestedPrs: SplitPr[];
191 mergeOrder: string[];
192 }>(text);
193
194 if (!parsed || !Array.isArray(parsed.suggestedPrs) || parsed.suggestedPrs.length < 2) {
195 setCached(prId, null);
196 return null;
197 }
198
199 const suggestion: SplitSuggestion = {
200 originalPrTitle: prTitle,
201 totalFiles,
202 totalLines,
203 suggestedPrs: parsed.suggestedPrs,
204 mergeOrder: Array.isArray(parsed.mergeOrder) ? parsed.mergeOrder : [],
205 };
206
207 setCached(prId, suggestion);
208 return suggestion;
209 } catch {
210 // Always degrade gracefully
211 setCached(prId, null);
212 return null;
213 }
214}
Addedsrc/routes/bus-factor.tsx+488−0View fileUnifiedSplit
1/**
2 * Bus Factor Report — /:owner/:repo/insights/bus-factor
3 *
4 * Lists files where knowledge is concentrated in a single author.
5 * Includes a pure-CSS bar chart per file and a "Re-analyze" button
6 * (owner-only) that triggers a fresh background analysis.
7 *
8 * GET /:owner/:repo/insights/bus-factor — report page
9 * POST /:owner/:repo/insights/bus-factor/reanalyze — trigger fresh analysis
10 */
11
12import { Hono } from "hono";
13import { db } from "../db";
14import { repositories, users, busFactorCache } from "../db/schema";
15import { and, eq } from "drizzle-orm";
16import type { AuthEnv } from "../middleware/auth";
17import { softAuth, requireAuth } from "../middleware/auth";
18import { requireRepoAccess } from "../middleware/repo-access";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { getUnreadCount } from "../lib/unread";
22import {
23 analyzeBusFactor,
24 type BusFactorFile,
25 type BusFactorReport,
26} from "../lib/bus-factor";
27
28const busFactorRoutes = new Hono<AuthEnv>();
29
30// ─── CSS ──────────────────────────────────────────────────────────────────────
31
32const styles = `
33 .bf-wrap {
34 max-width: 1080px;
35 margin: 0 auto;
36 padding: var(--space-5) var(--space-4);
37 }
38
39 /* Insights sub-navigation */
40 .bf-subnav {
41 display: flex;
42 gap: 4px;
43 margin-bottom: var(--space-5);
44 border-bottom: 1px solid var(--border);
45 padding-bottom: 0;
46 }
47 .bf-subnav-link {
48 padding: 8px 14px;
49 font-size: 13px;
50 font-weight: 500;
51 color: var(--text-muted);
52 text-decoration: none;
53 border-bottom: 2px solid transparent;
54 margin-bottom: -1px;
55 transition: color 120ms ease, border-color 120ms ease;
56 border-radius: 4px 4px 0 0;
57 }
58 .bf-subnav-link:hover { color: var(--text); }
59 .bf-subnav-link.active {
60 color: var(--accent, #5865f2);
61 border-bottom-color: var(--accent, #5865f2);
62 }
63
64 /* Hero */
65 .bf-hero {
66 position: relative;
67 margin-bottom: var(--space-5);
68 padding: var(--space-5) var(--space-6);
69 background: var(--bg-elevated);
70 border: 1px solid var(--border);
71 border-radius: 16px;
72 overflow: hidden;
73 }
74 .bf-hero::before {
75 content: '';
76 position: absolute;
77 top: 0; left: 0; right: 0;
78 height: 2px;
79 background: linear-gradient(90deg, transparent 0%, #f59e0b 30%, #ef4444 70%, transparent 100%);
80 opacity: 0.75;
81 pointer-events: none;
82 }
83 .bf-hero-orb {
84 position: absolute;
85 inset: -30% -15% auto auto;
86 width: 460px; height: 460px;
87 background: radial-gradient(circle, rgba(245,158,11,0.16), rgba(239,68,68,0.08) 45%, transparent 70%);
88 filter: blur(80px);
89 opacity: 0.75;
90 pointer-events: none;
91 z-index: 0;
92 }
93 .bf-hero-inner { position: relative; z-index: 1; max-width: 760px; }
94 .bf-hero-eyebrow {
95 display: inline-flex; align-items: center; gap: 6px;
96 font-size: 11px; font-weight: 700; letter-spacing: 0.07em;
97 text-transform: uppercase;
98 color: #f59e0b;
99 margin-bottom: 10px;
100 }
101 .bf-hero-title {
102 font-family: var(--font-display);
103 font-size: clamp(22px, 3vw, 30px);
104 font-weight: 800;
105 letter-spacing: -0.025em;
106 line-height: 1.1;
107 margin: 0 0 8px;
108 color: var(--text-strong);
109 }
110 .bf-hero-sub {
111 font-size: 14px;
112 color: var(--text-muted);
113 margin: 0;
114 line-height: 1.5;
115 }
116 .bf-hero-actions {
117 display: flex;
118 gap: 10px;
119 align-items: center;
120 margin-top: 16px;
121 flex-wrap: wrap;
122 }
123
124 /* Stats bar */
125 .bf-stats {
126 display: flex;
127 gap: 16px;
128 margin-bottom: var(--space-5);
129 flex-wrap: wrap;
130 }
131 .bf-stat-card {
132 flex: 1 1 160px;
133 padding: 14px 18px;
134 background: var(--bg-elevated);
135 border: 1px solid var(--border);
136 border-radius: 12px;
137 min-width: 120px;
138 }
139 .bf-stat-value {
140 font-family: var(--font-display);
141 font-size: 26px;
142 font-weight: 800;
143 line-height: 1;
144 margin-bottom: 4px;
145 color: var(--text-strong);
146 font-variant-numeric: tabular-nums;
147 }
148 .bf-stat-label {
149 font-size: 12px;
150 color: var(--text-muted);
151 font-weight: 500;
152 }
153 .bf-stat-card.is-critical .bf-stat-value { color: #ef4444; }
154 .bf-stat-card.is-high .bf-stat-value { color: #f97316; }
155 .bf-stat-card.is-medium .bf-stat-value { color: #f59e0b; }
156
157 /* File list */
158 .bf-list {
159 display: flex;
160 flex-direction: column;
161 gap: 10px;
162 }
163 .bf-file-card {
164 padding: 14px 18px;
165 background: var(--bg-elevated);
166 border: 1px solid var(--border);
167 border-radius: 12px;
168 transition: border-color 120ms ease;
169 }
170 .bf-file-card:hover { border-color: rgba(245,158,11,0.4); }
171 .bf-file-card.risk-critical { border-left: 3px solid #ef4444; }
172 .bf-file-card.risk-high { border-left: 3px solid #f97316; }
173 .bf-file-card.risk-medium { border-left: 3px solid #f59e0b; }
174
175 .bf-file-header {
176 display: flex;
177 align-items: center;
178 gap: 10px;
179 margin-bottom: 10px;
180 flex-wrap: wrap;
181 }
182 .bf-file-path {
183 flex: 1 1 auto;
184 font-family: var(--font-mono);
185 font-size: 13px;
186 font-weight: 600;
187 color: var(--text-strong);
188 word-break: break-all;
189 }
190 .bf-risk-badge {
191 display: inline-flex;
192 align-items: center;
193 padding: 2px 9px;
194 border-radius: 9999px;
195 font-size: 10.5px;
196 font-weight: 700;
197 letter-spacing: 0.04em;
198 text-transform: uppercase;
199 flex-shrink: 0;
200 }
201 .bf-risk-badge.is-critical { color: #ef4444; background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); }
202 .bf-risk-badge.is-high { color: #f97316; background: rgba(249,115,22,0.12); border: 1px solid rgba(249,115,22,0.3); }
203 .bf-risk-badge.is-medium { color: #f59e0b; background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.3); }
204
205 .bf-file-meta {
206 display: flex;
207 gap: 16px;
208 font-size: 12px;
209 color: var(--text-muted);
210 margin-bottom: 10px;
211 flex-wrap: wrap;
212 }
213
214 /* CSS bar chart — no JS */
215 .bf-bar-wrap {
216 display: flex;
217 align-items: center;
218 gap: 10px;
219 }
220 .bf-bar-label {
221 font-size: 12px;
222 color: var(--text-muted);
223 white-space: nowrap;
224 min-width: 140px;
225 overflow: hidden;
226 text-overflow: ellipsis;
227 }
228 .bf-bar-track {
229 flex: 1;
230 height: 10px;
231 background: var(--bg-tertiary, rgba(255,255,255,0.06));
232 border-radius: 99px;
233 overflow: hidden;
234 }
235 .bf-bar-fill {
236 height: 100%;
237 border-radius: 99px;
238 background: linear-gradient(90deg, #f59e0b, #ef4444);
239 transition: width 300ms ease;
240 }
241 .bf-bar-pct {
242 font-size: 12px;
243 font-weight: 600;
244 color: var(--text-muted);
245 min-width: 40px;
246 text-align: right;
247 font-variant-numeric: tabular-nums;
248 }
249
250 /* Empty state */
251 .bf-empty {
252 text-align: center;
253 padding: 64px 24px;
254 color: var(--text-muted);
255 }
256 .bf-empty-icon { font-size: 40px; margin-bottom: 12px; }
257 .bf-empty-title { font-size: 18px; font-weight: 700; color: var(--text-strong); margin-bottom: 6px; }
258 .bf-empty-sub { font-size: 14px; }
259
260 /* Action button */
261 .bf-reanalyze-btn {
262 display: inline-flex; align-items: center; gap: 6px;
263 padding: 8px 16px;
264 border-radius: 8px;
265 font-size: 13px; font-weight: 600;
266 color: #fff;
267 background: linear-gradient(135deg, #f59e0b 0%, #ef4444 130%);
268 border: none;
269 cursor: pointer;
270 transition: opacity 120ms ease;
271 }
272 .bf-reanalyze-btn:hover { opacity: 0.85; }
273
274 .bf-analyzed-at {
275 font-size: 12px;
276 color: var(--text-muted);
277 margin-top: 16px;
278 }
279`;
280
281// ─── Route: GET /:owner/:repo/insights/bus-factor ─────────────────────────────
282
283busFactorRoutes.use("/:owner/:repo/insights/bus-factor", softAuth);
284
285busFactorRoutes.get("/:owner/:repo/insights/bus-factor", requireRepoAccess("read"), async (c) => {
286 const user = c.get("user") ?? null;
287 const params = c.req.param() as { owner: string; repo: string };
288 const ownerName = params.owner;
289 const repoName = params.repo;
290
291 // Load repo
292 const repoRows = await db
293 .select({ id: repositories.id, isPrivate: repositories.isPrivate, ownerId: repositories.ownerId })
294 .from(repositories)
295 .innerJoin(users, eq(repositories.ownerId, users.id))
296 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
297 .limit(1);
298
299 if (!repoRows.length) return c.notFound();
300 const repo = repoRows[0];
301 const isOwner = !!user && user.id === repo.ownerId;
302
303 const unreadCount = user ? await getUnreadCount(user.id) : 0;
304
305 // Load cached report
306 let report: BusFactorReport | null = null;
307 const cacheRows = await db
308 .select()
309 .from(busFactorCache)
310 .where(eq(busFactorCache.repositoryId, repo.id))
311 .limit(1);
312
313 if (cacheRows.length > 0) {
314 const cached = cacheRows[0];
315 report = {
316 repoId: repo.id,
317 analyzedAt: cached.analyzedAt.toISOString(),
318 atRiskFiles: cached.atRiskFiles as BusFactorFile[],
319 totalFilesAnalyzed: cached.totalFilesAnalyzed,
320 };
321 }
322
323 const criticalCount = report?.atRiskFiles.filter((f) => f.risk === "critical").length ?? 0;
324 const highCount = report?.atRiskFiles.filter((f) => f.risk === "high").length ?? 0;
325 const mediumCount = report?.atRiskFiles.filter((f) => f.risk === "medium").length ?? 0;
326
327 return c.html(
328 <Layout
329 title={`Bus Factor — ${ownerName}/${repoName}`}
330 user={user}
331 notificationCount={unreadCount}
332 >
333 <style dangerouslySetInnerHTML={{ __html: styles }} />
334 <div class="bf-wrap">
335 <RepoHeader owner={ownerName} repo={repoName} />
336 <RepoNav owner={ownerName} repo={repoName} active="insights" />
337
338 {/* Sub-navigation */}
339 <nav class="bf-subnav">
340 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights`}>Overview</a>
341 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/health`}>Health</a>
342 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/velocity`}>Velocity</a>
343 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/hotfiles`}>Hot Files</a>
344 <a class="bf-subnav-link active" href={`/${ownerName}/${repoName}/insights/bus-factor`}>Bus Factor</a>
345 </nav>
346
347 {/* Hero */}
348 <div class="bf-hero">
349 <div class="bf-hero-orb" aria-hidden="true" />
350 <div class="bf-hero-inner">
351 <div class="bf-hero-eyebrow">⚠ Knowledge Risk</div>
352 <h1 class="bf-hero-title">Bus Factor Analysis</h1>
353 <p class="bf-hero-sub">
354 Files where a single author owns more than 75% of commits.
355 If that person leaves, the team loses critical context.
356 </p>
357 <div class="bf-hero-actions">
358 {isOwner && (
359 <form method="post" action={`/${ownerName}/${repoName}/insights/bus-factor/reanalyze`}>
360 <button type="submit" class="bf-reanalyze-btn">
361 ↻ Re-analyze
362 </button>
363 </form>
364 )}
365 {report && (
366 <span class="bf-analyzed-at">
367 Last analyzed {new Date(report.analyzedAt).toLocaleDateString()} ·{" "}
368 {report.totalFilesAnalyzed} code files scanned
369 </span>
370 )}
371 </div>
372 </div>
373 </div>
374
375 {report ? (
376 <>
377 {/* Stats */}
378 <div class="bf-stats">
379 <div class="bf-stat-card is-critical">
380 <div class="bf-stat-value">{criticalCount}</div>
381 <div class="bf-stat-label">Critical risk files</div>
382 </div>
383 <div class="bf-stat-card is-high">
384 <div class="bf-stat-value">{highCount}</div>
385 <div class="bf-stat-label">High risk files</div>
386 </div>
387 <div class="bf-stat-card is-medium">
388 <div class="bf-stat-value">{mediumCount}</div>
389 <div class="bf-stat-label">Medium risk files</div>
390 </div>
391 <div class="bf-stat-card">
392 <div class="bf-stat-value">{report.atRiskFiles.length}</div>
393 <div class="bf-stat-label">Total at-risk files</div>
394 </div>
395 </div>
396
397 {/* File list */}
398 {report.atRiskFiles.length === 0 ? (
399 <div class="bf-empty">
400 <div class="bf-empty-icon"></div>
401 <div class="bf-empty-title">No knowledge concentration detected</div>
402 <p class="bf-empty-sub">
403 All analyzed files have healthy authorship spread.
404 </p>
405 </div>
406 ) : (
407 <div class="bf-list">
408 {report.atRiskFiles.map((file) => (
409 <div class={`bf-file-card risk-${file.risk}`}>
410 <div class="bf-file-header">
411 <code class="bf-file-path">{file.path}</code>
412 <span class={`bf-risk-badge is-${file.risk}`}>
413 {file.risk}
414 </span>
415 </div>
416 <div class="bf-file-meta">
417 <span>{file.totalCommits} commits</span>
418 <span>Last modified {file.lastModified}</span>
419 </div>
420 <div class="bf-bar-wrap">
421 <span class="bf-bar-label" title={file.primaryAuthor}>
422 {file.primaryAuthor}
423 </span>
424 <div class="bf-bar-track">
425 <div
426 class="bf-bar-fill"
427 style={`width:${file.primaryAuthorPct}%`}
428 />
429 </div>
430 <span class="bf-bar-pct">{file.primaryAuthorPct}%</span>
431 </div>
432 </div>
433 ))}
434 </div>
435 )}
436 </>
437 ) : (
438 <div class="bf-empty">
439 <div class="bf-empty-icon">📊</div>
440 <div class="bf-empty-title">No analysis yet</div>
441 <p class="bf-empty-sub">
442 {isOwner
443 ? "Click Re-analyze to run the bus factor scan on this repository."
444 : "The repository owner hasn't run a bus factor scan yet."}
445 </p>
446 </div>
447 )}
448 </div>
449 </Layout>
450 );
451});
452
453// ─── Route: POST /:owner/:repo/insights/bus-factor/reanalyze ─────────────────
454
455busFactorRoutes.use("/:owner/:repo/insights/bus-factor/reanalyze", requireAuth);
456
457busFactorRoutes.post(
458 "/:owner/:repo/insights/bus-factor/reanalyze",
459 requireRepoAccess("write"),
460 async (c) => {
461 const user = c.get("user")!;
462 const params = c.req.param() as { owner: string; repo: string };
463 const ownerName = params.owner;
464 const repoName = params.repo;
465
466 // Only repo owner may trigger re-analysis
467 const repoRows = await db
468 .select({ id: repositories.id, ownerId: repositories.ownerId })
469 .from(repositories)
470 .innerJoin(users, eq(repositories.ownerId, users.id))
471 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
472 .limit(1);
473
474 if (!repoRows.length) return c.notFound();
475 const repo = repoRows[0];
476
477 if (user.id !== repo.ownerId) {
478 return c.text("Forbidden", 403);
479 }
480
481 // Fire-and-forget background analysis
482 analyzeBusFactor(repo.id, ownerName, repoName).catch(() => {});
483
484 return c.redirect(`/${ownerName}/${repoName}/insights/bus-factor`);
485 }
486);
487
488export default busFactorRoutes;
Modifiedsrc/routes/pulls.tsx+212−8View fileUnifiedSplit
143143import { upgradeWebSocket, websocket as presenceWebsocket } from "hono/bun";
144144
145145export { presenceWebsocket };
146import { getBusFactorWarning, type BusFactorFile } from "../lib/bus-factor";
147import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
146148
147149const pulls = new Hono<AuthEnv>();
148150
14891491 .trio-pills-wrap {
14901492 display: inline-flex; align-items: center; gap: 4px;
14911493 }
1494
1495 /* ─── Bus Factor Warning Panel ─── */
1496 .busfactor-panel {
1497 display: flex;
1498 gap: 14px;
1499 align-items: flex-start;
1500 padding: 14px 18px;
1501 margin-bottom: 16px;
1502 border-radius: 12px;
1503 border: 1px solid rgba(245,158,11,0.35);
1504 background: rgba(245,158,11,0.06);
1505 }
1506 .busfactor-critical {
1507 border-color: rgba(239,68,68,0.4);
1508 background: rgba(239,68,68,0.06);
1509 }
1510 .busfactor-high {
1511 border-color: rgba(249,115,22,0.4);
1512 background: rgba(249,115,22,0.06);
1513 }
1514 .busfactor-medium {
1515 border-color: rgba(245,158,11,0.35);
1516 background: rgba(245,158,11,0.06);
1517 }
1518 .busfactor-icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
1519 .busfactor-body { flex: 1; min-width: 0; }
1520 .busfactor-body strong { font-size: 14px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1521 .busfactor-body p { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
1522 .busfactor-body ul { margin: 0; padding-left: 18px; }
1523 .busfactor-body li { font-size: 12.5px; color: var(--text-muted); margin-bottom: 3px; font-family: var(--font-mono); }
1524 .busfactor-body li strong { font-size: 12.5px; color: var(--text); display: inline; }
1525
1526 /* ─── PR Split Suggestion Panel ─── */
1527 .split-suggestion {
1528 margin-bottom: 16px;
1529 border: 1px solid rgba(140,109,255,0.35);
1530 border-radius: 12px;
1531 overflow: hidden;
1532 }
1533 .split-header {
1534 display: flex;
1535 align-items: center;
1536 gap: 10px;
1537 padding: 12px 18px;
1538 background: rgba(140,109,255,0.06);
1539 flex-wrap: wrap;
1540 }
1541 .split-icon { font-size: 18px; flex-shrink: 0; }
1542 .split-header strong { font-size: 14px; font-weight: 700; color: var(--text-strong); flex: 1; min-width: 200px; }
1543 .split-stat { font-size: 12px; color: var(--text-muted); background: var(--bg-elevated); padding: 2px 9px; border-radius: 9999px; border: 1px solid var(--border); white-space: nowrap; }
1544 .split-toggle {
1545 background: none;
1546 border: 1px solid rgba(140,109,255,0.45);
1547 color: rgba(140,109,255,0.9);
1548 font-size: 12.5px;
1549 font-weight: 600;
1550 padding: 4px 12px;
1551 border-radius: 8px;
1552 cursor: pointer;
1553 white-space: nowrap;
1554 transition: background 120ms ease;
1555 }
1556 .split-toggle:hover { background: rgba(140,109,255,0.1); }
1557 .split-body {
1558 padding: 16px 18px;
1559 border-top: 1px solid rgba(140,109,255,0.2);
1560 }
1561 .split-intro { font-size: 13.5px; color: var(--text-muted); margin: 0 0 14px; }
1562 .split-pr {
1563 display: flex;
1564 gap: 14px;
1565 align-items: flex-start;
1566 padding: 12px 0;
1567 border-bottom: 1px solid var(--border);
1568 }
1569 .split-pr:last-of-type { border-bottom: none; }
1570 .split-pr-num {
1571 width: 26px; height: 26px;
1572 border-radius: 50%;
1573 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
1574 color: #fff;
1575 font-size: 12px;
1576 font-weight: 800;
1577 display: inline-flex;
1578 align-items: center;
1579 justify-content: center;
1580 flex-shrink: 0;
1581 margin-top: 2px;
1582 }
1583 .split-pr-body { flex: 1; min-width: 0; }
1584 .split-pr-body strong { font-size: 13.5px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1585 .split-pr-body p { font-size: 12.5px; color: var(--text-muted); margin: 0 0 6px; }
1586 .split-pr-body code { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; }
1587 .split-lines { display: inline-block; margin-left: 10px; font-size: 11.5px; color: var(--text-muted); background: var(--bg-tertiary); padding: 1px 7px; border-radius: 9999px; }
1588 .split-order { font-size: 13px; color: var(--text-muted); margin: 14px 0 0; }
1589 .split-order strong { color: var(--text); }
14921590`;
14931591
14941592/* ──────────────────────────────────────────────────────────────────────
38873985 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
38883986 } catch { /* swallow — purely cosmetic */ }
38893987
3988 // Bus factor warning — non-blocking. Get changed files list first.
3989 let busRiskFiles: BusFactorFile[] = [];
3990 let splitSuggestion: SplitSuggestion | null = null;
3991 try {
3992 // Get names of files changed in this PR
3993 const repoDir = getRepoPath(ownerName, repoName);
3994 const nameOnlyProc = Bun.spawn(
3995 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
3996 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3997 );
3998 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
3999 await nameOnlyProc.exited;
4000 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4001
4002 // Bus factor — check cache for at-risk files that overlap changed files
4003 [busRiskFiles] = await Promise.all([
4004 getBusFactorWarning(resolved.repo.id, ownerName, repoName, prChangedFiles),
4005 ]);
4006
4007 // PR Split suggestion — only when PR is large (>400 lines)
4008 if (prSizeInfo && prSizeInfo.linesChanged > 400) {
4009 splitSuggestion = await suggestPrSplit(
4010 pr.id,
4011 pr.title,
4012 ownerName,
4013 repoName,
4014 pr.baseBranch,
4015 pr.headBranch
4016 );
4017 }
4018 } catch { /* always degrade gracefully */ }
4019
38904020 // Get diff for "Files changed" tab + load inline comments for that tab
38914021 let diffRaw = "";
38924022 let diffFiles: GitDiffFile[] = [];
43224452 )}
43234453 </div>
43244454 ) : tab === "files" ? (
4325 <DiffView
4326 raw={diffRaw}
4327 files={diffFiles}
4328 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4329 inlineComments={diffInlineComments}
4330 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4331 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
4332 />
4455 <>
4456 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4457 {splitSuggestion && (
4458 <div class="split-suggestion" id="pr-split-banner">
4459 <div class="split-header">
4460 <span class="split-icon" aria-hidden="true">✂️</span>
4461 <strong>This PR may be too large to review effectively</strong>
4462 <span class="split-stat">
4463 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4464 </span>
4465 <button
4466 class="split-toggle"
4467 type="button"
4468 onclick="const b=document.getElementById('pr-split-body');const hidden=b.hasAttribute('hidden');b.toggleAttribute('hidden');this.textContent=hidden?'Hide split suggestion':'Show split suggestion';"
4469 >
4470 Show split suggestion
4471 </button>
4472 </div>
4473 <div class="split-body" id="pr-split-body" hidden>
4474 <p class="split-intro">
4475 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4476 </p>
4477 {splitSuggestion.suggestedPrs.map((sp, i) => (
4478 <div class="split-pr">
4479 <div class="split-pr-num">{i + 1}</div>
4480 <div class="split-pr-body">
4481 <strong>{sp.title}</strong>
4482 <p>{sp.rationale}</p>
4483 <code>{sp.files.join(", ")}</code>
4484 <span class="split-lines">~{sp.estimatedLines} lines</span>
4485 </div>
4486 </div>
4487 ))}
4488 {splitSuggestion.mergeOrder.length > 0 && (
4489 <p class="split-order">
4490 Suggested merge order:{" "}
4491 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4492 </p>
4493 )}
4494 </div>
4495 </div>
4496 )}
4497
4498 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4499 {busRiskFiles.length > 0 && (() => {
4500 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4501 ? "critical"
4502 : busRiskFiles.some((f) => f.risk === "high")
4503 ? "high"
4504 : "medium";
4505 return (
4506 <div class={`busfactor-panel busfactor-${topRisk}`}>
4507 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4508 <div class="busfactor-body">
4509 <strong>Knowledge concentration warning</strong>
4510 <p>
4511 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4512 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4513 maintained by one person. Consider pairing on this review.
4514 </p>
4515 <ul>
4516 {busRiskFiles.map((f) => (
4517 <li>
4518 <code>{f.path}</code> —{" "}
4519 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4520 </li>
4521 ))}
4522 </ul>
4523 </div>
4524 </div>
4525 );
4526 })()}
4527
4528 <DiffView
4529 raw={diffRaw}
4530 files={diffFiles}
4531 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4532 inlineComments={diffInlineComments}
4533 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4534 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
4535 />
4536 </>
43334537 ) : (
43344538 <>
43354539 {pr.body && (
43364540