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
35 changes: 26 additions & 9 deletions .github/actions/report/dist/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -24689,6 +24689,15 @@ function getOctokit(token, options, ...additionalPlugins) {

// src/run.ts
var fs2 = __toESM(require("fs"));
var METRIC_TO_FIELD = {
coverage: "line_coverage",
branch_coverage: "branch_coverage",
complexity: "cyclomatic",
cyclomatic: "cyclomatic",
cognitive: "cognitive",
duplication: "duplication_pct",
maintainability: "maintainability"
};
async function run() {
const workerUrl = (process.env.WORKER_URL ?? "").replace(/\/$/, "");
const metricsFile = process.env.METRICS_FILE ?? "";
Expand Down Expand Up @@ -24733,21 +24742,25 @@ async function run() {
}
}
async function runIngest(workerUrl, oidcToken, metrics) {
const res = await fetch(`${workerUrl}/ingest`, {
const body = {};
for (const m of metrics) {
const field = METRIC_TO_FIELD[m.name];
if (field) body[field] = m.value;
}
const res = await fetch(`${workerUrl}/api/ci/coverage`, {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ metrics })
body: JSON.stringify(body)
});
if (!res.ok) {
const body = await res.text();
setFailed(`Ingest failed (HTTP ${res.status}): ${body}`);
const text = await res.text();
setFailed(`Ingest failed (HTTP ${res.status}): ${text}`);
return;
}
const data = await res.json();
info(`Ingested ${data.inserted} metric(s).`);
info("Coverage report submitted.");
}
async function runPRCheck(workerUrl, oidcToken, metrics, owner, repo) {
const minCoverage = parseThreshold(process.env.MIN_COVERAGE);
Expand All @@ -24756,11 +24769,15 @@ async function runPRCheck(workerUrl, oidcToken, metrics, owner, repo) {
const maxDuplication = parseThreshold(process.env.MAX_DUPLICATION);
const baselines = {};
for (const m of metrics) {
const url = `${workerUrl}/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
const url = `${workerUrl}/api/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${oidcToken}` } });
if (res.ok) {
const data = await res.json();
baselines[m.name] = data.value;
try {
const data = await res.json();
baselines[m.name] = data.value;
} catch {
warning(`Baseline fetch for "${m.name}" returned non-JSON body (HTTP ${res.status}) \u2014 skipping baseline.`);
}
} else if (res.status !== 404) {
warning(`Baseline fetch for "${m.name}" returned HTTP ${res.status}.`);
}
Expand Down
19 changes: 10 additions & 9 deletions .github/actions/report/src/__tests__/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,10 @@ describe('run()', () => {
expect(mockFetch).not.toHaveBeenCalled();
});

it('calls /ingest on a push to the default branch', async () => {
it('calls /api/ci/coverage on a push to the default branch', async () => {
await run();
expect(mockFetch).toHaveBeenCalledWith(
'https://worker.example.com/ingest',
'https://worker.example.com/api/ci/coverage',
expect.objectContaining({ method: 'POST' }),
);
});
Expand All @@ -248,10 +248,10 @@ describe('runIngest()', () => {

afterEach(() => vi.clearAllMocks());

it('calls core.info with the inserted count on success', async () => {
mockFetch.mockResolvedValue(okFetchResponse({ ok: true, inserted: 2 }));
it('calls core.info with a success message on 2xx', async () => {
mockFetch.mockResolvedValue(okFetchResponse({}));
await runIngest('https://worker.example.com', 'mock-token', metrics);
expect(vi.mocked(core.info)).toHaveBeenCalledWith('Ingested 2 metric(s).');
expect(vi.mocked(core.info)).toHaveBeenCalledWith('Coverage report submitted.');
});

it('calls core.setFailed on a non-OK HTTP response', async () => {
Expand All @@ -262,14 +262,15 @@ describe('runIngest()', () => {
);
});

it('sends repo/branch/commit only via the OIDC token (body has only metrics)', async () => {
mockFetch.mockResolvedValue(okFetchResponse({ ok: true, inserted: 2 }));
it('sends typed coverage fields (not a metrics array) in the request body', async () => {
mockFetch.mockResolvedValue(okFetchResponse({}));
await runIngest('https://worker.example.com', 'mock-token', metrics);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body).toEqual({ metrics });
// coverage → line_coverage, duplication → duplication_pct
expect(body).toEqual({ line_coverage: 85, duplication_pct: 0 });
expect(body).not.toHaveProperty('metrics');
expect(body).not.toHaveProperty('repository');
expect(body).not.toHaveProperty('branch');
});
});

Expand Down
40 changes: 30 additions & 10 deletions .github/actions/report/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ export interface ThresholdResult {
reason: string;
}

/** Maps metrics-file metric names to typed fields in the new /api/ci/coverage payload. */
const METRIC_TO_FIELD: Record<string, string> = {
coverage: 'line_coverage',
branch_coverage: 'branch_coverage',
complexity: 'cyclomatic',
cyclomatic: 'cyclomatic',
cognitive: 'cognitive',
duplication: 'duplication_pct',
maintainability: 'maintainability',
};

export async function run(): Promise<void> {
const workerUrl = (process.env.WORKER_URL ?? '').replace(/\/$/, '');
const metricsFile = process.env.METRICS_FILE ?? '';
Expand Down Expand Up @@ -90,24 +101,29 @@ export async function run(): Promise<void> {
// ── Push path: ingest metrics ─────────────────────────────────────────────

export async function runIngest(workerUrl: string, oidcToken: string, metrics: Metric[]): Promise<void> {
// Body carries only metric values; repo/branch/commit come from the OIDC token (A3)
const res = await fetch(`${workerUrl}/ingest`, {
// Map legacy metrics array to typed coverage fields
const body: Record<string, number> = {};
for (const m of metrics) {
const field = METRIC_TO_FIELD[m.name];
if (field) body[field] = m.value;
}

const res = await fetch(`${workerUrl}/api/ci/coverage`, {
method: 'POST',
headers: {
Authorization: `Bearer ${oidcToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ metrics }),
body: JSON.stringify(body),
});

if (!res.ok) {
const body = await res.text();
core.setFailed(`Ingest failed (HTTP ${res.status}): ${body}`);
const text = await res.text();
core.setFailed(`Ingest failed (HTTP ${res.status}): ${text}`);
return;
}

const data = (await res.json()) as { ok: boolean; inserted: number };
core.info(`Ingested ${data.inserted} metric(s).`);
core.info('Coverage report submitted.');
}

// ── PR path: fetch baselines, check thresholds, post Check Run ─────────────
Expand All @@ -127,11 +143,15 @@ export async function runPRCheck(
// Fetch baselines for all collected metrics
const baselines: Record<string, number> = {};
for (const m of metrics) {
const url = `${workerUrl}/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
const url = `${workerUrl}/api/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${oidcToken}` } });
if (res.ok) {
const data = (await res.json()) as BaselineResponse;
baselines[m.name] = data.value;
try {
const data = (await res.json()) as BaselineResponse;
baselines[m.name] = data.value;
} catch {
core.warning(`Baseline fetch for "${m.name}" returned non-JSON body (HTTP ${res.status}) — skipping baseline.`);
}
} else if (res.status !== 404) {
core.warning(`Baseline fetch for "${m.name}" returned HTTP ${res.status}.`);
}
Expand Down
36 changes: 19 additions & 17 deletions .github/workflows/deploy-dashboard.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
name: Deploy dashboard
name: Deploy

on:
push:
branches: [main]
paths:
- 'src/**'
- 'dashboard/**'
- 'migrations/**'
- 'wrangler.jsonc'
- '.github/workflows/deploy-dashboard.yml'
pull_request:
paths:
- 'src/**'
- 'dashboard/**'
- 'migrations/**'
- 'wrangler.jsonc'
- '.github/workflows/deploy-dashboard.yml'

permissions:
Expand All @@ -20,36 +26,32 @@ jobs:
runs-on: ubuntu-latest
environment:
name: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }}
url: ${{ steps.deploy.outputs.deployment-url }}
url: https://coverage-tracker.zerostash.org
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: npm
cache-dependency-path: dashboard/package-lock.json
cache-dependency-path: |
package-lock.json
dashboard/package-lock.json

- name: Install dependencies
working-directory: dashboard
- name: Install root dependencies
run: npm ci

- name: Install wrangler
run: npm install -g wrangler@^4
- name: Install dashboard dependencies
working-directory: dashboard
run: npm ci

- name: Build
- name: Build dashboard
working-directory: dashboard
run: npm run build

- name: Deploy to Cloudflare Pages
id: deploy
working-directory: dashboard
run: |
OUTPUT=$(wrangler pages deploy \
${{ github.ref != 'refs/heads/main' && format('--branch {0}', github.head_ref || github.ref_name) || '' }})
echo "$OUTPUT"
URL=$(echo "$OUTPUT" | grep -oP 'https://[^\s]+\.pages\.dev[^\s]*' | tail -1)
echo "deployment-url=${URL}" >> "$GITHUB_OUTPUT"
- name: Deploy Worker + assets
if: github.ref == 'refs/heads/main'
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
77 changes: 77 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# CLAUDE.md

Operating manual for **coverage-tracker**. Read this first every session. For the one-time convergence refactor, see `coverage-tracker-convergence-plan.md`; this file is the durable spec that stays true after it lands.

## What this is

A self-hostable, open-source (MIT) code-quality dashboard. **One instance per deployer, not multi-tenant.** A single Cloudflare Worker serves the SPA dashboard *and* the API from one apex domain, backed by D1. CI jobs push coverage/complexity metrics; the dashboard reads trends.

## Architecture invariants

- **One Worker, one `wrangler.jsonc`.** Static assets (`assets.directory` → `dist/`) and API live in the same Worker. Do not reintroduce a separate Pages project or a second Worker for the API.
- **Routing:** `assets.run_worker_first = ["/api/*"]` — only `/api/*` hits the Worker first; everything else is asset-first with `not_found_handling = "single-page-application"` (SPA deep links serve `index.html`).
- **Single D1 database** (`DB` binding) holds all repos' data. Free-tier cap is **500 MB/database**; the real ceiling is the **write limit** (~100k rows/day), not bytes.
- **Two coverage tables:**
- `coverage_runs` — raw per-commit rows, **pruned** after `RETENTION_DAYS` (14). Upsert on `(project_id, commit_sha)`.
- `coverage_daily` — **permanent** last-of-day snapshots produced by the cron; the historical trend source. Survives the prune.
- **Rollup is last-run-of-day**, not an average (`ROW_NUMBER() … ORDER BY ran_at DESC`). Idempotent: upsert + predicate delete, safe to re-run.
- Stack: **Hono** (router), **jose** (JWT/JWKS), **zod** (validation). TypeScript only — no JS.

## Auth model (per route)

| Route | Edge (Cloudflare Access) | In-code |
|---|---|---|
| Dashboard SPA (`/`, `/dashboard*`) | **Access-protected** | — |
| `/api/health` | none | none (public) |
| `/api/ci/coverage` | none | GitHub Actions **OIDC** (jose, JWKS) |
| `/api/webhooks/github` | none | GitHub App **HMAC** (`X-Hub-Signature-256`) |
| `/api/projects/*` | none | **Cloudflare Access JWT** (`Cf-Access-Jwt-Assertion`, verify `aud`) |

## Guardrails (do not violate)

- **Never put a Cloudflare Access application on `/api/*`.** Machine callers (CI OIDC, webhooks, health) must reach the Worker unauthenticated at the edge. API auth is enforced in code. This is the single most important invariant.
- **Never commit secrets.** Values are set with `wrangler secret put`. Code and `wrangler.jsonc` may reference names only: `GITHUB_OIDC_AUDIENCE`, `GITHUB_WEBHOOK_SECRET`, `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD`, `GITHUB_APP_*`.
- **Don't hand-write the `Bindings`/`Env` type.** Run `wrangler types` after any `wrangler.jsonc` change.
- **Don't use Workers Sites** (deprecated). Workers Static Assets only; requires Wrangler v4+.
- **Don't make `coverage_daily` writes lossy on re-run.** All rollup writes are `ON CONFLICT … DO UPDATE`.
- Don't widen retention or change rollup semantics without updating `RETENTION_DAYS` / the documented contract; both are single points of change.

## Commands

```bash
# Dev
npm run dev # wrangler dev (local assets + Worker)
wrangler types # regenerate Bindings after config changes

# Database
wrangler d1 migrations apply coverage --local
wrangler d1 migrations apply coverage --remote
wrangler d1 execute coverage --local --command "SELECT ..."

# Test (runs in the Workers runtime with real D1 bindings)
npm test # @cloudflare/vitest-pool-workers

# Deploy
wrangler deploy --dry-run # validate before shipping
wrangler deploy
wrangler tail # live logs

# Secrets (values never committed)
wrangler secret put <NAME>
```

## Conventions

- **DB access** goes through prepared statements with bound params — no string interpolation into SQL.
- **Validation** at the edge of every write route via zod; invalid → `422` with issues.
- **Auth failures:** missing credential → `401`, present-but-invalid → `403`.
- **Logging:** structured `console.log`/`console.error`; observability is enabled — keep it that way.
- **The Hono catch-all** `app.all('*', c => c.env.ASSETS.fetch(c.req.raw))` must remain the last route so non-API requests reaching the Worker fall through to assets.
- **Tests:** confirm `nodejs_compat` is in `wrangler.jsonc` directly — the vitest pool injects it and can mask a missing flag that then fails at deploy.

## Gotchas

- `Cf-Access-Jwt-Assertion`'s `aud` is **per Access application**. If the dashboard is ever split across multiple Access apps, the `/api/projects/*` middleware must accept an array of audiences.
- The cron (`30 6 * * *` UTC) is the only thing that moves rows from `coverage_runs` to `coverage_daily`. If it stops firing, raw rows accumulate and the historical series stops advancing — check Observability if trends look stale.
- D1 is single-threaded per database and bills on **rows scanned**. Keep `/api/projects/*` reads index-backed (`idx_runs_project_time`); avoid full scans.
- A large `DELETE` in the prune is fine at current scale; if raw volume ever grows, batch deletes ~1,000 rows at a time to stay under execution limits.
Loading
Loading