Skip to content

Commit e6068aa

Browse files
authored
Merge pull request #13 from ZeroStash/refactor/combine-dashboard-and-api-routes
refactor: merge dashboard into Worker with typed coverage schema
2 parents d1981b7 + 777a8fd commit e6068aa

41 files changed

Lines changed: 17458 additions & 495 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/actions/report/dist/run.js

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24689,6 +24689,15 @@ function getOctokit(token, options, ...additionalPlugins) {
2468924689

2469024690
// src/run.ts
2469124691
var fs2 = __toESM(require("fs"));
24692+
var METRIC_TO_FIELD = {
24693+
coverage: "line_coverage",
24694+
branch_coverage: "branch_coverage",
24695+
complexity: "cyclomatic",
24696+
cyclomatic: "cyclomatic",
24697+
cognitive: "cognitive",
24698+
duplication: "duplication_pct",
24699+
maintainability: "maintainability"
24700+
};
2469224701
async function run() {
2469324702
const workerUrl = (process.env.WORKER_URL ?? "").replace(/\/$/, "");
2469424703
const metricsFile = process.env.METRICS_FILE ?? "";
@@ -24733,21 +24742,25 @@ async function run() {
2473324742
}
2473424743
}
2473524744
async function runIngest(workerUrl, oidcToken, metrics) {
24736-
const res = await fetch(`${workerUrl}/ingest`, {
24745+
const body = {};
24746+
for (const m of metrics) {
24747+
const field = METRIC_TO_FIELD[m.name];
24748+
if (field) body[field] = m.value;
24749+
}
24750+
const res = await fetch(`${workerUrl}/api/ci/coverage`, {
2473724751
method: "POST",
2473824752
headers: {
2473924753
Authorization: `Bearer ${oidcToken}`,
2474024754
"Content-Type": "application/json"
2474124755
},
24742-
body: JSON.stringify({ metrics })
24756+
body: JSON.stringify(body)
2474324757
});
2474424758
if (!res.ok) {
24745-
const body = await res.text();
24746-
setFailed(`Ingest failed (HTTP ${res.status}): ${body}`);
24759+
const text = await res.text();
24760+
setFailed(`Ingest failed (HTTP ${res.status}): ${text}`);
2474724761
return;
2474824762
}
24749-
const data = await res.json();
24750-
info(`Ingested ${data.inserted} metric(s).`);
24763+
info("Coverage report submitted.");
2475124764
}
2475224765
async function runPRCheck(workerUrl, oidcToken, metrics, owner, repo) {
2475324766
const minCoverage = parseThreshold(process.env.MIN_COVERAGE);
@@ -24756,11 +24769,15 @@ async function runPRCheck(workerUrl, oidcToken, metrics, owner, repo) {
2475624769
const maxDuplication = parseThreshold(process.env.MAX_DUPLICATION);
2475724770
const baselines = {};
2475824771
for (const m of metrics) {
24759-
const url = `${workerUrl}/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
24772+
const url = `${workerUrl}/api/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
2476024773
const res = await fetch(url, { headers: { Authorization: `Bearer ${oidcToken}` } });
2476124774
if (res.ok) {
24762-
const data = await res.json();
24763-
baselines[m.name] = data.value;
24775+
try {
24776+
const data = await res.json();
24777+
baselines[m.name] = data.value;
24778+
} catch {
24779+
warning(`Baseline fetch for "${m.name}" returned non-JSON body (HTTP ${res.status}) \u2014 skipping baseline.`);
24780+
}
2476424781
} else if (res.status !== 404) {
2476524782
warning(`Baseline fetch for "${m.name}" returned HTTP ${res.status}.`);
2476624783
}

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

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,10 @@ describe('run()', () => {
218218
expect(mockFetch).not.toHaveBeenCalled();
219219
});
220220

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

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

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

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

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

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

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ export interface ThresholdResult {
2727
reason: string;
2828
}
2929

30+
/** Maps metrics-file metric names to typed fields in the new /api/ci/coverage payload. */
31+
const METRIC_TO_FIELD: Record<string, string> = {
32+
coverage: 'line_coverage',
33+
branch_coverage: 'branch_coverage',
34+
complexity: 'cyclomatic',
35+
cyclomatic: 'cyclomatic',
36+
cognitive: 'cognitive',
37+
duplication: 'duplication_pct',
38+
maintainability: 'maintainability',
39+
};
40+
3041
export async function run(): Promise<void> {
3142
const workerUrl = (process.env.WORKER_URL ?? '').replace(/\/$/, '');
3243
const metricsFile = process.env.METRICS_FILE ?? '';
@@ -90,24 +101,29 @@ export async function run(): Promise<void> {
90101
// ── Push path: ingest metrics ─────────────────────────────────────────────
91102

92103
export async function runIngest(workerUrl: string, oidcToken: string, metrics: Metric[]): Promise<void> {
93-
// Body carries only metric values; repo/branch/commit come from the OIDC token (A3)
94-
const res = await fetch(`${workerUrl}/ingest`, {
104+
// Map legacy metrics array to typed coverage fields
105+
const body: Record<string, number> = {};
106+
for (const m of metrics) {
107+
const field = METRIC_TO_FIELD[m.name];
108+
if (field) body[field] = m.value;
109+
}
110+
111+
const res = await fetch(`${workerUrl}/api/ci/coverage`, {
95112
method: 'POST',
96113
headers: {
97114
Authorization: `Bearer ${oidcToken}`,
98115
'Content-Type': 'application/json',
99116
},
100-
body: JSON.stringify({ metrics }),
117+
body: JSON.stringify(body),
101118
});
102119

103120
if (!res.ok) {
104-
const body = await res.text();
105-
core.setFailed(`Ingest failed (HTTP ${res.status}): ${body}`);
121+
const text = await res.text();
122+
core.setFailed(`Ingest failed (HTTP ${res.status}): ${text}`);
106123
return;
107124
}
108125

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

113129
// ── PR path: fetch baselines, check thresholds, post Check Run ─────────────
@@ -127,11 +143,15 @@ export async function runPRCheck(
127143
// Fetch baselines for all collected metrics
128144
const baselines: Record<string, number> = {};
129145
for (const m of metrics) {
130-
const url = `${workerUrl}/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
146+
const url = `${workerUrl}/api/baseline/${owner}/${repo}?metric=${encodeURIComponent(m.name)}`;
131147
const res = await fetch(url, { headers: { Authorization: `Bearer ${oidcToken}` } });
132148
if (res.ok) {
133-
const data = (await res.json()) as BaselineResponse;
134-
baselines[m.name] = data.value;
149+
try {
150+
const data = (await res.json()) as BaselineResponse;
151+
baselines[m.name] = data.value;
152+
} catch {
153+
core.warning(`Baseline fetch for "${m.name}" returned non-JSON body (HTTP ${res.status}) — skipping baseline.`);
154+
}
135155
} else if (res.status !== 404) {
136156
core.warning(`Baseline fetch for "${m.name}" returned HTTP ${res.status}.`);
137157
}
Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
name: Deploy dashboard
1+
name: Deploy
22

33
on:
44
push:
55
branches: [main]
66
paths:
7+
- 'src/**'
78
- 'dashboard/**'
9+
- 'migrations/**'
10+
- 'wrangler.jsonc'
811
- '.github/workflows/deploy-dashboard.yml'
912
pull_request:
1013
paths:
14+
- 'src/**'
1115
- 'dashboard/**'
16+
- 'migrations/**'
17+
- 'wrangler.jsonc'
1218
- '.github/workflows/deploy-dashboard.yml'
1319

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

2733
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
2834
with:
2935
node-version: '22'
3036
cache: npm
31-
cache-dependency-path: dashboard/package-lock.json
37+
cache-dependency-path: |
38+
package-lock.json
39+
dashboard/package-lock.json
3240
33-
- name: Install dependencies
34-
working-directory: dashboard
41+
- name: Install root dependencies
3542
run: npm ci
3643

37-
- name: Install wrangler
38-
run: npm install -g wrangler@^4
44+
- name: Install dashboard dependencies
45+
working-directory: dashboard
46+
run: npm ci
3947

40-
- name: Build
48+
- name: Build dashboard
4149
working-directory: dashboard
4250
run: npm run build
4351

44-
- name: Deploy to Cloudflare Pages
45-
id: deploy
46-
working-directory: dashboard
47-
run: |
48-
OUTPUT=$(wrangler pages deploy \
49-
${{ github.ref != 'refs/heads/main' && format('--branch {0}', github.head_ref || github.ref_name) || '' }})
50-
echo "$OUTPUT"
51-
URL=$(echo "$OUTPUT" | grep -oP 'https://[^\s]+\.pages\.dev[^\s]*' | tail -1)
52-
echo "deployment-url=${URL}" >> "$GITHUB_OUTPUT"
52+
- name: Deploy Worker + assets
53+
if: github.ref == 'refs/heads/main'
54+
run: npx wrangler deploy
5355
env:
5456
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
5557
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

CLAUDE.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# CLAUDE.md
2+
3+
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.
4+
5+
## What this is
6+
7+
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.
8+
9+
## Architecture invariants
10+
11+
- **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.
12+
- **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`).
13+
- **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.
14+
- **Two coverage tables:**
15+
- `coverage_runs` — raw per-commit rows, **pruned** after `RETENTION_DAYS` (14). Upsert on `(project_id, commit_sha)`.
16+
- `coverage_daily`**permanent** last-of-day snapshots produced by the cron; the historical trend source. Survives the prune.
17+
- **Rollup is last-run-of-day**, not an average (`ROW_NUMBER() … ORDER BY ran_at DESC`). Idempotent: upsert + predicate delete, safe to re-run.
18+
- Stack: **Hono** (router), **jose** (JWT/JWKS), **zod** (validation). TypeScript only — no JS.
19+
20+
## Auth model (per route)
21+
22+
| Route | Edge (Cloudflare Access) | In-code |
23+
|---|---|---|
24+
| Dashboard SPA (`/`, `/dashboard*`) | **Access-protected** ||
25+
| `/api/health` | none | none (public) |
26+
| `/api/ci/coverage` | none | GitHub Actions **OIDC** (jose, JWKS) |
27+
| `/api/webhooks/github` | none | GitHub App **HMAC** (`X-Hub-Signature-256`) |
28+
| `/api/projects/*` | none | **Cloudflare Access JWT** (`Cf-Access-Jwt-Assertion`, verify `aud`) |
29+
30+
## Guardrails (do not violate)
31+
32+
- **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.
33+
- **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_*`.
34+
- **Don't hand-write the `Bindings`/`Env` type.** Run `wrangler types` after any `wrangler.jsonc` change.
35+
- **Don't use Workers Sites** (deprecated). Workers Static Assets only; requires Wrangler v4+.
36+
- **Don't make `coverage_daily` writes lossy on re-run.** All rollup writes are `ON CONFLICT … DO UPDATE`.
37+
- Don't widen retention or change rollup semantics without updating `RETENTION_DAYS` / the documented contract; both are single points of change.
38+
39+
## Commands
40+
41+
```bash
42+
# Dev
43+
npm run dev # wrangler dev (local assets + Worker)
44+
wrangler types # regenerate Bindings after config changes
45+
46+
# Database
47+
wrangler d1 migrations apply coverage --local
48+
wrangler d1 migrations apply coverage --remote
49+
wrangler d1 execute coverage --local --command "SELECT ..."
50+
51+
# Test (runs in the Workers runtime with real D1 bindings)
52+
npm test # @cloudflare/vitest-pool-workers
53+
54+
# Deploy
55+
wrangler deploy --dry-run # validate before shipping
56+
wrangler deploy
57+
wrangler tail # live logs
58+
59+
# Secrets (values never committed)
60+
wrangler secret put <NAME>
61+
```
62+
63+
## Conventions
64+
65+
- **DB access** goes through prepared statements with bound params — no string interpolation into SQL.
66+
- **Validation** at the edge of every write route via zod; invalid → `422` with issues.
67+
- **Auth failures:** missing credential → `401`, present-but-invalid → `403`.
68+
- **Logging:** structured `console.log`/`console.error`; observability is enabled — keep it that way.
69+
- **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.
70+
- **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.
71+
72+
## Gotchas
73+
74+
- `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.
75+
- 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.
76+
- D1 is single-threaded per database and bills on **rows scanned**. Keep `/api/projects/*` reads index-backed (`idx_runs_project_time`); avoid full scans.
77+
- 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.

0 commit comments

Comments
 (0)