diff --git a/src/review/review-effort.ts b/src/review/review-effort.ts new file mode 100644 index 0000000000..9c7c5243eb --- /dev/null +++ b/src/review/review-effort.ts @@ -0,0 +1,57 @@ +// Deterministic per-PR review-effort estimator (#2068, core of #1955). Pure: given the changed files and their +// patches, weight each file's added-line count by its category, add a fixed per-file overhead, and map the total +// to a 1-5 complexity band plus a rounded minutes estimate. No AI, no IO — identical input always yields the same +// estimate. Consumed by the ROI and unified-comment surfaces; standalone and fully unit-testable. + +import { addedLineCount } from "./review-diff"; +import { classifyChangedFile, type ChangedFileCategory } from "../signals/path-matchers"; + +export type ReviewEffortFile = { path: string; patch?: string | undefined }; + +export type ReviewEffort = { + /** Complexity band from 1 (trivial) to 5 (heavy). */ + band: 1 | 2 | 3 | 4 | 5; + /** Rough minutes a human reviewer should budget. */ + minutes: number; +}; + +// Per-added-line review weight by file category. Genuine source costs the most to review; machine-produced or +// imported content (minified/generated/vendored/lockfiles) the least; docs/config/tests sit in between. A single +// auditable table rather than a branch chain, so the weighting is easy to read and adjust. +const CATEGORY_WEIGHT: Record = { + minified: 0.05, + generated: 0.05, + vendored: 0.05, + lockfile: 0.1, + dependency_manifest: 0.4, + config: 0.4, + docs: 0.25, + test: 0.5, + source: 1, + other: 0.5, +}; + +// A fixed per-file review-overhead: each touched file carries a context-switch cost on top of its lines. +const PER_FILE_OVERHEAD = 3; +// Upper effort bound of bands 1-4; anything larger is band 5. Deliberate, documented cut points — this is a +// triage aid, not a precise measurement. +const BAND_MAX = [10, 40, 120, 300]; +// Minutes are half the weighted effort, floored at 1 so any non-empty review reads as at least a minute. +const MINUTES_PER_EFFORT = 0.5; + +function bandForEffort(effort: number): 1 | 2 | 3 | 4 | 5 { + for (let i = 0; i < BAND_MAX.length; i++) { + if (effort <= BAND_MAX[i]!) return (i + 1) as 1 | 2 | 3 | 4; + } + return 5; +} + +/** Estimate the review effort of a change set. Pure and deterministic. */ +export function estimateReviewEffort(files: ReviewEffortFile[]): ReviewEffort { + let weighted = 0; + for (const file of files) { + weighted += addedLineCount(file.patch) * CATEGORY_WEIGHT[classifyChangedFile(file.path)]; + } + const effort = weighted + files.length * PER_FILE_OVERHEAD; + return { band: bandForEffort(effort), minutes: Math.max(1, Math.round(effort * MINUTES_PER_EFFORT)) }; +} diff --git a/test/unit/review-effort.test.ts b/test/unit/review-effort.test.ts new file mode 100644 index 0000000000..c37b02d52b --- /dev/null +++ b/test/unit/review-effort.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { estimateReviewEffort, type ReviewEffortFile } from "../../src/review/review-effort"; + +// A patch with exactly `added` added lines (each `+`), so a test can dial the effort precisely. +function srcPatch(added: number): string { + return ["@@ -0,0 +1," + added + " @@", ...Array.from({ length: added }, (_, i) => `+const x${i} = ${i};`)].join("\n"); +} + +function file(path: string, added: number): ReviewEffortFile { + return { path, patch: srcPatch(added) }; +} + +describe("estimateReviewEffort", () => { + it("returns band 1 and a floored minute for an empty change set", () => { + expect(estimateReviewEffort([])).toEqual({ band: 1, minutes: 1 }); + }); + + it("counts a file with no patch as zero added lines but still charges per-file overhead", () => { + // one file, no patch: weighted 0 + 1*3 = effort 3 -> band 1, minutes round(1.5) = 2 + expect(estimateReviewEffort([{ path: "src/a.ts" }])).toEqual({ band: 1, minutes: 2 }); + }); + + it("maps rising source-line volume across every band", () => { + expect(estimateReviewEffort([file("src/a.ts", 2)]).band).toBe(1); // effort 5 + expect(estimateReviewEffort([file("src/a.ts", 20)]).band).toBe(2); // effort 23 + expect(estimateReviewEffort([file("src/a.ts", 60)]).band).toBe(3); // effort 63 + expect(estimateReviewEffort([file("src/a.ts", 200)]).band).toBe(4); // effort 203 + expect(estimateReviewEffort([file("src/a.ts", 400)]).band).toBe(5); // effort 403 + }); + + it("derives minutes as half the weighted effort", () => { + // 400 source lines -> weighted 400 + 3 = 403 -> round(201.5) = 202 + expect(estimateReviewEffort([file("src/a.ts", 400)]).minutes).toBe(202); + }); + + it("weights non-source categories below source, so the same line count reviews as less effort", () => { + const source = estimateReviewEffort([file("src/a.ts", 100)]); // 100 + 3 = 103 -> band 3 + const docs = estimateReviewEffort([file("docs/guide.md", 100)]); // 25 + 3 = 28 -> band 2 + const lockfile = estimateReviewEffort([file("package-lock.json", 100)]); // 10 + 3 = 13 -> band 2 + expect(source.band).toBe(3); + expect(docs.band).toBe(2); + expect(lockfile.band).toBe(2); + expect(docs.minutes).toBeLessThan(source.minutes); + expect(lockfile.minutes).toBeLessThan(docs.minutes); + }); + + it("sums weighted effort across multiple files", () => { + const effort = estimateReviewEffort([file("src/a.ts", 10), file("src/b.ts", 10)]); // (10+10) + 2*3 = 26 -> band 2 + expect(effort.band).toBe(2); + expect(effort.minutes).toBe(13); + }); +});