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
11 changes: 11 additions & 0 deletions .github/actions/report/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ inputs:
required: false
default: ''

category:
description: >
Free-form identifier for this report series within the project, e.g.
"backend" or "frontend". Lets one repo track multiple independently
trended series in coverage-tracker (e.g. a backend unit-test suite and
a frontend suite reported by separate jobs). Optional: when omitted,
reports are grouped under "default", matching prior single-series
behavior.
required: false
default: 'default'

# ── Optional complexity / duplication reports ────────────────────────────
complexity-path:
description: >
Expand Down
28 changes: 25 additions & 3 deletions .github/actions/report/src/__tests__/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ describe('run()', () => {
beforeEach(() => {
vi.mocked(core.getIDToken).mockResolvedValue('mock-oidc-token');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(METRICS_ONE as unknown as Buffer);
vi.mocked(fs.readFileSync).mockReturnValue(METRICS_ONE as unknown as ReturnType<typeof fs.readFileSync>);
mockPayload.repository = { default_branch: 'main' };
mockChecksCreate.mockResolvedValue({});
mockFetch.mockResolvedValue(okFetchResponse({ ok: true, inserted: 1 }));
Expand Down Expand Up @@ -195,7 +195,7 @@ describe('run()', () => {
});

it('warns and returns early when metrics array is empty', async () => {
vi.mocked(fs.readFileSync).mockReturnValue(METRICS_EMPTY as unknown as Buffer);
vi.mocked(fs.readFileSync).mockReturnValue(METRICS_EMPTY as unknown as ReturnType<typeof fs.readFileSync>);
await run();
expect(vi.mocked(core.warning)).toHaveBeenCalledWith(
'No metrics collected — skipping report.',
Expand Down Expand Up @@ -271,10 +271,18 @@ describe('runIngest()', () => {
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
// coverage → line_coverage, duplication → duplication_pct
expect(body).toEqual({ line_coverage: 85, duplication_pct: 0 });
expect(body).toEqual({ category: 'default', line_coverage: 85, duplication_pct: 0 });
expect(body).not.toHaveProperty('metrics');
expect(body).not.toHaveProperty('repository');
});

it('includes an explicit category in the request body when provided', async () => {
mockFetch.mockResolvedValue(okFetchResponse({}));
await runIngest('https://worker.example.com', 'mock-token', metrics, 'frontend');
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.category).toBe('frontend');
});
});

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

it('includes the category in the baseline fetch URL, defaulting to "default"', async () => {
mockFetch.mockResolvedValue(errFetchResponse(404));
await runPRCheck(WORKER, TOKEN, coverageMetric, 'owner', 'repo');
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain('category=default');
});

it('includes an explicit category in the baseline fetch URL when provided', async () => {
mockFetch.mockResolvedValue(errFetchResponse(404));
await runPRCheck(WORKER, TOKEN, coverageMetric, 'owner', 'repo', 'frontend');
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain('category=frontend');
});

it('posts a failure Check Run when coverage is below min-coverage', async () => {
vi.stubEnv('MIN_COVERAGE', '80'); // 75 < 80
mockFetch.mockResolvedValue(errFetchResponse(404));
Expand Down
3 changes: 2 additions & 1 deletion .github/actions/report/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ export async function main(): Promise<void> {
}

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

function warnCoberturaTool(): void {
Expand Down
22 changes: 16 additions & 6 deletions .github/actions/report/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ export async function run(): Promise<void> {
* to ingest (push) or PR check. Shared by the legacy metrics-file entrypoint
* (`run`) and the parser-pipeline entrypoint (`src/index.ts`).
*/
export async function report(workerUrl: string, metrics: Metric[]): Promise<void> {
export async function report(
workerUrl: string,
metrics: Metric[],
category: string = 'default',
): Promise<void> {
workerUrl = workerUrl.replace(/\/$/, '');

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

if (isPR) {
const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? '').split('/');
await runPRCheck(workerUrl, oidcToken, metrics, owner, repo);
await runPRCheck(workerUrl, oidcToken, metrics, owner, repo, category);
} else {
await runIngest(workerUrl, oidcToken, metrics);
await runIngest(workerUrl, oidcToken, metrics, category);
}
}

// ── Push path: ingest metrics ─────────────────────────────────────────────

export async function runIngest(workerUrl: string, oidcToken: string, metrics: Metric[]): Promise<void> {
export async function runIngest(
workerUrl: string,
oidcToken: string,
metrics: Metric[],
category: string = 'default',
): Promise<void> {
// Map legacy metrics array to typed coverage fields
const body: Record<string, number> = {};
const body: Record<string, number | string> = { category };
for (const m of metrics) {
const field = METRIC_TO_FIELD[m.name];
if (field) body[field] = m.value;
Expand Down Expand Up @@ -162,6 +171,7 @@ export async function runPRCheck(
metrics: Metric[],
owner: string,
repo: string,
category: string = 'default',
): Promise<void> {
const minCoverage = parseThreshold(process.env.MIN_COVERAGE);
const maxCoverageDrop = parseThreshold(process.env.MAX_COVERAGE_DROP);
Expand All @@ -171,7 +181,7 @@ export async function runPRCheck(
// Fetch baselines for all collected metrics
const baselines: Record<string, number> = {};
for (const m of metrics) {
const url = `${workerUrl}/api/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
const url = `${workerUrl}/api/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}&category=${encodeURIComponent(category)}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${oidcToken}` } });
if (res.ok) {
try {
Expand Down
18 changes: 17 additions & 1 deletion dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ProjectRow, TrendResponse } from './types';
import type { ProjectRow, TrendResponse, GroupedTrendResponse } from './types';

export async function fetchProjects(fetchFn: typeof fetch = fetch): Promise<ProjectRow[]> {
const res = await fetchFn('/api/projects', { redirect: 'manual' });
Expand All @@ -21,3 +21,19 @@ export async function fetchTrend(
if (!res.ok) throw new Error(`Failed to fetch trend: HTTP ${res.status}`);
return res.json() as Promise<TrendResponse>;
}

export async function fetchTrendByCategory(
owner: string,
repo: string,
metric: string,
branch: string,
limit: number,
fetchFn: typeof fetch = fetch,
): Promise<GroupedTrendResponse> {
const params = new URLSearchParams({ metric, branch, limit: String(limit) });
const res = await fetchFn(`/api/projects/${owner}/${repo}/metrics/categories?${params}`, {
redirect: 'manual',
});
if (!res.ok) throw new Error(`Failed to fetch trend: HTTP ${res.status}`);
return res.json() as Promise<GroupedTrendResponse>;
}
12 changes: 12 additions & 0 deletions dashboard/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export interface TrendResponse {
data: MetricPoint[];
}

export interface CategoryTrend {
category: string;
data: MetricPoint[];
}

export interface GroupedTrendResponse {
project: string;
branch: string;
metric: string;
categories: CategoryTrend[];
}

export type MetricName = 'coverage' | 'complexity' | 'duplication';

export const METRICS: MetricName[] = ['coverage', 'complexity', 'duplication'];
89 changes: 49 additions & 40 deletions dashboard/src/routes/[owner]/[repo]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,6 @@
branchInput = data.branch;
});

// Delta badge: latest value vs. previous point
const latestValue = $derived(
data.trend.data.length > 0 ? data.trend.data[data.trend.data.length - 1].value : null,
);
const prevValue = $derived(
data.trend.data.length > 1 ? data.trend.data[data.trend.data.length - 2].value : null,
);
const delta = $derived(
latestValue !== null && prevValue !== null ? latestValue - prevValue : null,
);
const unit = $derived(data.trend.data[0]?.unit ?? '');

// Chart color for the active metric
const metricChartColor = $derived(
theme.tokens.chart[METRICS.indexOf(data.metric as (typeof METRICS)[number])] ??
Expand Down Expand Up @@ -99,41 +87,55 @@
</div>
</div>

{#if data.trend.data.length === 0}
{#if data.trend.categories.length === 0}
<p class="empty">
No data for <code>{data.metric}</code> on branch <code>{data.branch}</code> yet.
</p>
{:else if browser}
<div class="trend-card">
<div class="trend-card-header">
<div class="trend-card-meta">
<span class="trend-title">{data.metric.charAt(0).toUpperCase() + data.metric.slice(1)} over time</span>
<span class="trend-desc">Last 30 days · {data.branch}</span>
</div>
<div class="trend-card-value">
{#if latestValue !== null}
<span class="big-value">{latestValue.toFixed(1)}{unit}</span>
{#if delta !== null}
<span
class="delta-badge"
style="background:{metricChartColor}28; color:{metricChartColor}"
aria-label="{delta >= 0 ? 'up' : 'down'} {Math.abs(delta).toFixed(1)}{unit}"
<div class="trend-stack">
{#each data.trend.categories as cat (cat.category)}
{@const latestValue = cat.data.length > 0 ? cat.data[cat.data.length - 1].value : null}
{@const prevValue = cat.data.length > 1 ? cat.data[cat.data.length - 2].value : null}
{@const delta = latestValue !== null && prevValue !== null ? latestValue - prevValue : null}
{@const unit = cat.data[0]?.unit ?? ''}
<div class="trend-card">
<div class="trend-card-header">
<div class="trend-card-meta">
<span class="trend-title"
>{cat.category} — {data.metric.charAt(0).toUpperCase() + data.metric.slice(1)} over time</span
>
{delta >= 0 ? '▲' : '▼'} {delta >= 0 ? '+' : ''}{delta.toFixed(1)}{unit}
</span>
{/if}
<span class="trend-desc">Last 30 days · {data.branch}</span>
</div>
<div class="trend-card-value">
{#if latestValue !== null}
<span class="big-value">{latestValue.toFixed(1)}{unit}</span>
{#if delta !== null}
<span
class="delta-badge"
style="background:{metricChartColor}28; color:{metricChartColor}"
aria-label="{delta >= 0 ? 'up' : 'down'} {Math.abs(delta).toFixed(1)}{unit}"
>
{delta >= 0 ? '▲' : '▼'} {delta >= 0 ? '+' : ''}{delta.toFixed(1)}{unit}
</span>
{/if}
{/if}
</div>
</div>
{#if cat.data.length === 0}
<p class="empty">No data for category <code>{cat.category}</code> yet.</p>
{:else}
<TrendChart
data={cat.data}
metric={data.metric}
unit={unit}
color={metricChartColor}
borderColor={theme.tokens.border}
mutedColor={theme.tokens.muted}
textColor={theme.tokens.text}
/>
{/if}
</div>
</div>
<TrendChart
data={data.trend.data}
metric={data.metric}
unit={unit}
color={metricChartColor}
borderColor={theme.tokens.border}
mutedColor={theme.tokens.muted}
textColor={theme.tokens.text}
/>
{/each}
</div>
{/if}

Expand Down Expand Up @@ -310,6 +312,13 @@
opacity: 0.9;
}

/* Trend stack: one full-width chart per category, stacked vertically */
.trend-stack {
display: flex;
flex-direction: column;
gap: 20px;
}

/* Trend card */
.trend-card {
background: var(--card);
Expand Down
6 changes: 3 additions & 3 deletions dashboard/src/routes/[owner]/[repo]/+page.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { fetchProjects, fetchTrend } from '$lib/api';
import { fetchProjects, fetchTrendByCategory } from '$lib/api';

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

let trend;
try {
trend = await fetchTrend(owner, repo, metric, branch, 100, fetch);
trend = await fetchTrendByCategory(owner, repo, metric, branch, 100, fetch);
} catch {
trend = { project: fullSlug, branch, metric, data: [] };
trend = { project: fullSlug, branch, metric, categories: [] };
}

return { project, trend, metric, branch };
Expand Down
15 changes: 14 additions & 1 deletion dashboard/tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ export const MOCK_TREND_EMPTY = {
data: [],
};

export const MOCK_GROUPED_TREND_EMPTY = {
project: 'testorg/repo',
branch: 'main',
metric: 'coverage',
categories: [],
};

/**
* Intercepts all /api/* requests so tests run without a live Worker backend.
* Register this before page.goto() so routes are in place before any fetch fires.
Expand All @@ -35,7 +42,13 @@ export async function mockApi(page: Page): Promise<void> {
await page.route('**/api/**', (route) =>
route.fulfill({ status: 404, body: 'Not found' }),
);
// Specific routes registered last = highest priority (override the catch-all)
// Specific routes registered last = highest priority (override the catch-all).
// metrics/categories is registered before metrics* since Playwright glob `*`
// does not cross `/` — the two never actually collide, but keeping the more
// specific path first mirrors the LIFO-priority convention documented above.
await page.route('**/api/projects/testorg/repo/metrics/categories*', (route) =>
route.fulfill({ json: MOCK_GROUPED_TREND_EMPTY }),
);
await page.route('**/api/projects/testorg/repo/metrics*', (route) =>
route.fulfill({ json: MOCK_TREND_EMPTY }),
);
Expand Down
Loading
Loading