Skip to content

Commit 9491d66

Browse files
tbjersclaude
andcommitted
feat: support multiple named coverage categories per project
Lets one project track several independently-trended report series (e.g. "backend" and "frontend") instead of a single series per commit, so repos with multiple test suites/frameworks get separate coverage/complexity/ duplication history instead of one overwriting the other. Adds a `category` column to coverage_runs/coverage_daily, a grouped trend endpoint, a stacked-chart dashboard view (one chart per category), and an optional `category` input on the reporting GitHub Action. Defaults to "default" everywhere so existing single-category callers are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4bd0002 commit 9491d66

22 files changed

Lines changed: 586 additions & 76 deletions

File tree

.github/actions/report/action.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ inputs:
3939
required: false
4040
default: ''
4141

42+
category:
43+
description: >
44+
Free-form identifier for this report series within the project, e.g.
45+
"backend" or "frontend". Lets one repo track multiple independently
46+
trended series in coverage-tracker (e.g. a backend unit-test suite and
47+
a frontend suite reported by separate jobs). Optional: when omitted,
48+
reports are grouped under "default", matching prior single-series
49+
behavior.
50+
required: false
51+
default: 'default'
52+
4253
# ── Optional complexity / duplication reports ────────────────────────────
4354
complexity-path:
4455
description: >

.github/actions/report/src/__tests__/run.test.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ describe('run()', () => {
159159
beforeEach(() => {
160160
vi.mocked(core.getIDToken).mockResolvedValue('mock-oidc-token');
161161
vi.mocked(fs.existsSync).mockReturnValue(true);
162-
vi.mocked(fs.readFileSync).mockReturnValue(METRICS_ONE as unknown as Buffer);
162+
vi.mocked(fs.readFileSync).mockReturnValue(METRICS_ONE as unknown as ReturnType<typeof fs.readFileSync>);
163163
mockPayload.repository = { default_branch: 'main' };
164164
mockChecksCreate.mockResolvedValue({});
165165
mockFetch.mockResolvedValue(okFetchResponse({ ok: true, inserted: 1 }));
@@ -195,7 +195,7 @@ describe('run()', () => {
195195
});
196196

197197
it('warns and returns early when metrics array is empty', async () => {
198-
vi.mocked(fs.readFileSync).mockReturnValue(METRICS_EMPTY as unknown as Buffer);
198+
vi.mocked(fs.readFileSync).mockReturnValue(METRICS_EMPTY as unknown as ReturnType<typeof fs.readFileSync>);
199199
await run();
200200
expect(vi.mocked(core.warning)).toHaveBeenCalledWith(
201201
'No metrics collected — skipping report.',
@@ -271,10 +271,18 @@ describe('runIngest()', () => {
271271
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
272272
const body = JSON.parse(init.body as string);
273273
// coverage → line_coverage, duplication → duplication_pct
274-
expect(body).toEqual({ line_coverage: 85, duplication_pct: 0 });
274+
expect(body).toEqual({ category: 'default', line_coverage: 85, duplication_pct: 0 });
275275
expect(body).not.toHaveProperty('metrics');
276276
expect(body).not.toHaveProperty('repository');
277277
});
278+
279+
it('includes an explicit category in the request body when provided', async () => {
280+
mockFetch.mockResolvedValue(okFetchResponse({}));
281+
await runIngest('https://worker.example.com', 'mock-token', metrics, 'frontend');
282+
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
283+
const body = JSON.parse(init.body as string);
284+
expect(body.category).toBe('frontend');
285+
});
278286
});
279287

280288
// ── runPRCheck() ──────────────────────────────────────────────────────────────
@@ -309,6 +317,20 @@ describe('runPRCheck()', () => {
309317
expect(vi.mocked(core.setFailed)).not.toHaveBeenCalled();
310318
});
311319

320+
it('includes the category in the baseline fetch URL, defaulting to "default"', async () => {
321+
mockFetch.mockResolvedValue(errFetchResponse(404));
322+
await runPRCheck(WORKER, TOKEN, coverageMetric, 'owner', 'repo');
323+
const [url] = mockFetch.mock.calls[0] as [string];
324+
expect(url).toContain('category=default');
325+
});
326+
327+
it('includes an explicit category in the baseline fetch URL when provided', async () => {
328+
mockFetch.mockResolvedValue(errFetchResponse(404));
329+
await runPRCheck(WORKER, TOKEN, coverageMetric, 'owner', 'repo', 'frontend');
330+
const [url] = mockFetch.mock.calls[0] as [string];
331+
expect(url).toContain('category=frontend');
332+
});
333+
312334
it('posts a failure Check Run when coverage is below min-coverage', async () => {
313335
vi.stubEnv('MIN_COVERAGE', '80'); // 75 < 80
314336
mockFetch.mockResolvedValue(errFetchResponse(404));

.github/actions/report/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ export async function main(): Promise<void> {
136136
}
137137

138138
// ── 4. Threshold checks → Check Run → ingest (shared flow) ────────────────
139-
await report(workerUrl, metrics);
139+
const category = core.getInput('category') || 'default';
140+
await report(workerUrl, metrics, category);
140141
}
141142

142143
function warnCoberturaTool(): void {

.github/actions/report/src/run.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ export async function run(): Promise<void> {
6767
* to ingest (push) or PR check. Shared by the legacy metrics-file entrypoint
6868
* (`run`) and the parser-pipeline entrypoint (`src/index.ts`).
6969
*/
70-
export async function report(workerUrl: string, metrics: Metric[]): Promise<void> {
70+
export async function report(
71+
workerUrl: string,
72+
metrics: Metric[],
73+
category: string = 'default',
74+
): Promise<void> {
7175
workerUrl = workerUrl.replace(/\/$/, '');
7276

7377
core.info(`Reporting ${metrics.length} metric(s): ${metrics.map((m) => m.name).join(', ')}`);
@@ -120,17 +124,22 @@ export async function report(workerUrl: string, metrics: Metric[]): Promise<void
120124

121125
if (isPR) {
122126
const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? '').split('/');
123-
await runPRCheck(workerUrl, oidcToken, metrics, owner, repo);
127+
await runPRCheck(workerUrl, oidcToken, metrics, owner, repo, category);
124128
} else {
125-
await runIngest(workerUrl, oidcToken, metrics);
129+
await runIngest(workerUrl, oidcToken, metrics, category);
126130
}
127131
}
128132

129133
// ── Push path: ingest metrics ─────────────────────────────────────────────
130134

131-
export async function runIngest(workerUrl: string, oidcToken: string, metrics: Metric[]): Promise<void> {
135+
export async function runIngest(
136+
workerUrl: string,
137+
oidcToken: string,
138+
metrics: Metric[],
139+
category: string = 'default',
140+
): Promise<void> {
132141
// Map legacy metrics array to typed coverage fields
133-
const body: Record<string, number> = {};
142+
const body: Record<string, number | string> = { category };
134143
for (const m of metrics) {
135144
const field = METRIC_TO_FIELD[m.name];
136145
if (field) body[field] = m.value;
@@ -162,6 +171,7 @@ export async function runPRCheck(
162171
metrics: Metric[],
163172
owner: string,
164173
repo: string,
174+
category: string = 'default',
165175
): Promise<void> {
166176
const minCoverage = parseThreshold(process.env.MIN_COVERAGE);
167177
const maxCoverageDrop = parseThreshold(process.env.MAX_COVERAGE_DROP);
@@ -171,7 +181,7 @@ export async function runPRCheck(
171181
// Fetch baselines for all collected metrics
172182
const baselines: Record<string, number> = {};
173183
for (const m of metrics) {
174-
const url = `${workerUrl}/api/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
184+
const url = `${workerUrl}/api/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}&category=${encodeURIComponent(category)}`;
175185
const res = await fetch(url, { headers: { Authorization: `Bearer ${oidcToken}` } });
176186
if (res.ok) {
177187
try {

dashboard/src/lib/api.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ProjectRow, TrendResponse } from './types';
1+
import type { ProjectRow, TrendResponse, GroupedTrendResponse } from './types';
22

33
export async function fetchProjects(fetchFn: typeof fetch = fetch): Promise<ProjectRow[]> {
44
const res = await fetchFn('/api/projects', { redirect: 'manual' });
@@ -21,3 +21,19 @@ export async function fetchTrend(
2121
if (!res.ok) throw new Error(`Failed to fetch trend: HTTP ${res.status}`);
2222
return res.json() as Promise<TrendResponse>;
2323
}
24+
25+
export async function fetchTrendByCategory(
26+
owner: string,
27+
repo: string,
28+
metric: string,
29+
branch: string,
30+
limit: number,
31+
fetchFn: typeof fetch = fetch,
32+
): Promise<GroupedTrendResponse> {
33+
const params = new URLSearchParams({ metric, branch, limit: String(limit) });
34+
const res = await fetchFn(`/api/projects/${owner}/${repo}/metrics/categories?${params}`, {
35+
redirect: 'manual',
36+
});
37+
if (!res.ok) throw new Error(`Failed to fetch trend: HTTP ${res.status}`);
38+
return res.json() as Promise<GroupedTrendResponse>;
39+
}

dashboard/src/lib/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export interface TrendResponse {
2424
data: MetricPoint[];
2525
}
2626

27+
export interface CategoryTrend {
28+
category: string;
29+
data: MetricPoint[];
30+
}
31+
32+
export interface GroupedTrendResponse {
33+
project: string;
34+
branch: string;
35+
metric: string;
36+
categories: CategoryTrend[];
37+
}
38+
2739
export type MetricName = 'coverage' | 'complexity' | 'duplication';
2840

2941
export const METRICS: MetricName[] = ['coverage', 'complexity', 'duplication'];

dashboard/src/routes/[owner]/[repo]/+page.svelte

Lines changed: 49 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,6 @@
2727
branchInput = data.branch;
2828
});
2929
30-
// Delta badge: latest value vs. previous point
31-
const latestValue = $derived(
32-
data.trend.data.length > 0 ? data.trend.data[data.trend.data.length - 1].value : null,
33-
);
34-
const prevValue = $derived(
35-
data.trend.data.length > 1 ? data.trend.data[data.trend.data.length - 2].value : null,
36-
);
37-
const delta = $derived(
38-
latestValue !== null && prevValue !== null ? latestValue - prevValue : null,
39-
);
40-
const unit = $derived(data.trend.data[0]?.unit ?? '');
41-
4230
// Chart color for the active metric
4331
const metricChartColor = $derived(
4432
theme.tokens.chart[METRICS.indexOf(data.metric as (typeof METRICS)[number])] ??
@@ -99,41 +87,55 @@
9987
</div>
10088
</div>
10189

102-
{#if data.trend.data.length === 0}
90+
{#if data.trend.categories.length === 0}
10391
<p class="empty">
10492
No data for <code>{data.metric}</code> on branch <code>{data.branch}</code> yet.
10593
</p>
10694
{:else if browser}
107-
<div class="trend-card">
108-
<div class="trend-card-header">
109-
<div class="trend-card-meta">
110-
<span class="trend-title">{data.metric.charAt(0).toUpperCase() + data.metric.slice(1)} over time</span>
111-
<span class="trend-desc">Last 30 days · {data.branch}</span>
112-
</div>
113-
<div class="trend-card-value">
114-
{#if latestValue !== null}
115-
<span class="big-value">{latestValue.toFixed(1)}{unit}</span>
116-
{#if delta !== null}
117-
<span
118-
class="delta-badge"
119-
style="background:{metricChartColor}28; color:{metricChartColor}"
120-
aria-label="{delta >= 0 ? 'up' : 'down'} {Math.abs(delta).toFixed(1)}{unit}"
95+
<div class="trend-stack">
96+
{#each data.trend.categories as cat (cat.category)}
97+
{@const latestValue = cat.data.length > 0 ? cat.data[cat.data.length - 1].value : null}
98+
{@const prevValue = cat.data.length > 1 ? cat.data[cat.data.length - 2].value : null}
99+
{@const delta = latestValue !== null && prevValue !== null ? latestValue - prevValue : null}
100+
{@const unit = cat.data[0]?.unit ?? ''}
101+
<div class="trend-card">
102+
<div class="trend-card-header">
103+
<div class="trend-card-meta">
104+
<span class="trend-title"
105+
>{cat.category} — {data.metric.charAt(0).toUpperCase() + data.metric.slice(1)} over time</span
121106
>
122-
{delta >= 0 ? '' : ''} {delta >= 0 ? '+' : ''}{delta.toFixed(1)}{unit}
123-
</span>
124-
{/if}
107+
<span class="trend-desc">Last 30 days · {data.branch}</span>
108+
</div>
109+
<div class="trend-card-value">
110+
{#if latestValue !== null}
111+
<span class="big-value">{latestValue.toFixed(1)}{unit}</span>
112+
{#if delta !== null}
113+
<span
114+
class="delta-badge"
115+
style="background:{metricChartColor}28; color:{metricChartColor}"
116+
aria-label="{delta >= 0 ? 'up' : 'down'} {Math.abs(delta).toFixed(1)}{unit}"
117+
>
118+
{delta >= 0 ? '' : ''} {delta >= 0 ? '+' : ''}{delta.toFixed(1)}{unit}
119+
</span>
120+
{/if}
121+
{/if}
122+
</div>
123+
</div>
124+
{#if cat.data.length === 0}
125+
<p class="empty">No data for category <code>{cat.category}</code> yet.</p>
126+
{:else}
127+
<TrendChart
128+
data={cat.data}
129+
metric={data.metric}
130+
unit={unit}
131+
color={metricChartColor}
132+
borderColor={theme.tokens.border}
133+
mutedColor={theme.tokens.muted}
134+
textColor={theme.tokens.text}
135+
/>
125136
{/if}
126137
</div>
127-
</div>
128-
<TrendChart
129-
data={data.trend.data}
130-
metric={data.metric}
131-
unit={unit}
132-
color={metricChartColor}
133-
borderColor={theme.tokens.border}
134-
mutedColor={theme.tokens.muted}
135-
textColor={theme.tokens.text}
136-
/>
138+
{/each}
137139
</div>
138140
{/if}
139141

@@ -310,6 +312,13 @@
310312
opacity: 0.9;
311313
}
312314
315+
/* Trend stack: one full-width chart per category, stacked vertically */
316+
.trend-stack {
317+
display: flex;
318+
flex-direction: column;
319+
gap: 20px;
320+
}
321+
313322
/* Trend card */
314323
.trend-card {
315324
background: var(--card);

dashboard/src/routes/[owner]/[repo]/+page.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { error } from '@sveltejs/kit';
22
import type { PageLoad } from './$types';
3-
import { fetchProjects, fetchTrend } from '$lib/api';
3+
import { fetchProjects, fetchTrendByCategory } from '$lib/api';
44

55
export const load: PageLoad = async ({ params, url, fetch }) => {
66
const { owner, repo } = params;
@@ -15,9 +15,9 @@ export const load: PageLoad = async ({ params, url, fetch }) => {
1515

1616
let trend;
1717
try {
18-
trend = await fetchTrend(owner, repo, metric, branch, 100, fetch);
18+
trend = await fetchTrendByCategory(owner, repo, metric, branch, 100, fetch);
1919
} catch {
20-
trend = { project: fullSlug, branch, metric, data: [] };
20+
trend = { project: fullSlug, branch, metric, categories: [] };
2121
}
2222

2323
return { project, trend, metric, branch };

dashboard/tests/helpers.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ export const MOCK_TREND_EMPTY = {
2222
data: [],
2323
};
2424

25+
export const MOCK_GROUPED_TREND_EMPTY = {
26+
project: 'testorg/repo',
27+
branch: 'main',
28+
metric: 'coverage',
29+
categories: [],
30+
};
31+
2532
/**
2633
* Intercepts all /api/* requests so tests run without a live Worker backend.
2734
* Register this before page.goto() so routes are in place before any fetch fires.
@@ -35,7 +42,13 @@ export async function mockApi(page: Page): Promise<void> {
3542
await page.route('**/api/**', (route) =>
3643
route.fulfill({ status: 404, body: 'Not found' }),
3744
);
38-
// Specific routes registered last = highest priority (override the catch-all)
45+
// Specific routes registered last = highest priority (override the catch-all).
46+
// metrics/categories is registered before metrics* since Playwright glob `*`
47+
// does not cross `/` — the two never actually collide, but keeping the more
48+
// specific path first mirrors the LIFO-priority convention documented above.
49+
await page.route('**/api/projects/testorg/repo/metrics/categories*', (route) =>
50+
route.fulfill({ json: MOCK_GROUPED_TREND_EMPTY }),
51+
);
3952
await page.route('**/api/projects/testorg/repo/metrics*', (route) =>
4053
route.fulfill({ json: MOCK_TREND_EMPTY }),
4154
);

0 commit comments

Comments
 (0)