import { repoDb } from "../scoped-db"; import { requestCredential } from "../token-scope"; import { loadRepo, repoPermission } from "../repos"; import { limitBrowse } from "../limits"; import { HttpError } from "../errors"; import { WORKFLOW_DIR } from "./spec"; const BADGE_PATH = /^\/([^/]+)\/([^/]+)\/actions\/workflows\/([^/]+\.ya?ml)\/badge\.svg$/; const CHAR_WIDTH = 6.6; const PADDING = 10; const PUBLIC_MAX_AGE = 60; const STATES: Record = { success: { text: "passing", color: "#2ea043" }, failure: { text: "failing", color: "#da3633" }, cancelled: { text: "cancelled", color: "#6e7681" }, skipped: { text: "skipped", color: "#6e7681" }, none: { text: "no status", color: "#6e7681" }, }; function escapeXml(value: string): string { return value.replace(/[<>&"']/g, (char) => `&#${char.charCodeAt(0)};`); } export function badgeSvg(label: string, state: string): string { const status = STATES[state] ?? STATES.none; const left = Math.round(label.length * CHAR_WIDTH + PADDING * 2); const right = Math.round(status.text.length * CHAR_WIDTH + PADDING * 2); const width = left + right; const text = (x: number, value: string) => `${value}${value}`; return ` ${escapeXml(label)}: ${status.text} ${text(left / 2, escapeXml(label))}${text(left + right / 2, status.text)} `; } /** * `///actions/workflows//badge.svg?branch=` — public repos are edge-cached briefly. Authorization * runs before the cache lookup and the cache key carries the repo's visibility and `updated_at`, so a badge cached * while the repo was public stops being served the moment it turns private (CI-A8). */ export async function handleBadge(request: Request, url: URL): Promise { const match = BADGE_PATH.exec(url.pathname); if (!match || request.method !== "GET") return null; try { await limitBrowse(null, request); } catch (error) { if (error instanceof HttpError) return new Response(error.message, { status: error.status }); throw error; } const repo = await loadRepo(decodeURIComponent(match[1]), decodeURIComponent(match[2])); const viewer = repo ? (await requestCredential(request, true)).viewer : null; if (!repo || (await repoPermission(repo, viewer)) === "none") return new Response("Not found", { status: 404 }); const isPublic = repo.visibility === "public"; const cache = isPublic ? await caches.open("gitfor-badges") : null; const cacheKey = cache ? new Request(`${url.origin}${url.pathname}?${url.searchParams}&v=${repo.visibility}&u=${repo.updated_at}`, { method: "GET", }) : null; if (cache && cacheKey) { const cached = await cache.match(cacheKey); if (cached) return cached; } const branch = url.searchParams.get("branch") ?? repo.default_branch; const event = url.searchParams.get("event"); const path = `${WORKFLOW_DIR}/${decodeURIComponent(match[3])}`; const latest = await repoDb(repo.id).first<{ conclusion: string | null; workflow_name: string }>( `SELECT conclusion, workflow_name FROM workflow_runs WHERE repo_id = ? AND workflow_path = ? AND ref = ? AND status = 'completed' ${event ? "AND event = ?" : ""} ORDER BY run_number DESC LIMIT 1`, repo.id, path, `refs/heads/${branch}`, ...(event ? [event] : []), ); const label = latest?.workflow_name ?? decodeURIComponent(match[3]).replace(/\.ya?ml$/, ""); const response = new Response(badgeSvg(label.slice(0, 60), latest?.conclusion ?? "none"), { headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": isPublic ? `public, max-age=${PUBLIC_MAX_AGE}` : "private, no-store", }, }); if (cache && cacheKey) await cache.put(cacheKey, response.clone()); return response; }