Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

badges.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

badges.tsBlame269 lines · 1 contributor
3648956Claude1/**
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;