Commit3648956unknown_key
feat(BLOCK-J): J10 repository status badges (shields.io-style SVG)
feat(BLOCK-J): J10 repository status badges (shields.io-style SVG) Embeddable badges served from repo origin — no third-party deps. - src/lib/badge.ts: zero-IO renderer. escapeXml, Verdana-11 width estimator, named colour table (green/red/yellow/blue/grey/orange) + hex passthrough, 64-char label/value clamp, shields.io flat style with title + aria-label + shadow/main text pairs. - src/routes/badges.ts: /:o/:r/badge/gates.svg (gate_runs rollup), /issues.svg, /prs.svg, /status.svg (combined commit status on HEAD), /status/:context.svg (single-context). Every handler fails safe to grey 'unknown' on DB/git error — never 500. - Cache-Control: public, max-age=60, stale-while-revalidate=300. - 21 new tests (escapeXml, width, colorForState, renderBadge, route smokes). Total: 806 passing.
5 files changed+558−036489563e5103761dfd3d57ac0137cad393af566
5 changed files+558−0
ModifiedBUILD_BIBLE.md+3−0View fileUnifiedSplit
@@ -92,6 +92,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
9292| Commit signature verification (Verified badge) | ✅ | J3 — GPG + SSH pubkey registration at `/settings/signing-keys`, `gpgsig` extraction from raw commit objects, OpenPGP packet walker for Issuer Fingerprint, SHA-256 fingerprints for SSHSIG pubkeys, memoised in `commit_verifications`. Green "Verified" badge rendered on commit list + detail when a registered key matches. `src/lib/signatures.ts` + `src/routes/signing-keys.tsx` + `drizzle/0030_signing_keys.sql`. |
9393| Repository rulesets (push policy engine) | ✅ | J6 — named policies group N rules at active/evaluate/disabled enforcement. Pure evaluator `evaluatePush(rulesets, ctx)` → `{allowed, violations}`. Six rule types: commit_message_pattern, branch_name_pattern, tag_name_pattern, blocked_file_paths, max_file_size, forbid_force_push. Glob-lite matcher (`*` = non-slash, `**` = anything). Owner-only CRUD at `/:owner/:repo/settings/rulesets`. `src/lib/rulesets.ts` + `src/routes/rulesets.tsx` + `drizzle/0032_repo_rulesets.sql`. |
9494| Commit status API (external CI signals) | ✅ | J8 — external systems POST per-commit (sha, context) statuses with state pending/success/failure/error. Combined rollup reduces to worst state. Public list + combined endpoints; write requires owner auth. Rendered on commit detail view as a pill row. `src/lib/commit-statuses.ts` + `src/routes/commit-statuses.ts` + `drizzle/0033_commit_statuses.sql`. |
95| Repo status badges (shields.io SVG) | ✅ | J10 — embeddable `image/svg+xml` badges repositories serve from their own origin: `/:o/:r/badge/gates.svg`, `/issues.svg`, `/prs.svg`, `/status.svg`, `/status/:context.svg`. Zero-IO Verdana-11 text width estimator, 64-char clamp, XML-escape, named + hex colours. Never 500s — falls back to grey "unknown" on DB/git failure. `public, max-age=60, stale-while-revalidate=300`. `src/lib/badge.ts` + `src/routes/badges.ts`. |
9596
9697### 2.3 Collaboration
9798| Feature | Status | Notes |
@@ -475,6 +476,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
475476- `src/lib/commit-statuses.ts` (Block J8) — pure helpers: `isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`. DB helpers: `setStatus` (delete-then-insert upsert on `(repo, sha, context)`, SHA lowercased, description/url clamped to 1000/2048 chars), `listStatuses` (newest first), `combinedStatus` (latest per context, reduces to worst state, returns counts + context pills). `STATUS_STATES` exported array. Never throws on clamping; null/empty description returns null.
476477- `src/routes/commit-statuses.ts` (Block J8) — `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth — accepts session/OAuth/PAT; owner-only), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (softAuth, private-repo visibility enforced), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup, same visibility rules).
477478- `src/lib/contribution-heatmap.ts` (Block J9) — pure heatmap builder. Exports `buildHeatmap(activities, windowDays=365, today?)` returning 53-week Sunday-aligned grid + rollup stats. Helpers: `levelFor(count, max)` (5-level quartile bucket), `formatDateKey` (UTC YYYY-MM-DD), `startOfUtcDay`, `daysBetween`. `__internal` re-exports for tests. Silently ignores invalid dates + activity outside the window.
479- `src/lib/badge.ts` (Block J10) — zero-IO SVG badge renderer. Exports `renderBadge({label, value, color, labelColor})`, `escapeXml`, `estimateTextWidth` (Verdana-11 per-char heuristic), `colorForState` (success/passed→green, pending→yellow, failure/failed/error→red, else grey). Named colour table (green/red/yellow/blue/grey/orange) + hex literals accepted. Label + value clamped to 64 chars each before rendering. Shields.io flat style with `<title>` + `aria-label` + shadow/main text pairs.
480- `src/routes/badges.ts` (Block J10) — serves `/:owner/:repo/badge/gates.svg` (latest 20 gate_runs rollup), `/issues.svg` (open count), `/prs.svg` (open count), `/status.svg` (combined commit status on default-branch HEAD), `/status/:context.svg` (single context on HEAD). Every handler wrapped in try/catch and returns a grey "unknown" SVG on DB or git failure — never 500. `image/svg+xml; charset=utf-8`, `Cache-Control: public, max-age=60, stale-while-revalidate=300`. `softAuth` so public-repo badges don't require cookies.
478481
479482### 4.7 Views (locked contracts)
480483- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Addedsrc/__tests__/badges.test.ts+156−0View fileUnifiedSplit
@@ -0,0 +1,156 @@
1/**
2 * Block J10 — Badge renderer tests + route smokes.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import {
8 colorForState,
9 escapeXml,
10 estimateTextWidth,
11 renderBadge,
12} from "../lib/badge";
13
14describe("badge — escapeXml", () => {
15 it("escapes the five standard chars", () => {
16 expect(escapeXml("<a & b > c \"d\" 'e'")).toBe(
17 "<a & b > c "d" 'e'"
18 );
19 });
20
21 it("returns empty for empty input", () => {
22 expect(escapeXml("")).toBe("");
23 });
24});
25
26describe("badge — estimateTextWidth", () => {
27 it("returns 0 for empty", () => {
28 expect(estimateTextWidth("")).toBe(0);
29 });
30
31 it("scales roughly linearly with string length", () => {
32 const a = estimateTextWidth("abcd");
33 const b = estimateTextWidth("abcdabcd");
34 expect(b).toBeGreaterThan(a);
35 expect(b).toBeLessThan(a * 2.5);
36 });
37
38 it("handles punctuation + digits without throwing", () => {
39 expect(() => estimateTextWidth("Hi! 12.3%")).not.toThrow();
40 });
41});
42
43describe("badge — colorForState", () => {
44 it("maps success / passed to green", () => {
45 expect(colorForState("success")).toBe("green");
46 expect(colorForState("passed")).toBe("green");
47 });
48
49 it("maps pending to yellow", () => {
50 expect(colorForState("pending")).toBe("yellow");
51 });
52
53 it("maps failure / failed / error to red", () => {
54 expect(colorForState("failure")).toBe("red");
55 expect(colorForState("failed")).toBe("red");
56 expect(colorForState("error")).toBe("red");
57 });
58
59 it("unknown → grey", () => {
60 expect(colorForState("unknown" as any)).toBe("grey");
61 });
62});
63
64describe("badge — renderBadge", () => {
65 it("emits well-formed SVG with label + value + title", () => {
66 const svg = renderBadge({ label: "build", value: "passing", color: "green" });
67 expect(svg.startsWith("<svg")).toBe(true);
68 expect(svg.includes("</svg>")).toBe(true);
69 expect(svg.includes("build")).toBe(true);
70 expect(svg.includes("passing")).toBe(true);
71 expect(svg.includes("<title>build: passing</title>")).toBe(true);
72 });
73
74 it("honours named colours", () => {
75 expect(renderBadge({ label: "x", value: "y", color: "red" })).toContain(
76 "#da3633"
77 );
78 expect(renderBadge({ label: "x", value: "y", color: "green" })).toContain(
79 "#2ea043"
80 );
81 });
82
83 it("falls back to grey for unknown colour", () => {
84 const svg = renderBadge({ label: "x", value: "y", color: "puce" });
85 expect(svg).toContain("#586069");
86 });
87
88 it("accepts hex colour literals", () => {
89 const svg = renderBadge({ label: "x", value: "y", color: "#abc" });
90 expect(svg).toContain("#abc");
91 });
92
93 it("escapes markup in label + value", () => {
94 const svg = renderBadge({ label: "<evil>", value: "\"v&v'", color: "blue" });
95 expect(svg).toContain("<evil>");
96 expect(svg).toContain(""v&v'");
97 expect(svg).not.toContain("<evil>");
98 });
99
100 it("clamps ridiculously long inputs to ≤64 chars each", () => {
101 const long = "z".repeat(200);
102 const svg = renderBadge({ label: long, value: long });
103 // "z" is not used in any SVG attribute / tag name, so every occurrence
104 // in the output comes from our label / value payloads. Each of label +
105 // value appears in: <title>, aria-label, shadow <text>, main <text> —
106 // so at most 64 chars × 2 payloads × 4 occurrences = 512. Unclamped a
107 // 200-char payload would produce well over 1000.
108 const matches = svg.match(/z/g) || [];
109 expect(matches.length).toBeLessThan(600);
110 expect(matches.length).toBeGreaterThan(0);
111 // Sanity: SVG should be well under the unclamped size.
112 expect(svg.length).toBeLessThan(2500);
113 });
114});
115
116describe("badge — routes", () => {
117 it("GET /:o/:r/badge/gates.svg returns SVG (even when repo missing)", async () => {
118 const res = await app.request("/alice/nope/badge/gates.svg");
119 expect(res.status).toBe(200);
120 expect(res.headers.get("content-type")).toContain("image/svg+xml");
121 const body = await res.text();
122 expect(body.startsWith("<svg")).toBe(true);
123 });
124
125 it("GET /:o/:r/badge/issues.svg returns SVG", async () => {
126 const res = await app.request("/alice/nope/badge/issues.svg");
127 expect(res.status).toBe(200);
128 expect(res.headers.get("content-type")).toContain("image/svg+xml");
129 });
130
131 it("GET /:o/:r/badge/prs.svg returns SVG", async () => {
132 const res = await app.request("/alice/nope/badge/prs.svg");
133 expect(res.status).toBe(200);
134 expect(res.headers.get("content-type")).toContain("image/svg+xml");
135 });
136
137 it("GET /:o/:r/badge/status.svg returns SVG", async () => {
138 const res = await app.request("/alice/nope/badge/status.svg");
139 expect(res.status).toBe(200);
140 expect(res.headers.get("content-type")).toContain("image/svg+xml");
141 });
142
143 it("GET /:o/:r/badge/status/:context.svg returns SVG", async () => {
144 const res = await app.request("/alice/nope/badge/status/ci.svg");
145 expect(res.status).toBe(200);
146 expect(res.headers.get("content-type")).toContain("image/svg+xml");
147 const body = await res.text();
148 expect(body).toContain("ci");
149 });
150
151 it("responses are Cache-Control aware", async () => {
152 const res = await app.request("/alice/nope/badge/gates.svg");
153 const cc = res.headers.get("cache-control") || "";
154 expect(cc).toContain("max-age");
155 });
156});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -75,6 +75,7 @@ import signingKeysRoutes from "./routes/signing-keys";
7575import followsRoutes from "./routes/follows";
7676import rulesetsRoutes from "./routes/rulesets";
7777import commitStatusesRoutes from "./routes/commit-statuses";
78import badgesRoutes from "./routes/badges";
7879import webRoutes from "./routes/web";
7980
8081const app = new Hono();
@@ -262,6 +263,9 @@ app.route("/", rulesetsRoutes);
262263// Commit status API — /api/v1/repos/:o/:r/statuses/:sha (Block J8)
263264app.route("/", commitStatusesRoutes);
264265
266// Shields.io-style SVG badges — /:owner/:repo/badge/*.svg (Block J10)
267app.route("/", badgesRoutes);
268
265269// Insights + milestones
266270app.route("/", insightsRoutes);
267271
Addedsrc/lib/badge.ts+126−0View fileUnifiedSplit
@@ -0,0 +1,126 @@
1/**
2 * Block J10 — SVG status badges.
3 *
4 * Render a two-segment "label | value" shields.io-style badge as an inline
5 * SVG string. Pure, deterministic, zero IO. Keep this file dependency-free
6 * so it can be reused server- or client-side in the future.
7 */
8
9export type BadgeColor =
10 | "green"
11 | "red"
12 | "yellow"
13 | "blue"
14 | "grey"
15 | "orange"
16 | string;
17
18export interface BadgeInput {
19 label: string;
20 value: string;
21 color?: BadgeColor;
22 labelColor?: string;
23}
24
25const NAMED_COLORS: Record<string, string> = {
26 green: "#2ea043",
27 red: "#da3633",
28 yellow: "#d29922",
29 blue: "#1f6feb",
30 grey: "#586069",
31 gray: "#586069",
32 orange: "#db6d28",
33};
34
35function resolveColor(c: BadgeColor | undefined, fallback: string): string {
36 if (!c) return fallback;
37 if (NAMED_COLORS[c]) return NAMED_COLORS[c];
38 if (/^#[0-9a-fA-F]{3,8}$/.test(c)) return c;
39 return fallback;
40}
41
42/** XML/HTML-safe escape (&, <, >, ", '). */
43export function escapeXml(s: string): string {
44 return s
45 .replace(/&/g, "&")
46 .replace(/</g, "<")
47 .replace(/>/g, ">")
48 .replace(/"/g, """)
49 .replace(/'/g, "'");
50}
51
52/**
53 * Rough Verdana-11 width estimator. Good enough for badges without shipping a
54 * full font-metrics table. Based on average char widths observed in shields
55 * SVGs (~6.5px per char for small DejaVu / Verdana, tighter for narrow chars).
56 */
57export function estimateTextWidth(s: string): number {
58 let w = 0;
59 for (const ch of s) {
60 if ("iIl.,:;|!'".includes(ch)) w += 3.5;
61 else if ("fjrt".includes(ch)) w += 4.5;
62 else if (ch >= "A" && ch <= "Z") w += 7.5;
63 else if (ch >= "0" && ch <= "9") w += 6.5;
64 else if (ch === " ") w += 3.5;
65 else w += 6.2;
66 }
67 return Math.round(w);
68}
69
70/** Build the SVG string for a two-segment badge. */
71export function renderBadge(input: BadgeInput): string {
72 const label = String(input.label || "").slice(0, 64);
73 const value = String(input.value || "").slice(0, 64);
74 const valueColor = resolveColor(input.color, NAMED_COLORS.grey);
75 const labelColor = resolveColor(input.labelColor, "#555");
76
77 const padding = 10;
78 const labelWidth = estimateTextWidth(label) + padding * 2;
79 const valueWidth = estimateTextWidth(value) + padding * 2;
80 const totalWidth = labelWidth + valueWidth;
81 const height = 20;
82
83 const labelEsc = escapeXml(label);
84 const valueEsc = escapeXml(value);
85 const title = escapeXml(`${label}: ${value}`);
86
87 // Matches shields.io's flat style closely enough to blend into READMEs.
88 return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${title}">
89<title>${title}</title>
90<linearGradient id="s" x2="0" y2="100%">
91<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
92<stop offset="1" stop-opacity=".1"/>
93</linearGradient>
94<clipPath id="r"><rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/></clipPath>
95<g clip-path="url(#r)">
96<rect width="${labelWidth}" height="${height}" fill="${labelColor}"/>
97<rect x="${labelWidth}" width="${valueWidth}" height="${height}" fill="${valueColor}"/>
98<rect width="${totalWidth}" height="${height}" fill="url(#s)"/>
99</g>
100<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
101<text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${labelEsc}</text>
102<text x="${labelWidth / 2}" y="14">${labelEsc}</text>
103<text x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${valueEsc}</text>
104<text x="${labelWidth + valueWidth / 2}" y="14">${valueEsc}</text>
105</g>
106</svg>`;
107}
108
109/** Pick a colour from a (state, source) pair. Used by route handlers. */
110export function colorForState(
111 state: "success" | "passed" | "pending" | "failure" | "failed" | "error" | "unknown"
112): BadgeColor {
113 switch (state) {
114 case "success":
115 case "passed":
116 return "green";
117 case "pending":
118 return "yellow";
119 case "failure":
120 case "failed":
121 case "error":
122 return "red";
123 default:
124 return "grey";
125 }
126}
Addedsrc/routes/badges.ts+269−0View fileUnifiedSplit
@@ -0,0 +1,269 @@
1/**
2 * Block J10 — Repository status badges.
3 *
4 * Serves shields.io-style SVG badges repositories can embed in READMEs:
5 *
6 * GET /:owner/:repo/badge/gates.svg — latest gate_runs rollup
7 * GET /:owner/:repo/badge/issues.svg — open issue count
8 * GET /:owner/:repo/badge/prs.svg — open PR count
9 * GET /:owner/:repo/badge/status.svg — combined commit status on HEAD
10 * GET /:owner/:repo/badge/status/:context.svg — single status context
11 *
12 * All responses: image/svg+xml, public short-cache, never 500s — badges fall
13 * back to a grey "unknown" state on DB or git failure.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq, sql } from "drizzle-orm";
18import { db } from "../db";
19import {
20 repositories,
21 users,
22 gateRuns,
23 issues,
24 pullRequests,
25 commitStatuses,
26} from "../db/schema";
27import { softAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { renderBadge, colorForState } from "../lib/badge";
30import { getDefaultBranch } from "../git/repository";
31import { resolveRef } from "../git/repository";
32
33const badges = new Hono<AuthEnv>();
34
35const CACHE = "public, max-age=60, stale-while-revalidate=300";
36
37function svg(body: string) {
38 return new Response(body, {
39 status: 200,
40 headers: {
41 "content-type": "image/svg+xml; charset=utf-8",
42 "cache-control": CACHE,
43 },
44 });
45}
46
47async function resolveRepo(ownerName: string, repoName: string) {
48 try {
49 const [owner] = await db
50 .select()
51 .from(users)
52 .where(eq(users.username, ownerName))
53 .limit(1);
54 if (!owner) return null;
55 const [repo] = await db
56 .select()
57 .from(repositories)
58 .where(
59 and(
60 eq(repositories.ownerId, owner.id),
61 eq(repositories.name, repoName)
62 )
63 )
64 .limit(1);
65 if (!repo) return null;
66 return { owner, repo };
67 } catch {
68 return null;
69 }
70}
71
72// ---------------------------------------------------------------------------
73// /:owner/:repo/badge/gates.svg — latest gate_runs rollup
74// ---------------------------------------------------------------------------
75badges.get("/:owner/:repo/badge/gates.svg", softAuth, async (c) => {
76 const { owner: ownerName, repo: repoName } = c.req.param();
77 const resolved = await resolveRepo(ownerName, repoName);
78 if (!resolved) {
79 return svg(renderBadge({ label: "gates", value: "unknown", color: "grey" }));
80 }
81
82 try {
83 const rows = await db
84 .select()
85 .from(gateRuns)
86 .where(eq(gateRuns.repositoryId, resolved.repo.id))
87 .orderBy(desc(gateRuns.createdAt))
88 .limit(20);
89 if (!rows.length) {
90 return svg(
91 renderBadge({ label: "gates", value: "no runs", color: "grey" })
92 );
93 }
94 const anyFailed = rows.some((r) => r.status === "failed");
95 const anyRunning = rows.some(
96 (r) => r.status === "running" || r.status === "pending"
97 );
98 const state = anyFailed ? "failed" : anyRunning ? "pending" : "passed";
99 const label = "gates";
100 const value = anyFailed ? "failing" : anyRunning ? "running" : "passing";
101 return svg(
102 renderBadge({ label, value, color: colorForState(state as any) })
103 );
104 } catch {
105 return svg(
106 renderBadge({ label: "gates", value: "unknown", color: "grey" })
107 );
108 }
109});
110
111// ---------------------------------------------------------------------------
112// /:owner/:repo/badge/issues.svg — open issue count
113// ---------------------------------------------------------------------------
114badges.get("/:owner/:repo/badge/issues.svg", softAuth, async (c) => {
115 const { owner: ownerName, repo: repoName } = c.req.param();
116 const resolved = await resolveRepo(ownerName, repoName);
117 if (!resolved) {
118 return svg(
119 renderBadge({ label: "issues", value: "unknown", color: "grey" })
120 );
121 }
122 try {
123 const [row] = await db
124 .select({ n: sql<number>`count(*)::int` })
125 .from(issues)
126 .where(
127 and(
128 eq(issues.repositoryId, resolved.repo.id),
129 eq(issues.state, "open")
130 )
131 );
132 const n = Number(row?.n || 0);
133 return svg(
134 renderBadge({
135 label: "issues",
136 value: `${n} open`,
137 color: n === 0 ? "green" : n < 10 ? "blue" : "yellow",
138 })
139 );
140 } catch {
141 return svg(
142 renderBadge({ label: "issues", value: "unknown", color: "grey" })
143 );
144 }
145});
146
147// ---------------------------------------------------------------------------
148// /:owner/:repo/badge/prs.svg — open PR count
149// ---------------------------------------------------------------------------
150badges.get("/:owner/:repo/badge/prs.svg", softAuth, async (c) => {
151 const { owner: ownerName, repo: repoName } = c.req.param();
152 const resolved = await resolveRepo(ownerName, repoName);
153 if (!resolved) {
154 return svg(renderBadge({ label: "PRs", value: "unknown", color: "grey" }));
155 }
156 try {
157 const [row] = await db
158 .select({ n: sql<number>`count(*)::int` })
159 .from(pullRequests)
160 .where(
161 and(
162 eq(pullRequests.repositoryId, resolved.repo.id),
163 eq(pullRequests.state, "open")
164 )
165 );
166 const n = Number(row?.n || 0);
167 return svg(
168 renderBadge({
169 label: "PRs",
170 value: `${n} open`,
171 color: n === 0 ? "green" : n < 10 ? "blue" : "yellow",
172 })
173 );
174 } catch {
175 return svg(renderBadge({ label: "PRs", value: "unknown", color: "grey" }));
176 }
177});
178
179// ---------------------------------------------------------------------------
180// /:owner/:repo/badge/status.svg — combined commit status on default HEAD
181// /:owner/:repo/badge/status/:context.svg — single named status context
182// ---------------------------------------------------------------------------
183async function statusBadge(
184 resolved: NonNullable<Awaited<ReturnType<typeof resolveRepo>>>,
185 owner: string,
186 repo: string,
187 context: string | null
188) {
189 try {
190 const branch = (await getDefaultBranch(owner, repo)) || "main";
191 const sha = await resolveRef(owner, repo, branch);
192 if (!sha) {
193 return renderBadge({
194 label: context || "status",
195 value: "no commit",
196 color: "grey",
197 });
198 }
199 const rows = await db
200 .select()
201 .from(commitStatuses)
202 .where(
203 and(
204 eq(commitStatuses.repositoryId, resolved.repo.id),
205 eq(commitStatuses.commitSha, sha.toLowerCase())
206 )
207 );
208 let latest = rows;
209 if (context) {
210 latest = rows.filter((r) => r.context === context);
211 } else {
212 // latest per context
213 const m = new Map<string, (typeof rows)[number]>();
214 for (const r of rows) {
215 const p = m.get(r.context);
216 if (!p || p.updatedAt < r.updatedAt) m.set(r.context, r);
217 }
218 latest = [...m.values()];
219 }
220 if (!latest.length) {
221 return renderBadge({
222 label: context || "status",
223 value: "none",
224 color: "grey",
225 });
226 }
227 const states = latest.map((r) => r.state);
228 const combined = states.some((s) => s === "failure" || s === "error")
229 ? "failure"
230 : states.some((s) => s === "pending")
231 ? "pending"
232 : "success";
233 return renderBadge({
234 label: context || "status",
235 value: combined,
236 color: colorForState(combined as any),
237 });
238 } catch {
239 return renderBadge({
240 label: context || "status",
241 value: "unknown",
242 color: "grey",
243 });
244 }
245}
246
247badges.get("/:owner/:repo/badge/status.svg", softAuth, async (c) => {
248 const { owner: ownerName, repo: repoName } = c.req.param();
249 const resolved = await resolveRepo(ownerName, repoName);
250 if (!resolved) {
251 return svg(
252 renderBadge({ label: "status", value: "unknown", color: "grey" })
253 );
254 }
255 return svg(await statusBadge(resolved, ownerName, repoName, null));
256});
257
258badges.get("/:owner/:repo/badge/status/:context.svg", softAuth, async (c) => {
259 const { owner: ownerName, repo: repoName, context } = c.req.param();
260 const resolved = await resolveRepo(ownerName, repoName);
261 if (!resolved) {
262 return svg(
263 renderBadge({ label: context, value: "unknown", color: "grey" })
264 );
265 }
266 return svg(await statusBadge(resolved, ownerName, repoName, context));
267});
268
269export default badges;
0270