Skip to content
Merged
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
57 changes: 57 additions & 0 deletions src/review/review-effort.ts
Original file line number Diff line number Diff line change
@@ -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<ChangedFileCategory, number> = {
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)) };
}
52 changes: 52 additions & 0 deletions test/unit/review-effort.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading