Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions review-enrichment/src/analyzers/churn-hotspot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Churn-hotspot + bug-density scorer (#1513). Flags changed files that are statistical churn hotspots — high
// commit frequency AND a high fraction of fix/revert commits in recent history — fragile areas where defects
// cluster, so the reviewer scrutinizes harder. This is heavy/historical analysis the no-checkout headless
// `claude --print` reviewer cannot do (it would need the git log); the REES returns it as a brief block.
//
// Data source: GitHub REST `GET /repos/{owner}/{repo}/commits?path=<file>&since=<90d>&per_page=100` per changed
// file; commit subjects are classified locally (free with an installation token). Distinct from #1478's
// author-track-record, which grades the submitter; this grades the FILE.
//
// Fail-safe: returns [] on any network error, non-ok response, or missing token. One file's fetch failure does
// not abort the rest. Abort signals propagate as `analyzer_aborted` so the orchestrator marks the brief partial.
import type { EnrichRequest, ChurnHotspotFinding } from "../types.js";

const SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; // requires alphanumeric start; encodeURIComponent prevents path traversal
// Fan-out cap (one GitHub call per changed file). Bounded so a large diff cannot exhaust the shared REST budget;
// only the first MAX_FILES_SCANNED files are queried — scan in arrival order, cap the RETURNED findings after sort.
const MAX_FILES_SCANNED = 15;
const LOOKBACK_DAYS = 90;
const PER_PAGE = 100;
// A hotspot has MEANINGFUL recent traffic (a file touched twice is not a defect cluster) AND a high fix/revert
// fraction (defects cluster where fixes keep landing). The two together keep the signal high-precision: a busy
// file with few fixes is just active, and a quiet file with two fix commits is not a cluster.
const MIN_COMMITS = 10;
const MIN_FIX_REVERT_RATE = 0.3;

// Commit-subject classifier: true when the subject reads as a defect fix or a revert. Requires the Conventional
// Commit shape — `fix:` / `fix!:` / `fix(scope):` / `fix(scope)!:` / `bugfix:` / `hotfix:` / `revert:` — or git's
// auto-generated `Revert "…"` prefix. The `!` marker (Conventional Commit breaking change) is supported. The
// trailing `:` (or `"` for git reverts) prevents false positives like `fix tests` or `revert docs` that merely
// START with the word but carry no defect-fix intent. Case-insensitive; the body is never inspected.
const FIX_REVERT_RE = /^\s*(?:(?:fix|bugfix|hotfix)\s*(?:\([^)]*\))?\s*!?\s*:|revert\s*(?::|"))/i;

/** True when a commit message's subject (first line) reads as a defect fix or a revert. Pure. */
export function isFixOrRevertCommit(message: string | null | undefined): boolean {
if (!message) return false;
const subject = message.split("\n", 1)[0] ?? message;
return FIX_REVERT_RE.test(subject);
}

/** Tally a file's recent commit messages into a (total, fixRevert) pair. Pure + total. */
export function tallyCommits(messages: Array<string | null | undefined>): { total: number; fixRevert: number } {
let total = 0;
let fixRevert = 0;
for (const message of messages) {
total += 1;
if (isFixOrRevertCommit(message)) fixRevert += 1;
}
return { total, fixRevert };
}

/** A file is a churn hotspot when it carries meaningful commit frequency AND a high fix/revert fraction.
* Pure + total; the thresholds are the committed defaults (a deployment may tighten them privately). */
export function isHotspot(total: number, fixRevert: number): boolean {
if (total < MIN_COMMITS) return false;
return fixRevert / total >= MIN_FIX_REVERT_RATE;
}

type ScanLimits = {
maxFiles?: number;
};

interface ScanOptions {
signal?: AbortSignal;
limits?: ScanLimits;
}

/** Fetch up to PER_PAGE recent commit messages for one file path. Returns [] on any error (fail-safe). */
async function fetchFileCommits(
repoOwner: string,
repoName: string,
path: string,
sinceIso: string,
headers: Record<string, string>,
fetchFn: typeof fetch,
signal?: AbortSignal,
): Promise<Array<string | null>> {
const url = `https://github.com/ghapi/repos/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}/commits?path=${encodeURIComponent(path)}&since=${encodeURIComponent(sinceIso)}&per_page=${PER_PAGE}`;
const resp = await fetchFn(url, { headers, signal });
if (!resp.ok) return [];
const body = (await resp.json()) as unknown;
// A rate-limit message, HTML interstitial, or driver anomaly can return a non-array body; guard so a
// malformed response degrades to [] (no messages) rather than throwing a runtime TypeError.
if (!Array.isArray(body)) return [];
return (body as Array<{ commit?: { message?: string } } | null>).map(
(entry) => entry?.commit?.message ?? null,
);
}

/** Analyzer entrypoint: report changed files that are churn hotspots, busiest (highest fix/revert rate) first. */
export async function scanChurnHotspots(
req: EnrichRequest,
fetchFn: typeof fetch,
options: ScanOptions = {},
): Promise<ChurnHotspotFinding[]> {
const { repoFullName, githubToken, files = [] } = req;
if (!githubToken) return [];
const parts = repoFullName.split("/");
if (parts.length !== 2) return []; // require exactly `owner/repo`
const repoOwner = parts[0]!;
const repoName = parts[1]!;
if (!SLUG_RE.test(repoOwner) || !SLUG_RE.test(repoName)) return [];

const maxFiles = Math.max(0, options.limits?.maxFiles ?? MAX_FILES_SCANNED);
const sinceIso = new Date(Date.now() - LOOKBACK_DAYS * 86_400_000).toISOString();
const headers: Record<string, string> = {
Authorization: `Bearer ${githubToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};

const findings: ChurnHotspotFinding[] = [];
let scanned = 0;
for (const file of files) {
if (options.signal?.aborted) throw new Error("analyzer_aborted");
if (scanned >= maxFiles) break;
scanned += 1;
let messages: Array<string | null>;
try {
messages = await fetchFileCommits(repoOwner, repoName, file.path, sinceIso, headers, fetchFn, options.signal);
} catch (error) {
// An abort must propagate so the orchestrator marks the brief partial; any other network error is one
// file's transient failure — skip it and keep scanning the rest.
if (options.signal?.aborted) throw new Error("analyzer_aborted");
void error;
continue;
}
const { total, fixRevert } = tallyCommits(messages);
if (!isHotspot(total, fixRevert)) continue;
findings.push({
file: file.path,
commits: total,
fixRevertCommits: fixRevert,
fixRevertRate: Number((fixRevert / total).toFixed(2)),
});
}

findings.sort((a, b) => b.fixRevertRate - a.fixRevertRate || b.commits - a.commits);
return findings;
}
2 changes: 2 additions & 0 deletions review-enrichment/src/brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { scanCodeowners } from "./analyzers/codeowners.js";
import { scanSecretLog } from "./analyzers/secret-log.js";
import { scanAssetWeight } from "./analyzers/asset-weight.js";
import { scanTyposquat } from "./analyzers/typosquat.js";
import { scanChurnHotspots } from "./analyzers/churn-hotspot.js";
import { renderBrief } from "./render.js";
import { captureAnalyzerDegradation } from "./sentry.js";

Expand All @@ -41,6 +42,7 @@ const ANALYZERS: Record<keyof BriefFindings, AnalyzerFn> = {
secretLog: (req, signal) => scanSecretLog(req, signal),
assetWeight: (req, signal) => scanAssetWeight(req, fetch, { signal }),
typosquat: (req, signal) => scanTyposquat(req, fetch, { signal }),
churnHotspot: (req, signal) => scanChurnHotspots(req, fetch, { signal }),
};

function runWithTimeout<T>(
Expand Down
13 changes: 13 additions & 0 deletions review-enrichment/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,19 @@ export function renderBrief(
}
}

const churnHotspots = findings.churnHotspot ?? [];
if (churnHotspots.length) {
lines.push(
"### Churn hotspots (high commit frequency + fix/revert clustering — scrutinize harder)",
);
for (const item of churnHotspots) {
const pct = Math.round(item.fixRevertRate * 100);
lines.push(
`- ${safeCodeSpan(item.file)} — ${item.commits} commits in the last 90 days, ${pct}% fix/revert (${item.fixRevertCommits}/${item.commits})`,
);
}
}

if (!lines.length) return { promptSection: "", systemSuffix: "" };

const header =
Expand Down
14 changes: 14 additions & 0 deletions review-enrichment/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,19 @@ export interface TyposquatFinding {
reason: string;
}

/** A changed file that is a statistical churn hotspot — high commit frequency AND a high fraction of fix/revert
* commits in recent history (#1513). Defects cluster in such files, so the reviewer scrutinizes harder. Counts
* + a rate only — no commit messages, authors, or dates (public-safe by construction). */
export interface ChurnHotspotFinding {
file: string;
/** Total commits touching this file in the lookback window (capped at the per-page fetch limit). */
commits: number;
/** Commits whose subject reads as a defect fix or a revert. */
fixRevertCommits: number;
/** `fixRevertCommits / commits`, rounded to 2 decimals. */
fixRevertRate: number;
}

/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */
export interface BriefFindings {
dependency?: DependencyFinding[];
Expand All @@ -177,6 +190,7 @@ export interface BriefFindings {
secretLog?: SecretLogFinding[];
assetWeight?: AssetWeightFinding[];
typosquat?: TyposquatFinding[];
churnHotspot?: ChurnHotspotFinding[];
}

export type AnalyzerStatus = "ok" | "degraded" | "skipped";
Expand Down
Loading