diff --git a/.github/actions/report/action.yml b/.github/actions/report/action.yml index 7c01507..49d97af 100644 --- a/.github/actions/report/action.yml +++ b/.github/actions/report/action.yml @@ -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: > diff --git a/.github/actions/report/src/__tests__/run.test.ts b/.github/actions/report/src/__tests__/run.test.ts index 9048cc4..4f249d3 100644 --- a/.github/actions/report/src/__tests__/run.test.ts +++ b/.github/actions/report/src/__tests__/run.test.ts @@ -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); mockPayload.repository = { default_branch: 'main' }; mockChecksCreate.mockResolvedValue({}); mockFetch.mockResolvedValue(okFetchResponse({ ok: true, inserted: 1 })); @@ -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); await run(); expect(vi.mocked(core.warning)).toHaveBeenCalledWith( 'No metrics collected — skipping report.', @@ -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() ────────────────────────────────────────────────────────────── @@ -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)); diff --git a/.github/actions/report/src/index.ts b/.github/actions/report/src/index.ts index 5552636..1075a52 100644 --- a/.github/actions/report/src/index.ts +++ b/.github/actions/report/src/index.ts @@ -136,7 +136,8 @@ export async function main(): Promise { } // ── 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 { diff --git a/.github/actions/report/src/run.ts b/.github/actions/report/src/run.ts index 2946098..929567c 100644 --- a/.github/actions/report/src/run.ts +++ b/.github/actions/report/src/run.ts @@ -67,7 +67,11 @@ export async function run(): Promise { * 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 { +export async function report( + workerUrl: string, + metrics: Metric[], + category: string = 'default', +): Promise { workerUrl = workerUrl.replace(/\/$/, ''); 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 { +export async function runIngest( + workerUrl: string, + oidcToken: string, + metrics: Metric[], + category: string = 'default', +): Promise { // Map legacy metrics array to typed coverage fields - const body: Record = {}; + const body: Record = { category }; for (const m of metrics) { const field = METRIC_TO_FIELD[m.name]; if (field) body[field] = m.value; @@ -162,6 +171,7 @@ export async function runPRCheck( metrics: Metric[], owner: string, repo: string, + category: string = 'default', ): Promise { const minCoverage = parseThreshold(process.env.MIN_COVERAGE); const maxCoverageDrop = parseThreshold(process.env.MAX_COVERAGE_DROP); @@ -171,7 +181,7 @@ export async function runPRCheck( // Fetch baselines for all collected metrics const baselines: Record = {}; 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 { diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 2005927..9d44474 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -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 { const res = await fetchFn('/api/projects', { redirect: 'manual' }); @@ -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; } + +export async function fetchTrendByCategory( + owner: string, + repo: string, + metric: string, + branch: string, + limit: number, + fetchFn: typeof fetch = fetch, +): Promise { + 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; +} diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index f231108..4cdcdcc 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -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']; diff --git a/dashboard/src/routes/[owner]/[repo]/+page.svelte b/dashboard/src/routes/[owner]/[repo]/+page.svelte index 56a61af..628d1a9 100644 --- a/dashboard/src/routes/[owner]/[repo]/+page.svelte +++ b/dashboard/src/routes/[owner]/[repo]/+page.svelte @@ -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])] ?? @@ -99,41 +87,55 @@ - {#if data.trend.data.length === 0} + {#if data.trend.categories.length === 0}

No data for {data.metric} on branch {data.branch} yet.

{:else if browser} -
-
-
- {data.metric.charAt(0).toUpperCase() + data.metric.slice(1)} over time - Last 30 days · {data.branch} -
-
- {#if latestValue !== null} - {latestValue.toFixed(1)}{unit} - {#if delta !== null} - + {#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 ?? ''} +
+
+
+ {cat.category} — {data.metric.charAt(0).toUpperCase() + data.metric.slice(1)} over time - {delta >= 0 ? '▲' : '▼'} {delta >= 0 ? '+' : ''}{delta.toFixed(1)}{unit} - - {/if} + Last 30 days · {data.branch} +
+
+ {#if latestValue !== null} + {latestValue.toFixed(1)}{unit} + {#if delta !== null} + + {delta >= 0 ? '▲' : '▼'} {delta >= 0 ? '+' : ''}{delta.toFixed(1)}{unit} + + {/if} + {/if} +
+
+ {#if cat.data.length === 0} +

No data for category {cat.category} yet.

+ {:else} + {/if}
-
- + {/each}
{/if} @@ -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); diff --git a/dashboard/src/routes/[owner]/[repo]/+page.ts b/dashboard/src/routes/[owner]/[repo]/+page.ts index e80bd1d..d94fda7 100644 --- a/dashboard/src/routes/[owner]/[repo]/+page.ts +++ b/dashboard/src/routes/[owner]/[repo]/+page.ts @@ -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; @@ -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 }; diff --git a/dashboard/tests/helpers.ts b/dashboard/tests/helpers.ts index 154afed..63a42d1 100644 --- a/dashboard/tests/helpers.ts +++ b/dashboard/tests/helpers.ts @@ -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. @@ -35,7 +42,13 @@ export async function mockApi(page: Page): Promise { 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 }), ); diff --git a/dashboard/tests/project-detail.spec.ts b/dashboard/tests/project-detail.spec.ts new file mode 100644 index 0000000..f44166c --- /dev/null +++ b/dashboard/tests/project-detail.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { mockApi } from './helpers.js'; + +const MOCK_GROUPED_TREND_TWO_CATEGORIES = { + project: 'testorg/repo', + branch: 'main', + metric: 'coverage', + categories: [ + { + category: 'backend', + data: [ + { commit_sha: 'aaa1', value: 88, unit: '%', recorded_at: '2026-01-01' }, + { commit_sha: 'aaa2', value: 92, unit: '%', recorded_at: '2026-01-02' }, + ], + }, + { + category: 'frontend', + data: [ + { commit_sha: 'bbb1', value: 40, unit: '%', recorded_at: '2026-01-01' }, + { commit_sha: 'bbb2', value: 45, unit: '%', recorded_at: '2026-01-02' }, + ], + }, + ], +}; + +test.describe('project detail page — multiple categories', () => { + test.beforeEach(async ({ page }) => { + await mockApi(page); + // Override the categories mock registered by mockApi() with a two-category + // fixture — Playwright's LIFO route priority means this registration (made + // after mockApi()'s) wins. + await page.route('**/api/projects/testorg/repo/metrics/categories*', (route) => + route.fulfill({ json: MOCK_GROUPED_TREND_TWO_CATEGORIES }), + ); + await page.goto('/testorg/repo'); + await page.waitForSelector('[role="tablist"]'); + }); + + test('renders one stacked chart card per category', async ({ page }) => { + const cards = page.locator('.trend-card'); + await expect(cards).toHaveCount(2); + + await expect(cards.nth(0)).toContainText('backend'); + await expect(cards.nth(1)).toContainText('frontend'); + + // Each card's latest value reflects its own series, not a shared one. + await expect(cards.nth(0)).toContainText('92.0%'); + await expect(cards.nth(1)).toContainText('45.0%'); + }); + + test('stacks cards vertically, not side by side', async ({ page }) => { + const cards = page.locator('.trend-card'); + const [firstBox, secondBox] = await Promise.all([ + cards.nth(0).boundingBox(), + cards.nth(1).boundingBox(), + ]); + expect(firstBox).not.toBeNull(); + expect(secondBox).not.toBeNull(); + // Second card starts below where the first one ends -> vertical stack. + expect(secondBox!.y).toBeGreaterThanOrEqual(firstBox!.y + firstBox!.height); + }); + + test('has no WCAG 2.0 AA violations with two stacked category charts rendered', async ({ page }) => { + await expect(page.locator('.trend-card')).toHaveCount(2); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + // Theme palette colors are intentional; color-contrast is excluded (matches tests/a11y/axe.spec.ts) + .disableRules(['color-contrast']) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); diff --git a/migrations/0003_categories.sql b/migrations/0003_categories.sql new file mode 100644 index 0000000..68ac0e1 --- /dev/null +++ b/migrations/0003_categories.sql @@ -0,0 +1,43 @@ +-- Adds `category`: a free-form identifier for independently tracked report +-- series within one project (e.g. "backend", "frontend"). Existing rows +-- predate categories entirely -> backfill 'default', preserving current +-- single-series behavior for every project that never opts in. + +ALTER TABLE coverage_runs ADD COLUMN category TEXT NOT NULL DEFAULT 'default'; + +-- Re-key the idempotent-ingest guard: the same commit may now legitimately +-- post once per category. +DROP INDEX IF EXISTS idx_runs_project_commit; +CREATE UNIQUE INDEX idx_runs_project_commit + ON coverage_runs (project_id, category, commit_sha); + +-- Read paths filter on project_id + branch first (single-category reads add +-- category = ?, the grouped read ranges over category) — keep that as a +-- contiguous, index-backed prefix. +DROP INDEX IF EXISTS idx_runs_project_time; +CREATE INDEX idx_runs_project_time + ON coverage_runs (project_id, branch, category, ran_at); + +-- coverage_daily: SQLite can't ALTER a PRIMARY KEY in place — rebuild. +CREATE TABLE coverage_daily_new ( + project_id INTEGER NOT NULL, + category TEXT NOT NULL DEFAULT 'default', + day TEXT NOT NULL, -- YYYY-MM-DD + line_coverage REAL NOT NULL, + branch_coverage REAL, + cyclomatic REAL, + cognitive REAL, + duplication_pct REAL, + maintainability REAL, + run_count INTEGER NOT NULL, + PRIMARY KEY (project_id, category, day), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +INSERT INTO coverage_daily_new + (project_id, category, day, line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability, run_count) +SELECT project_id, 'default', day, line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability, run_count +FROM coverage_daily; + +DROP TABLE coverage_daily; +ALTER TABLE coverage_daily_new RENAME TO coverage_daily; diff --git a/src/db/rollup.ts b/src/db/rollup.ts index 9491507..da9ef76 100644 --- a/src/db/rollup.ts +++ b/src/db/rollup.ts @@ -14,26 +14,26 @@ export async function rollupAndPrune(env: Bindings): Promise { // using the last run of that day (ROW_NUMBER window). env.DB.prepare( `INSERT INTO coverage_daily - (project_id, day, line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability, run_count) + (project_id, category, day, line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability, run_count) SELECT - project_id, + project_id, category, strftime('%Y-%m-%d', ran_at, 'unixepoch') AS day, line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability, run_count FROM ( SELECT *, ROW_NUMBER() OVER ( - PARTITION BY project_id, strftime('%Y-%m-%d', ran_at, 'unixepoch') + PARTITION BY project_id, category, strftime('%Y-%m-%d', ran_at, 'unixepoch') ORDER BY ran_at DESC ) AS rn, COUNT(*) OVER ( - PARTITION BY project_id, strftime('%Y-%m-%d', ran_at, 'unixepoch') + PARTITION BY project_id, category, strftime('%Y-%m-%d', ran_at, 'unixepoch') ) AS run_count FROM coverage_runs WHERE ran_at < ?1 ) WHERE rn = 1 - ON CONFLICT(project_id, day) DO UPDATE SET + ON CONFLICT(project_id, category, day) DO UPDATE SET line_coverage = excluded.line_coverage, branch_coverage = excluded.branch_coverage, cyclomatic = excluded.cyclomatic, diff --git a/src/lib/db.ts b/src/lib/db.ts index 9041d65..5d5cefa 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -201,6 +201,7 @@ export async function upsertCoverageRun( branch: string, ranAt: number, fields: { + category?: string; line_coverage: number; branch_coverage?: number | null; cyclomatic?: number | null; @@ -209,13 +210,14 @@ export async function upsertCoverageRun( maintainability?: number | null; }, ): Promise { + const category = fields.category ?? 'default'; await db .prepare( `INSERT INTO coverage_runs - (project_id, commit_sha, branch, ran_at, line_coverage, branch_coverage, + (project_id, commit_sha, branch, category, ran_at, line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(project_id, commit_sha) DO UPDATE SET + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, category, commit_sha) DO UPDATE SET branch = excluded.branch, ran_at = excluded.ran_at, line_coverage = excluded.line_coverage, @@ -229,6 +231,7 @@ export async function upsertCoverageRun( projectId, commitSha, branch, + category, ranAt, fields.line_coverage, fields.branch_coverage ?? null, @@ -244,15 +247,16 @@ export async function getLatestCoverageRun( db: D1Database, projectId: number, branch: string, + category: string = 'default', ): Promise { const row = await db .prepare( `SELECT * FROM coverage_runs - WHERE project_id = ? AND branch = ? + WHERE project_id = ? AND branch = ? AND category = ? ORDER BY ran_at DESC LIMIT 1`, ) - .bind(projectId, branch) + .bind(projectId, branch, category) .first(); return row ?? null; } @@ -263,7 +267,7 @@ type LatestCoverage = Pick< >; /** - * Returns the most recent coverage values for a project/branch. + * Returns the most recent coverage values for a project/branch/category. * Checks coverage_runs first; falls back to coverage_daily for dormant repos * whose raw runs have been pruned by the daily rollup cron. */ @@ -271,8 +275,9 @@ export async function getLatestCoverage( db: D1Database, projectId: number, branch: string, + category: string = 'default', ): Promise { - const run = await getLatestCoverageRun(db, projectId, branch); + const run = await getLatestCoverageRun(db, projectId, branch, category); if (run) return run; const daily = await db @@ -280,11 +285,11 @@ export async function getLatestCoverage( `SELECT 'aggregated' AS commit_sha, line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability FROM coverage_daily - WHERE project_id = ? + WHERE project_id = ? AND category = ? ORDER BY day DESC LIMIT 1`, ) - .bind(projectId) + .bind(projectId, category) .first(); return daily ?? null; } @@ -305,6 +310,7 @@ export async function getCoverageTrend( projectId: number, branch: string, limit: number, + category: string = 'default', ): Promise { // Take the most-recent `limit` days across both tables, then reverse to ASC for display. const { results } = await db @@ -316,11 +322,11 @@ export async function getCoverageTrend( day AS recorded_at, line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability FROM coverage_daily - WHERE project_id = ?1 + WHERE project_id = ?1 AND category = ?4 AND day NOT IN ( SELECT DISTINCT strftime('%Y-%m-%d', ran_at, 'unixepoch') FROM coverage_runs - WHERE project_id = ?1 AND branch = ?2 + WHERE project_id = ?1 AND branch = ?2 AND category = ?4 ) UNION ALL @@ -335,7 +341,7 @@ export async function getCoverageTrend( ORDER BY ran_at DESC ) AS rn FROM coverage_runs - WHERE project_id = ?1 AND branch = ?2 + WHERE project_id = ?1 AND branch = ?2 AND category = ?4 ) WHERE rn = 1 @@ -344,11 +350,72 @@ export async function getCoverageTrend( ) ORDER BY recorded_at ASC`, ) - .bind(projectId, branch, limit) + .bind(projectId, branch, limit, category) .all(); return results; } +export interface CoverageTrendPointWithCategory extends CoverageTrendPoint { + category: string; +} + +/** + * Like getCoverageTrend, but returns every category's series in one query, + * each capped independently at `limit` (not a flat limit split across + * categories). Doubles as category discovery — whichever categories have + * data for this project/branch simply appear in the result. + */ +export async function getCoverageTrendGrouped( + db: D1Database, + projectId: number, + branch: string, + limit: number, +): Promise { + const { results } = await db + .prepare( + `SELECT category, commit_sha, recorded_at, + line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability + FROM ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY category ORDER BY recorded_at DESC + ) AS rn2 + FROM ( + SELECT cd.category AS category, 'aggregated' AS commit_sha, cd.day AS recorded_at, + cd.line_coverage, cd.branch_coverage, cd.cyclomatic, cd.cognitive, cd.duplication_pct, cd.maintainability + FROM coverage_daily cd + WHERE cd.project_id = ?1 + AND cd.day NOT IN ( + SELECT DISTINCT strftime('%Y-%m-%d', ran_at, 'unixepoch') + FROM coverage_runs + WHERE project_id = ?1 AND branch = ?2 AND category = cd.category + ) + + UNION ALL + + SELECT category, commit_sha, + strftime('%Y-%m-%d', ran_at, 'unixepoch') AS recorded_at, + line_coverage, branch_coverage, cyclomatic, cognitive, duplication_pct, maintainability + FROM ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY category, strftime('%Y-%m-%d', ran_at, 'unixepoch') + ORDER BY ran_at DESC + ) AS rn + FROM coverage_runs + WHERE project_id = ?1 AND branch = ?2 + ) + WHERE rn = 1 + ) + ) + WHERE rn2 <= ?3 + ORDER BY category ASC, recorded_at ASC`, + ) + .bind(projectId, branch, limit) + .all(); + return results; +} + /** Extract the right numeric value from a coverage trend point by column name. */ export function pickColumnValue(point: CoverageTrendPoint, column: CoverageColumn): number | null { const v = point[column]; diff --git a/src/routes/api.ts b/src/routes/api.ts index ed9cb0c..39d6040 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -1,6 +1,12 @@ import { Hono } from 'hono'; import { requireAccess } from '../middleware/access'; -import { listProjectsWithOwners, getProjectBySlug, getCoverageTrend, pickColumnValue } from '../lib/db'; +import { + listProjectsWithOwners, + getProjectBySlug, + getCoverageTrend, + getCoverageTrendGrouped, + pickColumnValue, +} from '../lib/db'; import { metricToColumn } from '../lib/metrics'; import type { Bindings, Variables } from '../types'; @@ -26,7 +32,8 @@ api.get('/projects/:owner/:repo/metrics', requireAccess(), async (c) => { const mapping = metricToColumn(metric); if (!mapping) return c.json({ error: `Unknown metric: ${metric}` }, 400); - const points = await getCoverageTrend(c.env.DB, project.id, branch, limit); + const category = c.req.query('category') ?? 'default'; + const points = await getCoverageTrend(c.env.DB, project.id, branch, limit, category); const data = points .map((p) => { const value = pickColumnValue(p, mapping.column); @@ -43,4 +50,35 @@ api.get('/projects/:owner/:repo/metrics', requireAccess(), async (c) => { return c.json({ project: fullSlug, branch, metric, data }); }); +/** Trend data for one repo+branch+metric, grouped by category. Access-gated. */ +api.get('/projects/:owner/:repo/metrics/categories', requireAccess(), async (c) => { + const fullSlug = `${c.req.param('owner')}/${c.req.param('repo')}`; + const project = await getProjectBySlug(c.env.DB, fullSlug); + if (!project) return c.json({ error: 'Not found' }, 404); + + const metric = c.req.query('metric') ?? 'coverage'; + const branch = c.req.query('branch') ?? project.default_branch; + if (branch.length > 255) return c.json({ error: 'Invalid branch' }, 400); + const limit = Math.min(Number(c.req.query('limit') ?? '100'), 1000); + + const mapping = metricToColumn(metric); + if (!mapping) return c.json({ error: `Unknown metric: ${metric}` }, 400); + + const points = await getCoverageTrendGrouped(c.env.DB, project.id, branch, limit); + const byCategory = new Map>(); + for (const p of points) { + const value = pickColumnValue(p, mapping.column); + if (value === null) continue; + const arr = byCategory.get(p.category) ?? []; + arr.push({ commit_sha: p.commit_sha, value, unit: mapping.unit, recorded_at: p.recorded_at }); + byCategory.set(p.category, arr); + } + + const categories = [...byCategory.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([category, data]) => ({ category, data })); + + return c.json({ project: fullSlug, branch, metric, categories }); +}); + export default api; diff --git a/src/routes/baseline.ts b/src/routes/baseline.ts index 87f6b74..bc45b3c 100644 --- a/src/routes/baseline.ts +++ b/src/routes/baseline.ts @@ -31,7 +31,8 @@ baseline.get('/:owner/:repo', requireOidc(), async (c) => { const mapping = metricToColumn(metricName); if (!mapping) return c.json({ error: `Unknown metric: ${metricName}` }, 400); - const run = await getLatestCoverage(c.env.DB, project.id, branch); + const category = c.req.query('category') ?? 'default'; + const run = await getLatestCoverage(c.env.DB, project.id, branch, category); if (!run) return c.json({ error: 'No data for this metric/branch' }, 404); const value = pickMetricValue(run, mapping.column); diff --git a/src/routes/ci.ts b/src/routes/ci.ts index e7acecc..633010c 100644 --- a/src/routes/ci.ts +++ b/src/routes/ci.ts @@ -6,7 +6,10 @@ import type { Bindings, Variables } from '../types'; const ci = new Hono<{ Bindings: Bindings; Variables: Variables }>(); +const CATEGORY_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; + const CoverageReport = z.object({ + category: z.string().regex(CATEGORY_RE, 'category must be alphanumeric with hyphens/underscores, max 64 chars').optional(), line_coverage: z.number().min(0).max(100), branch_coverage: z.number().min(0).max(100).optional(), cyclomatic: z.number().min(0).optional(), @@ -46,6 +49,7 @@ ci.post('/coverage', requireOidc(), async (c) => { const data = result.data; await upsertCoverageRun(c.env.DB, project.id, sha, branch, Math.floor(Date.now() / 1000), { + category: data.category ?? 'default', line_coverage: data.line_coverage, branch_coverage: data.branch_coverage ?? null, cyclomatic: data.cyclomatic ?? null, diff --git a/src/types.ts b/src/types.ts index ab9e6b7..9f6361f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,7 @@ export interface CoverageRun { project_id: number; commit_sha: string; branch: string; + category: string; ran_at: number; line_coverage: number; branch_coverage: number | null; @@ -97,6 +98,7 @@ export interface CoverageRun { export interface CoverageDaily { project_id: number; + category: string; day: string; line_coverage: number; branch_coverage: number | null; diff --git a/test/api.test.ts b/test/api.test.ts index 860c674..911d716 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -114,4 +114,44 @@ describe('GET /api/projects/:owner/:repo/metrics', () => { const body = await res.json() as { data: unknown[] }; expect(body.data).toHaveLength(1); }); + + it('only returns rows for an explicitly requested category', async () => { + const now = Math.floor(Date.now() / 1000); + await testEnv.DB.prepare( + `INSERT INTO coverage_runs (project_id, commit_sha, branch, category, ran_at, line_coverage) + VALUES (1, 'sha-be', 'main', 'backend', ?1, 90), + (1, 'sha-fe', 'main', 'frontend', ?1, 30)`, + ).bind(now).run(); + + const res = await get('/api/projects/testorg/repo/metrics?category=frontend'); + const body = await res.json() as { data: Array<{ value: number }> }; + expect(body.data).toHaveLength(1); + expect(body.data[0].value).toBe(30); + }); +}); + +describe('GET /api/projects/:owner/:repo/metrics/categories', () => { + it('returns 404 for an unregistered project', async () => { + const res = await get('/api/projects/nobody/nothing/metrics/categories'); + expect(res.status).toBe(404); + }); + + it('groups trend data by category with independent latest values', async () => { + const now = Math.floor(Date.now() / 1000); + await testEnv.DB.prepare( + `INSERT INTO coverage_runs (project_id, commit_sha, branch, category, ran_at, line_coverage) + VALUES (1, 'sha-be', 'main', 'backend', ?1, 92), + (1, 'sha-fe', 'main', 'frontend', ?1, 55)`, + ).bind(now).run(); + + const res = await get('/api/projects/testorg/repo/metrics/categories'); + expect(res.status).toBe(200); + const body = await res.json() as { + categories: Array<{ category: string; data: Array<{ value: number }> }>; + }; + + expect(body.categories.map((c) => c.category)).toEqual(['backend', 'frontend']); + expect(body.categories.find((c) => c.category === 'backend')!.data[0].value).toBe(92); + expect(body.categories.find((c) => c.category === 'frontend')!.data[0].value).toBe(55); + }); }); diff --git a/test/ci.test.ts b/test/ci.test.ts index 4e92dac..26dc452 100644 --- a/test/ci.test.ts +++ b/test/ci.test.ts @@ -124,4 +124,42 @@ describe('POST /api/ci/coverage', () => { expect(results).toHaveLength(1); expect(results[0].line_coverage).toBe(99); }); + + it('persists a category field and defaults to "default" when omitted', async () => { + const token = await signOidcJwt({ sha: 'e'.repeat(40) }); + const res = await fetchCI({ line_coverage: 92.5, category: 'frontend' }, token); + expect(res.status).toBe(202); + + const row = await getLatestCoverageRun(testEnv.DB, 1, 'main', 'frontend'); + expect(row).not.toBeNull(); + expect(row!.category).toBe('frontend'); + + const defaultRow = await getLatestCoverageRun(testEnv.DB, 1, 'main', 'default'); + expect(defaultRow).toBeNull(); + }); + + it('same commit under two categories creates two rows, not one', async () => { + const sha = 'f'.repeat(40); + const backendToken = await signOidcJwt({ sha }); + const frontendToken = await signOidcJwt({ sha }); + + await fetchCI({ line_coverage: 90, category: 'backend' }, backendToken); + await fetchCI({ line_coverage: 40, category: 'frontend' }, frontendToken); + + const { results } = await testEnv.DB.prepare( + 'SELECT category, line_coverage FROM coverage_runs WHERE project_id = 1 AND commit_sha = ? ORDER BY category', + ) + .bind(sha) + .all<{ category: string; line_coverage: number }>(); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ category: 'backend', line_coverage: 90 }); + expect(results[1]).toMatchObject({ category: 'frontend', line_coverage: 40 }); + }); + + it('rejects an invalid category with 422', async () => { + const token = await signOidcJwt({}); + const res = await fetchCI({ line_coverage: 90, category: 'Not Valid!' }, token); + expect(res.status).toBe(422); + }); }); diff --git a/test/db.test.ts b/test/db.test.ts index 093411e..c7cf9ca 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -3,6 +3,7 @@ import { env } from 'cloudflare:test'; import { upsertCoverageRun, getCoverageTrend, + getCoverageTrendGrouped, getLatestCoverage, insertMetric, getMetricsTrend, @@ -67,6 +68,83 @@ describe('getCoverageTrend — most-recent-N ordering', () => { }); }); +describe('upsertCoverageRun — category isolation', () => { + it('same commit_sha under two categories creates two rows, not a collision', async () => { + await upsertCoverageRun(testEnv.DB, 1, 'sha-shared', 'main', NOW, { + category: 'backend', + line_coverage: 70, + }); + await upsertCoverageRun(testEnv.DB, 1, 'sha-shared', 'main', NOW, { + category: 'frontend', + line_coverage: 40, + }); + + const { results } = await testEnv.DB.prepare( + `SELECT category, line_coverage FROM coverage_runs WHERE project_id = 1 AND commit_sha = 'sha-shared' ORDER BY category`, + ).all<{ category: string; line_coverage: number }>(); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ category: 'backend', line_coverage: 70 }); + expect(results[1]).toMatchObject({ category: 'frontend', line_coverage: 40 }); + }); + + it('omitting category defaults to "default"', async () => { + await upsertCoverageRun(testEnv.DB, 1, 'sha-nocat', 'main', NOW, { line_coverage: 55 }); + + const row = await testEnv.DB.prepare( + `SELECT category FROM coverage_runs WHERE project_id = 1 AND commit_sha = 'sha-nocat'`, + ).first<{ category: string }>(); + + expect(row?.category).toBe('default'); + }); +}); + +describe('getCoverageTrend — category filter', () => { + it('only returns rows for the requested category', async () => { + await upsertCoverageRun(testEnv.DB, 1, 'sha-be', 'main', NOW, { + category: 'backend', + line_coverage: 90, + }); + await upsertCoverageRun(testEnv.DB, 1, 'sha-fe', 'main', NOW, { + category: 'frontend', + line_coverage: 30, + }); + + const backendTrend = await getCoverageTrend(testEnv.DB, 1, 'main', 20, 'backend'); + expect(backendTrend).toHaveLength(1); + expect(backendTrend[0].line_coverage).toBe(90); + + const frontendTrend = await getCoverageTrend(testEnv.DB, 1, 'main', 20, 'frontend'); + expect(frontendTrend).toHaveLength(1); + expect(frontendTrend[0].line_coverage).toBe(30); + }); +}); + +describe('getCoverageTrendGrouped', () => { + it('returns every category with data, each capped at limit independently', async () => { + for (let i = 0; i < 5; i++) { + await upsertCoverageRun(testEnv.DB, 1, `sha-be-${i}`, 'main', NOW - (4 - i) * DAY, { + category: 'backend', + line_coverage: 80 + i, + }); + } + await upsertCoverageRun(testEnv.DB, 1, 'sha-fe-0', 'main', NOW, { + category: 'frontend', + line_coverage: 20, + }); + + const grouped = await getCoverageTrendGrouped(testEnv.DB, 1, 'main', 3); + + const backend = grouped.filter((r) => r.category === 'backend'); + const frontend = grouped.filter((r) => r.category === 'frontend'); + + expect(backend).toHaveLength(3); + expect(backend.at(-1)!.line_coverage).toBe(84); // most recent backend row + expect(frontend).toHaveLength(1); + expect(frontend[0].line_coverage).toBe(20); + }); +}); + describe('getLatestCoverage — coverage_daily fallback', () => { it('returns null when both tables are empty', async () => { const result = await getLatestCoverage(testEnv.DB, 1, 'main'); diff --git a/test/rollup.test.ts b/test/rollup.test.ts index a28eba9..6b9d595 100644 --- a/test/rollup.test.ts +++ b/test/rollup.test.ts @@ -61,4 +61,22 @@ describe('rollupAndPrune', () => { ).first<{ cnt: number }>(); expect(daily!.cnt).toBe(1); }); + + it('snapshots two categories aged out on the same day into separate daily rows', async () => { + const old = Math.floor(Date.now() / 1000) - 15 * 86400; + await testEnv.DB.prepare( + `INSERT INTO coverage_runs (project_id, commit_sha, branch, category, ran_at, line_coverage) + VALUES (1, 'sha-be', 'main', 'backend', ?1, 80.0), + (1, 'sha-fe', 'main', 'frontend', ?1, 40.0)`, + ).bind(old).run(); + + await rollupAndPrune(testEnv); + + const daily = await testEnv.DB.prepare( + `SELECT category, line_coverage FROM coverage_daily WHERE project_id = 1 ORDER BY category`, + ).all<{ category: string; line_coverage: number }>(); + expect(daily.results).toHaveLength(2); + expect(daily.results[0]).toMatchObject({ category: 'backend', line_coverage: 80.0 }); + expect(daily.results[1]).toMatchObject({ category: 'frontend', line_coverage: 40.0 }); + }); }); diff --git a/test/seed-local.sql b/test/seed-local.sql index daa32f9..cc0a63f 100644 --- a/test/seed-local.sql +++ b/test/seed-local.sql @@ -27,3 +27,17 @@ VALUES (1, 'aaa0013', 'main', strftime('%s', 'now', '-2 days'), 97.5, 0.0), (1, 'aaa0014', 'main', strftime('%s', 'now', '-1 day'), 98.0, 0.0), (1, 'aaa0015', 'main', strftime('%s', 'now'), 98.1, 0.0); + +-- Second category ("frontend") demonstrating multiple stacked report series +-- per project. The rows above have no explicit category and default to +-- 'default'; these are tagged separately so the dashboard renders two +-- stacked charts under the Coverage tab. +INSERT OR IGNORE INTO coverage_runs + (project_id, commit_sha, branch, category, ran_at, line_coverage, duplication_pct) +VALUES + (1, 'bbb0001', 'main', 'frontend', strftime('%s', 'now', '-14 days'), 62.0, 3.5), + (1, 'bbb0002', 'main', 'frontend', strftime('%s', 'now', '-11 days'), 65.4, 3.2), + (1, 'bbb0003', 'main', 'frontend', strftime('%s', 'now', '-8 days'), 68.9, 2.8), + (1, 'bbb0004', 'main', 'frontend', strftime('%s', 'now', '-5 days'), 71.2, 2.1), + (1, 'bbb0005', 'main', 'frontend', strftime('%s', 'now', '-2 days'), 73.6, 1.9), + (1, 'bbb0006', 'main', 'frontend', strftime('%s', 'now'), 75.0, 1.7);