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.
| 1d6db4d | 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 | ||
| 16 | import { join } from "path"; | |
| 17 | import { eq } from "drizzle-orm"; | |
| 18 | import { db } from "../db"; | |
| 19 | import { busFactorCache } from "../db/schema"; | |
| 20 | import { config } from "./config"; | |
| 21 | ||
| 22 | // --------------------------------------------------------------------------- | |
| 23 | // Public types | |
| 24 | // --------------------------------------------------------------------------- | |
| 25 | ||
| 26 | export 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 | ||
| 35 | export 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 | ||
| 46 | const 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 | ||
| 56 | const SKIP_DIRS = ["node_modules", "dist", ".next", "build", "vendor", ".git", "coverage"]; | |
| 57 | ||
| 58 | function 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 | ||
| 69 | function getRepoDir(owner: string, repo: string): string { | |
| 70 | return join(config.gitReposPath, `${owner}/${repo}.git`); | |
| 71 | } | |
| 72 | ||
| 73 | async 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 | */ | |
| 93 | function 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 | ||
| 145 | function 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 | ||
| 168 | export 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 | */ | |
| 270 | export 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 | } |