Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

bus-factor.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

bus-factor.tsBlame306 lines · 1 contributor
1d6db4dClaude1/**
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}