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
34 changes: 25 additions & 9 deletions src/report/aggregate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,27 @@ test('counts merged-in-window PRs and surfaces backlog age buckets', () => {
mergedInWindowCount: 1,
oldestOpenDays: expect.any(Number) as number,
});
// The 263-day-old PR should fall into the 180+ bucket.
const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '180+ days');
// The 263-day-old PR should fall into the 90+ bucket (90 and 180+ are no longer split).
const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '90+ days');
expect(oldBucket?.count).toBe(1);
});

test('averages open PR age and reports null when nothing is open', () => {
// collectionContext.now is 2026-05-22, so these open PRs are 10 and 30 days old.
const withOpen = collectedData.build({
dependabotPrs: [
dependabotPr.build({ state: 'open', createdAt: '2026-05-12T00:00:00Z' }),
dependabotPr.build({ state: 'open', createdAt: '2026-04-22T00:00:00Z' }),
],
});
expect(aggregate(withOpen).prBacklog.openAvgAgeDays).toBe(20);

const noOpen = collectedData.build({
dependabotPrs: [dependabotPr.build({ state: 'closed', merged: true, mergedAt: '2026-04-01T00:00:00Z' })],
});
expect(aggregate(noOpen).prBacklog.openAvgAgeDays).toBeNull();
});

test('rolls org/visibility/language counts up into orgOverview', () => {
const data = collectedData.build({
repos: [
Expand Down Expand Up @@ -180,13 +196,13 @@ test('builds a cost estimate from human merges and reviews, excluding bot merges
const bundle = aggregate(data);
expect(bundle.costEstimate.humanMergeCount).toBe(100);
expect(bundle.costEstimate.humanReviewCount).toBe(0);
expect(bundle.costEstimate.hourlyRateUsd).toBe(150);
expect(bundle.costEstimate.minutesPerPr).toBe(5);
// 100 actions × 5 min × $150/hr / 60 = $1250 in window
expect(bundle.costEstimate.windowCostUsd).toBe(1250);
// ~$423/month over 90 days (window × 30.44/90)
expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(400);
expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(450);
expect(bundle.costEstimate.hourlyRateUsd).toBe(200);
expect(bundle.costEstimate.minutesPerPr).toBe(12);
// 100 actions × 12 min × $200/hr / 60 = $4000 in window
expect(bundle.costEstimate.windowCostUsd).toBe(4000);
// ~$1,352/month over 90 days (window × 30.44/90)
expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(1300);
expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(1400);
expect(bundle.costEstimate.annualCostUsd).toBe(bundle.costEstimate.monthlyCostUsd * 12);
expect(bundle.costEstimate.savingsScenarios.map((s) => s.autoMergeRate)).toEqual([0.5, 0.6, 0.7, 0.8]);
expect(bundle.costEstimate.savingsScenarios[0]?.annualSavingsUsd).toBe(
Expand Down
16 changes: 8 additions & 8 deletions src/report/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface PrBacklog {
mergedInWindowCount: number;
openAgeBuckets: Array<{ label: string; count: number }>;
oldestOpenDays: number | null;
openAvgAgeDays: number | null;
bumpTypeSplit: Array<{ bumpType: string; count: number; percentage: number }>;
devOnlyShare: { count: number; percentage: number };
ciStatusMix: { green: number; failing: number; pending: number };
Expand Down Expand Up @@ -235,19 +236,17 @@ function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant)
{ label: '0–30 days', min: 0, max: 30 },
{ label: '30–60 days', min: 30, max: 60 },
{ label: '60–90 days', min: 60, max: 90 },
{ label: '90–180 days', min: 90, max: 180 },
{ label: '180+ days', min: 180, max: Number.POSITIVE_INFINITY },
{ label: '90+ days', min: 90, max: Number.POSITIVE_INFINITY },
];
const openAges = openPrs.map((p) => daysBetween(now, instantFromString(p.createdAt)));
const openAgeBuckets = buckets.map((b) => ({
label: b.label,
count: openPrs.filter((p) => {
const age = daysBetween(now, instantFromString(p.createdAt));
return age >= b.min && age < b.max;
}).length,
count: openAges.filter((age) => age >= b.min && age < b.max).length,
}));

const oldestOpenDays =
openPrs.length === 0 ? null : Math.max(...openPrs.map((p) => daysBetween(now, instantFromString(p.createdAt))));
const oldestOpenDays = openAges.length === 0 ? null : Math.max(...openAges);
const openAvgAgeDays =
openAges.length === 0 ? null : Math.round(openAges.reduce((sum, age) => sum + age, 0) / openAges.length);

const bumpCounts = new Map<string, number>();
for (const pr of prs) {
Expand Down Expand Up @@ -300,6 +299,7 @@ function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant)
mergedInWindowCount: mergedInWindow.length,
openAgeBuckets,
oldestOpenDays,
openAvgAgeDays,
bumpTypeSplit,
devOnlyShare,
ciStatusMix: { green, failing, pending },
Expand Down
4 changes: 2 additions & 2 deletions src/report/costFormulas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// React app. No Temporal, no Bun globals, no React — keep this module dependency-free
// so it bundles cleanly into both targets.

export const ASSUMED_HOURLY_RATE_USD = 150;
export const ASSUMED_MIN_PER_PR = 5;
export const ASSUMED_HOURLY_RATE_USD = 200;
export const ASSUMED_MIN_PER_PR = 12;

export const AUTO_MERGE_SCENARIO_RATES = [0.5, 0.6, 0.7, 0.8] as const;

Expand Down
54 changes: 33 additions & 21 deletions src/report/testFactories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ReportMeta,
StalledSignals,
} from './aggregate.ts';
import { ASSUMED_HOURLY_RATE_USD, ASSUMED_MIN_PER_PR, deriveCostEstimate, derivePersonCosts } from './costFormulas.ts';
import { type EmbeddedReportData, toEmbeddedShape } from './embeddedShape.ts';
import type { ReportAnalyticsConfig } from './reportAnalyticsConfig.ts';

Expand Down Expand Up @@ -56,10 +57,10 @@ export const prBacklog = Factory.define<PrBacklog>(() => ({
{ label: '0–30 days', count: 40 },
{ label: '30–60 days', count: 18 },
{ label: '60–90 days', count: 6 },
{ label: '90–180 days', count: 25 },
{ label: '180+ days', count: 13 },
{ label: '90+ days', count: 38 },
],
oldestOpenDays: 312,
openAvgAgeDays: 74,
bumpTypeSplit: [
{ bumpType: 'patch', count: 150, percentage: 55 },
{ bumpType: 'minor', count: 95, percentage: 34.8 },
Expand All @@ -77,31 +78,42 @@ export const stalledSignals = Factory.define<StalledSignals>(() => ({
reposWithConfigButNoRecentPrs: ['acme/old-tool'],
}));

// Cost figures derive from the real defaults and formulas so the fixtures track
// production whenever the assumptions move, rather than restating stale literals.
const COST_WINDOW_DAYS = 90;
const HUMAN_MERGE_COUNT = 150;
const HUMAN_REVIEW_COUNT = 12;

export const people = Factory.define<People>(() => ({
mergers: [
{ login: 'alice', count: 90, windowCostUsd: 1125, annualCostUsd: 4563 },
{ login: 'bob', count: 60, windowCostUsd: 750, annualCostUsd: 3042 },
],
reviewers: [{ login: 'alice', count: 12, windowCostUsd: 90, annualCostUsd: 365 }],
mergers: derivePersonCosts(
[
{ login: 'alice', count: 90 },
{ login: 'bob', count: 60 },
],
COST_WINDOW_DAYS,
ASSUMED_MIN_PER_PR,
ASSUMED_HOURLY_RATE_USD,
),
reviewers: derivePersonCosts(
[{ login: 'alice', count: 12 }],
COST_WINDOW_DAYS,
ASSUMED_MIN_PER_PR,
ASSUMED_HOURLY_RATE_USD,
),
commenters: [],
}));

export const costEstimate = Factory.define<CostEstimate>(() => ({
humanMergeCount: 150,
humanReviewCount: 12,
humanMergeCount: HUMAN_MERGE_COUNT,
humanReviewCount: HUMAN_REVIEW_COUNT,
openCount: 102,
windowDays: 90,
hourlyRateUsd: 150,
minutesPerPr: 5,
windowCostUsd: 2025,
monthlyCostUsd: 684,
annualCostUsd: 8208,
savingsScenarios: [
{ autoMergeRate: 0.5, monthlySavingsUsd: 342, annualSavingsUsd: 4104 },
{ autoMergeRate: 0.6, monthlySavingsUsd: 410, annualSavingsUsd: 4920 },
{ autoMergeRate: 0.7, monthlySavingsUsd: 479, annualSavingsUsd: 5748 },
{ autoMergeRate: 0.8, monthlySavingsUsd: 547, annualSavingsUsd: 6564 },
],
windowDays: COST_WINDOW_DAYS,
hourlyRateUsd: ASSUMED_HOURLY_RATE_USD,
minutesPerPr: ASSUMED_MIN_PER_PR,
...deriveCostEstimate(HUMAN_MERGE_COUNT + HUMAN_REVIEW_COUNT, COST_WINDOW_DAYS, {
hourlyRateUsd: ASSUMED_HOURLY_RATE_USD,
minutesPerPr: ASSUMED_MIN_PER_PR,
}),
}));

export const cveExposureOk = Factory.define<CveExposure>(() => ({
Expand Down
Loading