Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

dora.tsx

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

dora.tsxBlame645 lines · 1 contributor
8d1483cClaude1/**
2 * DORA (DevOps Research and Assessment) metrics page.
3 *
4 * Route: GET /:owner/:repo/insights/dora
5 *
6 * Computes the four key DORA metrics using existing tables:
7 * 1. Deployment frequency — deployments in last 30d
8 * 2. Lead time for changes — avg gap between consecutive deployments (proxy)
9 * 3. Change failure rate — % of deployments with status = 'failed'
10 * 4. MTTR — avg time from failure to next success
11 *
12 * Plus two Gluecron-specific bonus metrics:
13 * 5. Gate pass rate — % of gate_runs with status = 'pass'
14 * 6. Workflow success rate — % of workflow_runs with status = 'success'
15 *
16 * All DB queries are wrapped in Promise.all for parallelism and in
17 * try/catch so a DB failure never throws into the request path.
18 */
19
20import { Hono } from "hono";
21import { db } from "../db";
22import { deployments, gateRuns, workflowRuns, repositories, users } from "../db/schema";
23import { eq, and, desc, gte, sql } from "drizzle-orm";
24import type { AuthEnv } from "../middleware/auth";
25import { softAuth } from "../middleware/auth";
26import { requireRepoAccess } from "../middleware/repo-access";
27import { Layout } from "../views/layout";
28import { RepoHeader, RepoNav } from "../views/components";
29
30const doraRoutes = new Hono<AuthEnv>();
31
32// ─── DORA benchmark thresholds ───────────────────────────────────────────────
33
34type DoraLevel = "Elite" | "High" | "Medium" | "Low";
35
36function deployFreqLevel(deploysPerWeek: number): DoraLevel {
37 // Elite = multiple/day (>7/week), High = weekly (~1/week), Medium = monthly (~0.25/week)
38 if (deploysPerWeek >= 7) return "Elite";
39 if (deploysPerWeek >= 1) return "High";
40 if (deploysPerWeek >= 0.25) return "Medium";
41 return "Low";
42}
43
44function leadTimeLevel(avgGapHours: number): DoraLevel {
45 if (avgGapHours < 1) return "Elite";
46 if (avgGapHours < 24) return "High";
47 if (avgGapHours < 168) return "Medium"; // 1 week
48 return "Low";
49}
50
51function changeFailureLevel(failurePct: number): DoraLevel {
52 if (failurePct <= 2) return "Elite";
53 if (failurePct <= 5) return "High";
54 if (failurePct <= 15) return "Medium";
55 return "Low";
56}
57
58function mttrLevel(avgHours: number): DoraLevel {
59 if (avgHours < 1) return "Elite";
60 if (avgHours < 24) return "High";
61 if (avgHours < 168) return "Medium";
62 return "Low";
63}
64
65function levelColor(level: DoraLevel): string {
66 switch (level) {
67 case "Elite": return "var(--green, #4caf50)";
68 case "High": return "var(--blue, #2196f3)";
69 case "Medium":return "var(--yellow, #ff9800)";
70 case "Low": return "var(--red, #f44336)";
71 }
72}
73
74function worstLevel(levels: (DoraLevel | null)[]): DoraLevel {
75 const order: DoraLevel[] = ["Elite", "High", "Medium", "Low"];
76 let worst: DoraLevel = "Elite";
77 for (const l of levels) {
78 if (!l) continue;
79 if (order.indexOf(l) > order.indexOf(worst)) worst = l;
80 }
81 return worst;
82}
83
84function formatHours(h: number): string {
85 if (h < 1) return `${Math.round(h * 60)} min`;
86 if (h < 24) return `${h.toFixed(1)} h`;
87 return `${(h / 24).toFixed(1)} d`;
88}
89
90// ─── Scoped CSS ───────────────────────────────────────────────────────────────
91
92const styles = `
eed4684Claude93 .dora-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
8d1483cClaude94
95 .dora-hero {
96 position: relative;
97 margin-bottom: var(--space-5);
98 padding: var(--space-5) var(--space-6);
99 background: var(--bg-elevated);
100 border: 1px solid var(--border);
101 border-radius: 14px;
102 overflow: hidden;
103 }
104 .dora-hero::before {
105 content: '';
106 position: absolute;
107 top: 0; left: 0; right: 0;
108 height: 2px;
6fd5915Claude109 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8d1483cClaude110 opacity: 0.8;
111 pointer-events: none;
112 }
113
114 .dora-eyebrow {
115 font-size: 11px;
116 letter-spacing: 0.08em;
117 text-transform: uppercase;
118 color: var(--text-muted);
119 margin-bottom: var(--space-2);
120 }
121 .dora-hero-title {
122 font-size: 22px;
123 font-weight: 700;
124 margin: 0 0 var(--space-2) 0;
125 color: var(--text);
126 }
127 .dora-hero-sub {
128 color: var(--text-muted);
129 font-size: 14px;
130 margin: 0 0 var(--space-4) 0;
131 }
132 .dora-overall {
133 display: inline-flex;
134 align-items: center;
135 gap: 10px;
136 padding: 8px 18px;
137 border-radius: 8px;
138 background: var(--bg);
139 border: 1px solid var(--border);
140 }
141 .dora-overall-label {
142 font-size: 13px;
143 color: var(--text-muted);
144 }
145 .dora-overall-badge {
146 font-size: 15px;
147 font-weight: 700;
148 border-radius: 5px;
149 padding: 2px 10px;
150 color: #fff;
151 }
152
153 .dora-grid {
154 display: grid;
155 grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
156 gap: var(--space-4);
157 margin-bottom: var(--space-5);
158 }
159 .dora-card {
160 background: var(--bg-elevated);
161 border: 1px solid var(--border);
162 border-radius: 12px;
163 padding: var(--space-4);
164 display: flex;
165 flex-direction: column;
166 gap: 6px;
167 }
168 .dora-card-name {
169 font-size: 12px;
170 text-transform: uppercase;
171 letter-spacing: 0.07em;
172 color: var(--text-muted);
173 }
174 .dora-card-value {
175 font-size: 24px;
176 font-weight: 700;
177 font-variant-numeric: tabular-nums;
178 color: var(--text);
179 line-height: 1.1;
180 }
181 .dora-card-value.dora-na {
182 font-size: 18px;
183 color: var(--text-muted);
184 }
185 .dora-level-badge {
186 display: inline-block;
187 font-size: 11px;
188 font-weight: 600;
189 border-radius: 4px;
190 padding: 2px 8px;
191 color: #fff;
192 margin-top: 2px;
193 align-self: flex-start;
194 }
195 .dora-card-desc {
196 font-size: 12px;
197 color: var(--text-muted);
198 line-height: 1.45;
199 margin-top: 2px;
200 }
201
202 .dora-section-title {
203 font-size: 14px;
204 font-weight: 600;
205 color: var(--text);
206 margin: 0 0 var(--space-3) 0;
207 }
208 .dora-table-wrap {
209 background: var(--bg-elevated);
210 border: 1px solid var(--border);
211 border-radius: 12px;
212 overflow: hidden;
213 }
214 .dora-table {
215 width: 100%;
216 border-collapse: collapse;
217 font-size: 13px;
218 }
219 .dora-table th {
220 text-align: left;
221 padding: 10px 16px;
222 font-size: 11px;
223 text-transform: uppercase;
224 letter-spacing: 0.07em;
225 color: var(--text-muted);
226 border-bottom: 1px solid var(--border);
227 }
228 .dora-table td {
229 padding: 10px 16px;
230 border-bottom: 1px solid var(--border);
231 color: var(--text);
232 font-variant-numeric: tabular-nums;
233 }
234 .dora-table tr:last-child td { border-bottom: none; }
235 .dora-pill {
236 display: inline-block;
237 font-size: 11px;
238 font-weight: 600;
239 border-radius: 4px;
240 padding: 2px 8px;
241 color: #fff;
242 }
243 .dora-pill-success { background: var(--green, #4caf50); }
244 .dora-pill-failed { background: var(--red, #f44336); }
245 .dora-pill-other { background: var(--text-muted); }
246 .dora-sha { font-family: monospace; font-size: 12px; color: var(--text-muted); }
247`;
248
249// ─── Route ────────────────────────────────────────────────────────────────────
250
251doraRoutes.get(
252 "/:owner/:repo/insights/dora",
253 softAuth,
254 requireRepoAccess("read"),
255 async (c) => {
256 const { owner, repo } = c.req.param();
257 const user = c.get("user") ?? null;
258 const repository = (c.get("repository" as never) as { id: string; name: string; isPrivate: boolean }) ?? null;
259
260 if (!repository) {
261 return c.html("Repository not found", 404);
262 }
263
264 const repoId = repository.id;
265 const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
266
267 // ─── Parallel DB queries (all fail-open) ──────────────────────────────
268 const [
269 recentDeployments,
270 last20Deployments,
271 last50Deployments,
272 gateRunStats,
273 workflowRunStats,
274 ] = await Promise.all([
275 // 1 & 3: Recent deployments for frequency + failure rate
276 (async () => {
277 try {
278 return await db
279 .select({
280 id: deployments.id,
281 status: deployments.status,
282 commitSha: deployments.commitSha,
283 createdAt: deployments.createdAt,
284 completedAt: deployments.completedAt,
285 })
286 .from(deployments)
287 .where(
288 and(
289 eq(deployments.repositoryId, repoId),
290 gte(deployments.createdAt, thirtyDaysAgo)
291 )
292 )
293 .orderBy(desc(deployments.createdAt));
294 } catch {
295 return null;
296 }
297 })(),
298
299 // 2: Last 20 deployments for lead-time proxy (avg gap between consecutive)
300 (async () => {
301 try {
302 return await db
303 .select({ createdAt: deployments.createdAt })
304 .from(deployments)
305 .where(eq(deployments.repositoryId, repoId))
306 .orderBy(desc(deployments.createdAt))
307 .limit(20);
308 } catch {
309 return null;
310 }
311 })(),
312
313 // 4: Last 50 deployments for MTTR (failure→success pairs)
314 (async () => {
315 try {
316 return await db
317 .select({
318 status: deployments.status,
319 createdAt: deployments.createdAt,
320 })
321 .from(deployments)
322 .where(eq(deployments.repositoryId, repoId))
323 .orderBy(desc(deployments.createdAt))
324 .limit(50);
325 } catch {
326 return null;
327 }
328 })(),
329
330 // 5: Gate pass rate in last 30d
331 (async () => {
332 try {
333 const rows = await db
334 .select({ status: gateRuns.status })
335 .from(gateRuns)
336 .where(
337 and(
338 eq(gateRuns.repositoryId, repoId),
339 gte(gateRuns.createdAt, thirtyDaysAgo)
340 )
341 );
342 return rows;
343 } catch {
344 return null;
345 }
346 })(),
347
348 // 6: Workflow success rate in last 30d
349 (async () => {
350 try {
351 const rows = await db
352 .select({ status: workflowRuns.status })
353 .from(workflowRuns)
354 .where(
355 and(
356 eq(workflowRuns.repositoryId, repoId),
357 gte(workflowRuns.createdAt, thirtyDaysAgo)
358 )
359 );
360 return rows;
361 } catch {
362 return null;
363 }
364 })(),
365 ]);
366
367 // ─── Metric 1: Deployment frequency ──────────────────────────────────
368 let deploysPerWeek: number | null = null;
369 let freqLevel: DoraLevel | null = null;
370 if (recentDeployments !== null) {
371 const count = recentDeployments.length;
372 deploysPerWeek = (count / 30) * 7;
373 freqLevel = deployFreqLevel(deploysPerWeek);
374 }
375
376 // ─── Metric 2: Lead time proxy (avg gap between consecutive deploys) ──
377 let avgGapHours: number | null = null;
378 let leadLevel: DoraLevel | null = null;
379 if (last20Deployments !== null && last20Deployments.length >= 2) {
380 const sorted = [...last20Deployments].sort(
381 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
382 );
383 const gaps: number[] = [];
384 for (let i = 1; i < sorted.length; i++) {
385 const ms = new Date(sorted[i].createdAt).getTime() - new Date(sorted[i - 1].createdAt).getTime();
386 if (ms > 0) gaps.push(ms / (1000 * 3600)); // ms → hours
387 }
388 if (gaps.length > 0) {
389 avgGapHours = gaps.reduce((a, b) => a + b, 0) / gaps.length;
390 leadLevel = leadTimeLevel(avgGapHours);
391 }
392 }
393
394 // ─── Metric 3: Change failure rate ───────────────────────────────────
395 let failurePct: number | null = null;
396 let failureLevel: DoraLevel | null = null;
397 if (recentDeployments !== null && recentDeployments.length > 0) {
398 const failed = recentDeployments.filter((d) => d.status === "failed").length;
399 failurePct = (failed / recentDeployments.length) * 100;
400 failureLevel = changeFailureLevel(failurePct);
401 }
402
403 // ─── Metric 4: MTTR ───────────────────────────────────────────────────
404 let mttrHours: number | null = null;
405 let mttrLvl: DoraLevel | null = null;
406 if (last50Deployments !== null && last50Deployments.length >= 2) {
407 // Chronological order
408 const chron = [...last50Deployments].sort(
409 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
410 );
411 const gaps: number[] = [];
412 for (let i = 0; i < chron.length - 1; i++) {
413 if (chron[i].status === "failed") {
414 // Find the next success after this failure
415 for (let j = i + 1; j < chron.length; j++) {
416 if (chron[j].status === "success") {
417 const ms = new Date(chron[j].createdAt).getTime() - new Date(chron[i].createdAt).getTime();
418 if (ms > 0) gaps.push(ms / (1000 * 3600));
419 break;
420 }
421 }
422 }
423 }
424 if (gaps.length > 0) {
425 mttrHours = gaps.reduce((a, b) => a + b, 0) / gaps.length;
426 mttrLvl = mttrLevel(mttrHours);
427 }
428 }
429
430 // ─── Metric 5: Gate pass rate ─────────────────────────────────────────
431 let gatePassPct: number | null = null;
432 if (gateRunStats !== null && gateRunStats.length > 0) {
433 const passed = gateRunStats.filter((r) => r.status === "passed" || r.status === "pass").length;
434 gatePassPct = (passed / gateRunStats.length) * 100;
435 }
436
437 // ─── Metric 6: Workflow success rate ──────────────────────────────────
438 let workflowSuccessPct: number | null = null;
439 if (workflowRunStats !== null && workflowRunStats.length > 0) {
440 const succeeded = workflowRunStats.filter((r) => r.status === "success").length;
441 workflowSuccessPct = (succeeded / workflowRunStats.length) * 100;
442 }
443
444 // ─── Overall DORA level (worst of the 4 core metrics) ─────────────────
445 const overallLevel = worstLevel([freqLevel, leadLevel, failureLevel, mttrLvl]);
446
447 // ─── Last 10 deployments for the table ────────────────────────────────
448 const last10 = recentDeployments ? recentDeployments.slice(0, 10) : [];
449
450 // ─── Render ──────────────────────────────────────────────────────────
451 return c.html(
452 <Layout title={`DORA Metrics — ${owner}/${repo}`} user={user}>
453 <style dangerouslySetInnerHTML={{ __html: styles }} />
454 <div class="dora-wrap">
455 <RepoHeader owner={owner} repo={repo} />
456 <RepoNav owner={owner} repo={repo} active="insights" />
457
458 {/* Hero */}
459 <div class="dora-hero">
460 <div class="dora-eyebrow">DevOps Research &amp; Assessment</div>
461 <h1 class="dora-hero-title">DORA Metrics</h1>
462 <p class="dora-hero-sub">
463 Deployment performance for the last 30 days, measured against Google's DORA benchmarks.
464 </p>
465 <div class="dora-overall">
466 <span class="dora-overall-label">Overall DORA Level</span>
467 <span
468 class="dora-overall-badge"
469 style={`background:${levelColor(overallLevel)}`}
470 >
471 {overallLevel}
472 </span>
473 </div>
474 </div>
475
476 {/* 4-metric cards */}
477 <div class="dora-grid">
478 {/* Deployment Frequency */}
479 <div class="dora-card">
480 <div class="dora-card-name">Deployment Frequency</div>
481 {deploysPerWeek !== null ? (
482 <>
483 <div class="dora-card-value">{deploysPerWeek.toFixed(1)}<span style="font-size:14px;font-weight:400;color:var(--text-muted)"> /wk</span></div>
484 <span
485 class="dora-level-badge"
486 style={`background:${levelColor(freqLevel!)}`}
487 >
488 {freqLevel}
489 </span>
490 <div class="dora-card-desc">
491 Elite: multiple/day · High: weekly · Medium: monthly
492 </div>
493 </>
494 ) : (
495 <div class="dora-card-value dora-na">No data</div>
496 )}
497 </div>
498
499 {/* Lead Time (proxy) */}
500 <div class="dora-card">
501 <div class="dora-card-name">Lead Time for Changes</div>
502 {avgGapHours !== null ? (
503 <>
504 <div class="dora-card-value">{formatHours(avgGapHours)}</div>
505 <span
506 class="dora-level-badge"
507 style={`background:${levelColor(leadLevel!)}`}
508 >
509 {leadLevel}
510 </span>
511 <div class="dora-card-desc">
512 Avg gap between consecutive deploys. Elite: &lt;1h · High: &lt;1d
513 </div>
514 </>
515 ) : (
516 <div class="dora-card-value dora-na">No data</div>
517 )}
518 </div>
519
520 {/* Change Failure Rate */}
521 <div class="dora-card">
522 <div class="dora-card-name">Change Failure Rate</div>
523 {failurePct !== null ? (
524 <>
525 <div class="dora-card-value">{failurePct.toFixed(1)}<span style="font-size:14px;font-weight:400;color:var(--text-muted)">%</span></div>
526 <span
527 class="dora-level-badge"
528 style={`background:${levelColor(failureLevel!)}`}
529 >
530 {failureLevel}
531 </span>
532 <div class="dora-card-desc">
533 % of deployments that failed. Elite: 0–2% · High: 2–5%
534 </div>
535 </>
536 ) : (
537 <div class="dora-card-value dora-na">No data</div>
538 )}
539 </div>
540
541 {/* MTTR */}
542 <div class="dora-card">
543 <div class="dora-card-name">MTTR</div>
544 {mttrHours !== null ? (
545 <>
546 <div class="dora-card-value">{formatHours(mttrHours)}</div>
547 <span
548 class="dora-level-badge"
549 style={`background:${levelColor(mttrLvl!)}`}
550 >
551 {mttrLvl}
552 </span>
553 <div class="dora-card-desc">
554 Avg time failure → next success. Elite: &lt;1h · High: &lt;1d
555 </div>
556 </>
557 ) : (
558 <div class="dora-card-value dora-na">No data</div>
559 )}
560 </div>
561
562 {/* Gate Pass Rate */}
563 <div class="dora-card">
564 <div class="dora-card-name">Gate Pass Rate</div>
565 {gatePassPct !== null ? (
566 <>
567 <div class="dora-card-value">{gatePassPct.toFixed(1)}<span style="font-size:14px;font-weight:400;color:var(--text-muted)">%</span></div>
568 <div class="dora-card-desc">
569 Gate runs passing in the last 30 days ({gateRunStats!.length} total).
570 </div>
571 </>
572 ) : (
573 <div class="dora-card-value dora-na">No data</div>
574 )}
575 </div>
576
577 {/* Workflow Success Rate */}
578 <div class="dora-card">
579 <div class="dora-card-name">Workflow Success Rate</div>
580 {workflowSuccessPct !== null ? (
581 <>
582 <div class="dora-card-value">{workflowSuccessPct.toFixed(1)}<span style="font-size:14px;font-weight:400;color:var(--text-muted)">%</span></div>
583 <div class="dora-card-desc">
584 Workflow runs succeeding in the last 30 days ({workflowRunStats!.length} total).
585 </div>
586 </>
587 ) : (
588 <div class="dora-card-value dora-na">No data</div>
589 )}
590 </div>
591 </div>
592
593 {/* Last 10 deployments table */}
594 <h2 class="dora-section-title">Last 10 Deployments</h2>
595 {last10.length === 0 ? (
596 <p style="color:var(--text-muted);font-size:14px;">No deployments found in the last 30 days.</p>
597 ) : (
598 <div class="dora-table-wrap">
599 <table class="dora-table">
600 <thead>
601 <tr>
602 <th>SHA</th>
603 <th>Status</th>
604 <th>Created</th>
605 <th>Duration</th>
606 </tr>
607 </thead>
608 <tbody>
609 {last10.map((d) => {
610 const sha7 = d.commitSha.slice(0, 7);
611 const statusClass =
612 d.status === "success"
613 ? "dora-pill-success"
614 : d.status === "failed"
615 ? "dora-pill-failed"
616 : "dora-pill-other";
617 const createdStr = new Date(d.createdAt).toISOString().replace("T", " ").slice(0, 19) + " UTC";
618 let duration = "—";
619 if (d.completedAt) {
620 const ms = new Date(d.completedAt).getTime() - new Date(d.createdAt).getTime();
621 if (ms > 0) {
622 const secs = Math.round(ms / 1000);
623 duration = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
624 }
625 }
626 return (
627 <tr key={d.id}>
628 <td><span class="dora-sha">{sha7}</span></td>
629 <td><span class={`dora-pill ${statusClass}`}>{d.status}</span></td>
630 <td style="color:var(--text-muted)">{createdStr}</td>
631 <td style="color:var(--text-muted)">{duration}</td>
632 </tr>
633 );
634 })}
635 </tbody>
636 </table>
637 </div>
638 )}
639 </div>
640 </Layout>
641 );
642 }
643);
644
645export default doraRoutes;