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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
bus-factor.ts8.7 KB · 306 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
/**
 * Bus Factor Analysis — detect files that only one person understands.
 *
 * Uses git log parsing to build a commit-author map per file, then flags
 * files where one author has >75% of commits and total commits >= 3.
 *
 * Risk levels:
 *   critical — >90% single author, >=5 commits, modified in last 30 days
 *   high     — >80% single author, >=4 commits
 *   medium   — >75% single author, >=3 commits
 *
 * Results are cached in the `bus_factor_cache` table (7-day TTL).
 * No AI calls — pure git log parsing.
 */

import { join } from "path";
import { eq } from "drizzle-orm";
import { db } from "../db";
import { busFactorCache } from "../db/schema";
import { config } from "./config";

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

export interface BusFactorFile {
  path: string;
  primaryAuthor: string;        // username / display name of dominant author
  primaryAuthorPct: number;     // e.g. 87 (integer percent)
  totalCommits: number;
  lastModified: string;         // ISO date string
  risk: "critical" | "high" | "medium";
}

export interface BusFactorReport {
  repoId: string;
  analyzedAt: string;
  atRiskFiles: BusFactorFile[];  // files with bus factor = 1
  totalFilesAnalyzed: number;
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

const CODE_EXTENSIONS = new Set([
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
  ".py", ".go", ".rs", ".java", ".rb", ".php",
  ".c", ".cpp", ".cc", ".h", ".hpp",
  ".cs", ".swift", ".kt", ".scala",
  ".vue", ".svelte",
  ".sh", ".bash", ".zsh",
  ".sql",
]);

const SKIP_DIRS = ["node_modules", "dist", ".next", "build", "vendor", ".git", "coverage"];

function isCodeFile(filePath: string): boolean {
  // Skip generated / dependency directories
  const parts = filePath.split("/");
  if (parts.some((p) => SKIP_DIRS.includes(p))) return false;

  const dotIdx = filePath.lastIndexOf(".");
  if (dotIdx === -1) return false;
  const ext = filePath.slice(dotIdx).toLowerCase();
  return CODE_EXTENSIONS.has(ext);
}

function getRepoDir(owner: string, repo: string): string {
  return join(config.gitReposPath, `${owner}/${repo}.git`);
}

async function spawnGit(args: string[], cwd: string): Promise<string> {
  const proc = Bun.spawn(["git", "--git-dir", cwd, ...args], {
    stdout: "pipe",
    stderr: "pipe",
  });
  const out = await new Response(proc.stdout).text();
  await proc.exited;
  return out;
}

// ---------------------------------------------------------------------------
// Core analysis
// ---------------------------------------------------------------------------

/**
 * Parse `git log --name-only --format="%ae %an"` output into a map:
 *   Map<filePath, Map<authorIdentifier, commitCount>>
 *
 * Also returns a map of file → last modified date.
 */
function parseGitLog(raw: string): {
  fileAuthorMap: Map<string, Map<string, number>>;
  fileLastModified: Map<string, string>;
} {
  const fileAuthorMap = new Map<string, Map<string, number>>();
  const fileLastModified = new Map<string, string>();

  const lines = raw.split("\n");
  let currentAuthor: string | null = null;
  let currentDate: string | null = null;
  let inFileList = false;

  for (const line of lines) {
    const trimmed = line.trim();
    if (!trimmed) {
      // blank line separator between commits
      inFileList = false;
      currentDate = null;
      continue;
    }

    // Header line — format: "<email> <name> <date>"
    // We use "%ae %an %ad" with --date=short
    const headerMatch = trimmed.match(/^(\S+)\s+(.+?)\s+(\d{4}-\d{2}-\d{2})$/);
    if (headerMatch) {
      currentAuthor = headerMatch[2].trim(); // prefer display name
      currentDate = headerMatch[3];
      inFileList = true;
      continue;
    }

    if (inFileList && currentAuthor) {
      // This line is a file path
      const filePath = trimmed;
      if (!filePath || filePath.startsWith("diff") || filePath.startsWith("---")) continue;

      if (!fileAuthorMap.has(filePath)) {
        fileAuthorMap.set(filePath, new Map());
      }
      const authorMap = fileAuthorMap.get(filePath)!;
      authorMap.set(currentAuthor, (authorMap.get(currentAuthor) ?? 0) + 1);

      // Track most recent modification (git log is newest-first)
      if (!fileLastModified.has(filePath) && currentDate) {
        fileLastModified.set(filePath, currentDate);
      }
    }
  }

  return { fileAuthorMap, fileLastModified };
}

function computeRisk(
  primaryPct: number,
  totalCommits: number,
  lastModified: string
): "critical" | "high" | "medium" | null {
  if (primaryPct <= 75 || totalCommits < 3) return null;

  const daysSinceModified =
    (Date.now() - new Date(lastModified).getTime()) / (1000 * 60 * 60 * 24);

  if (primaryPct > 90 && totalCommits >= 5 && daysSinceModified <= 30) {
    return "critical";
  }
  if (primaryPct > 80 && totalCommits >= 4) {
    return "high";
  }
  return "medium";
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

export async function analyzeBusFactor(
  repoId: string,
  owner: string,
  repo: string
): Promise<BusFactorReport> {
  const repoDir = getRepoDir(owner, repo);
  const analyzedAt = new Date().toISOString();

  // Fetch git log with file names in one pass — format: "email name date\nfile1\nfile2\n\n"
  const raw = await spawnGit(
    [
      "log",
      "--name-only",
      "--format=%ae %an %ad",
      "--date=short",
      "--diff-filter=ACMR",
      "-n",
      "5000",
    ],
    repoDir
  );

  const { fileAuthorMap, fileLastModified } = parseGitLog(raw);

  const atRiskFiles: BusFactorFile[] = [];
  let totalFilesAnalyzed = 0;

  for (const [filePath, authorMap] of fileAuthorMap) {
    if (!isCodeFile(filePath)) continue;
    totalFilesAnalyzed++;

    const totalCommits = Array.from(authorMap.values()).reduce((a, b) => a + b, 0);
    if (totalCommits < 3) continue;

    // Find dominant author
    let primaryAuthor = "";
    let primaryCount = 0;
    for (const [author, count] of authorMap) {
      if (count > primaryCount) {
        primaryCount = count;
        primaryAuthor = author;
      }
    }

    const primaryAuthorPct = Math.round((primaryCount / totalCommits) * 100);
    const lastModified = fileLastModified.get(filePath) ?? new Date().toISOString().slice(0, 10);
    const risk = computeRisk(primaryAuthorPct, totalCommits, lastModified);

    if (risk) {
      atRiskFiles.push({
        path: filePath,
        primaryAuthor,
        primaryAuthorPct,
        totalCommits,
        lastModified,
        risk,
      });
    }

    if (atRiskFiles.length >= 50) break;
  }

  // Sort: critical first, then high, then medium
  const riskOrder = { critical: 0, high: 1, medium: 2 };
  atRiskFiles.sort((a, b) => riskOrder[a.risk] - riskOrder[b.risk]);

  const report: BusFactorReport = {
    repoId,
    analyzedAt,
    atRiskFiles,
    totalFilesAnalyzed,
  };

  // Upsert into cache
  try {
    await db
      .insert(busFactorCache)
      .values({
        repositoryId: repoId,
        analyzedAt: new Date(analyzedAt),
        atRiskFiles: atRiskFiles as unknown as object,
        totalFilesAnalyzed,
      })
      .onConflictDoUpdate({
        target: busFactorCache.repositoryId,
        set: {
          analyzedAt: new Date(analyzedAt),
          atRiskFiles: atRiskFiles as unknown as object,
          totalFilesAnalyzed,
        },
      });
  } catch {
    // Cache write failure is non-blocking
  }

  return report;
}

/**
 * Return cached at-risk files that overlap with `changedFiles`.
 * If the cache is older than 7 days, trigger a background re-analysis.
 */
export async function getBusFactorWarning(
  repoId: string,
  owner: string,
  repo: string,
  changedFiles: string[]
): Promise<BusFactorFile[]> {
  if (changedFiles.length === 0) return [];

  try {
    const rows = await db
      .select()
      .from(busFactorCache)
      .where(eq(busFactorCache.repositoryId, repoId))
      .limit(1);

    if (rows.length === 0) {
      // No cache yet — trigger background analysis and return empty
      analyzeBusFactor(repoId, owner, repo).catch(() => {});
      return [];
    }

    const cached = rows[0];
    const ageMs = Date.now() - cached.analyzedAt.getTime();
    const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;

    if (ageMs > sevenDaysMs) {
      // Stale — refresh in background
      analyzeBusFactor(repoId, owner, repo).catch(() => {});
    }

    const atRiskFiles = cached.atRiskFiles as BusFactorFile[];
    const changedSet = new Set(changedFiles);
    return atRiskFiles.filter((f) => changedSet.has(f.path));
  } catch {
    return [];
  }
}