|
| 1 | +// Churn-hotspot analyzer (#1513). For the files a PR changes, reads each file's recent commit history from the |
| 2 | +// GitHub API and flags the ones that are statistical fragility hotspots — a high commit frequency AND a high |
| 3 | +// fraction of fix/revert commits in the window. These are areas where defects historically cluster, so the |
| 4 | +// reviewer should scrutinize the change harder. This is heavy/external/historical analysis the no-checkout |
| 5 | +// `claude --print` reviewer cannot do. Surfaces only counts derived from the public commit log — never file |
| 6 | +// contents. Distinct from the history analyzer (#1478), which scores the AUTHOR's track record. |
| 7 | +import type { |
| 8 | + AnalyzerDiagnostics, |
| 9 | + EnrichRequest, |
| 10 | + ChurnHotspotFinding, |
| 11 | +} from "../types.js"; |
| 12 | +import type { AnalysisContext } from "../analysis-context.js"; |
| 13 | +import { boundedFetchJson } from "../external-fetch.js"; |
| 14 | + |
| 15 | +const GITHUB_API = "https://api.github.com"; |
| 16 | +const SLUG_RE = /^[A-Za-z0-9._-]+$/; |
| 17 | +const WINDOW_DAYS = 90; |
| 18 | +const PER_PAGE = 100; // one page; a file with a full page of commits in the window is already a clear hotspot |
| 19 | +const MAX_FILES_PROBED = 8; // bound the GitHub round-trips, matching the other history-class analyzers |
| 20 | +const MIN_COMMITS = 8; // a hotspot must change frequently within the window |
| 21 | +const MIN_FIX_FRACTION = 0.3; // and a meaningful share of those changes must be fixes/reverts |
| 22 | +// Files whose commit churn is not a useful code-fragility signal — lockfiles, generated output, and binaries. |
| 23 | +const SKIP_RE = |
| 24 | + /(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|go\.sum)$|\.(?:lock|min\.js|map|snap|png|jpe?g|gif|svg|ico|pdf|zip|gz|woff2?)$|(?:^|\/)(?:dist|build|vendor)\//i; |
| 25 | +// Defect-correcting commit subjects: fix/bugfix/hotfix/revert/regression (conventional-commit `fix:` included). |
| 26 | +const FIX_RE = /\b(?:fix(?:e[ds]|ing)?|bug ?fix|hotfix|revert(?:ed|s)?|regression)\b/i; |
| 27 | + |
| 28 | +interface ScanOptions { |
| 29 | + signal?: AbortSignal; |
| 30 | + analysis?: Pick<AnalysisContext, "fetchJson">; |
| 31 | + diagnostics?: AnalyzerDiagnostics; |
| 32 | +} |
| 33 | + |
| 34 | +/** The slice of a GitHub commit-list item this analyzer reads. */ |
| 35 | +interface CommitItem { |
| 36 | + commit?: { message?: string }; |
| 37 | +} |
| 38 | + |
| 39 | +/** True when a commit's SUBJECT line describes a defect correction. Pure. */ |
| 40 | +export function isFixCommit(message: string): boolean { |
| 41 | + const subject = message.split("\n", 1)[0] ?? ""; |
| 42 | + return FIX_RE.test(subject); |
| 43 | +} |
| 44 | + |
| 45 | +/** Reduce a commit list to total + fix counts, capped flag, and the fix fraction. Pure. */ |
| 46 | +export function summarizeChurn(commits: CommitItem[]): { |
| 47 | + commitCount: number; |
| 48 | + fixCount: number; |
| 49 | + fixFraction: number; |
| 50 | +} { |
| 51 | + let fixCount = 0; |
| 52 | + for (const item of commits) if (isFixCommit(item.commit?.message ?? "")) fixCount += 1; |
| 53 | + const commitCount = commits.length; |
| 54 | + return { commitCount, fixCount, fixFraction: commitCount ? fixCount / commitCount : 0 }; |
| 55 | +} |
| 56 | + |
| 57 | +/** True when a file's churn summary meets the hotspot thresholds (enough commits AND enough of them fixes). Pure. */ |
| 58 | +export function isHotspot(summary: { commitCount: number; fixFraction: number }): boolean { |
| 59 | + return summary.commitCount >= MIN_COMMITS && summary.fixFraction >= MIN_FIX_FRACTION; |
| 60 | +} |
| 61 | + |
| 62 | +function githubHeaders(token: string): Record<string, string> { |
| 63 | + return { |
| 64 | + Authorization: `Bearer ${token}`, |
| 65 | + Accept: "application/vnd.github+json", |
| 66 | + "X-GitHub-Api-Version": "2022-11-28", |
| 67 | + }; |
| 68 | +} |
| 69 | + |
| 70 | +/** Fetch one page of commits touching `path` since `since`. Returns the list, or null on any error / non-200. */ |
| 71 | +async function fetchFileCommits( |
| 72 | + url: string, |
| 73 | + headers: Record<string, string>, |
| 74 | + fetchFn: typeof fetch, |
| 75 | + signal: AbortSignal | undefined, |
| 76 | + options: Pick<ScanOptions, "analysis" | "diagnostics">, |
| 77 | +): Promise<CommitItem[] | null> { |
| 78 | + const fetchOptions = { |
| 79 | + endpointCategory: "github-commits", |
| 80 | + headers, |
| 81 | + signal, |
| 82 | + fetchImpl: fetchFn, |
| 83 | + diagnostics: options.diagnostics, |
| 84 | + phase: "churn-hotspot", |
| 85 | + subcall: "github-commits", |
| 86 | + maxBytes: 512 * 1024, |
| 87 | + maxCallsPerCategory: MAX_FILES_PROBED, |
| 88 | + }; |
| 89 | + const response = options.analysis |
| 90 | + ? await options.analysis.fetchJson<CommitItem[]>(url, fetchOptions) |
| 91 | + : await boundedFetchJson<CommitItem[]>(url, fetchOptions); |
| 92 | + return response.ok && Array.isArray(response.data) ? response.data : null; |
| 93 | +} |
| 94 | + |
| 95 | +/** Analyzer entrypoint: changed files → per-file recent commit history → fragility hotspots. Fail-safe. */ |
| 96 | +export async function scanChurnHotspot( |
| 97 | + req: EnrichRequest, |
| 98 | + fetchFn: typeof fetch = fetch, |
| 99 | + options: ScanOptions = {}, |
| 100 | +): Promise<ChurnHotspotFinding[]> { |
| 101 | + const { repoFullName, githubToken, files = [] } = req; |
| 102 | + if (!githubToken) return []; |
| 103 | + const [owner, repo] = repoFullName.split("/"); |
| 104 | + if (!owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return []; |
| 105 | + |
| 106 | + const headers = githubHeaders(githubToken); |
| 107 | + const since = new Date(Date.now() - WINDOW_DAYS * 86_400_000).toISOString(); |
| 108 | + // A newly-added file has no prior history; skip it (and non-code/generated files) before spending a round-trip. |
| 109 | + const paths = files |
| 110 | + .filter((file) => file.status !== "added" && !SKIP_RE.test(file.path)) |
| 111 | + .map((file) => file.path) |
| 112 | + .slice(0, MAX_FILES_PROBED); |
| 113 | + |
| 114 | + const findings: ChurnHotspotFinding[] = []; |
| 115 | + for (const path of paths) { |
| 116 | + if (options.signal?.aborted) break; |
| 117 | + const url = |
| 118 | + `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits` + |
| 119 | + `?path=${encodeURIComponent(path)}&since=${encodeURIComponent(since)}&per_page=${PER_PAGE}`; |
| 120 | + const commits = await fetchFileCommits(url, headers, fetchFn, options.signal, options); |
| 121 | + if (!commits) continue; |
| 122 | + const summary = summarizeChurn(commits); |
| 123 | + if (!isHotspot(summary)) continue; |
| 124 | + findings.push({ |
| 125 | + file: path, |
| 126 | + commitCount: summary.commitCount, |
| 127 | + fixCount: summary.fixCount, |
| 128 | + windowDays: WINDOW_DAYS, |
| 129 | + capped: summary.commitCount >= PER_PAGE, |
| 130 | + }); |
| 131 | + } |
| 132 | + return findings; |
| 133 | +} |
0 commit comments