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

pipeline.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.

pipeline.tsxBlame493 lines · 2 contributors
2c61840Claude1/**
2 * Unified CI/CD Pipeline Timeline — /:owner/:repo/pipeline
3 *
4 * Single scrollable view combining gate runs and deployments for a repo,
5 * ordered chronologically. Operators can see the full story of a push —
6 * from gate check through to production deploy — without clicking between
7 * separate pages.
8 *
9 * Events shown:
10 * - Gate runs (status: passed/failed/running/skipped/repaired)
11 * - Cloud deployments (status: success/failed/blocked/running/pending)
12 *
13 * Refreshes every 15s while any event is in-flight.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq, gte, or } from "drizzle-orm";
18import { db } from "../db";
19import { deployments, gateRuns, pullRequests, repositories, users } from "../db/schema";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24
25const app = new Hono<AuthEnv>();
26app.use("/:owner/:repo/pipeline", softAuth);
27
28// ---------------------------------------------------------------------------
29// Helpers
30// ---------------------------------------------------------------------------
31
32function relTime(d: Date): string {
33 const sec = Math.floor((Date.now() - d.getTime()) / 1000);
34 if (sec < 60) return `${sec}s ago`;
35 if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
36 if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
37 return `${Math.floor(sec / 86400)}d ago`;
38}
39
40function durationStr(startMs: number, endMs?: number | null): string {
41 const ms = (endMs ?? Date.now()) - startMs;
42 if (ms < 1000) return `${ms}ms`;
43 if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
44 return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
45}
46
47type GateEvent = {
48 kind: "gate";
49 id: string;
50 createdAt: Date;
51 completedAt: Date | null;
52 status: string;
53 gateName: string;
54 commitSha: string;
55 ref: string;
56 prNumber: number | null;
57 repairAttempted: boolean | null;
58 repairSucceeded: boolean | null;
59 durationMs: number | null;
60};
61
62type DeployEvent = {
63 kind: "deploy";
64 id: string;
65 createdAt: Date;
66 completedAt: Date | null;
67 status: string;
68 environment: string;
69 ref: string;
70 commitSha: string;
71 blockedReason: string | null;
72};
73
74type TimelineEvent = GateEvent | DeployEvent;
75
76// ---------------------------------------------------------------------------
77// GET /:owner/:repo/pipeline
78// ---------------------------------------------------------------------------
79
80app.get("/:owner/:repo/pipeline", async (c) => {
81 const { owner, repo } = c.req.param();
82
83 const [repoRow] = await db
84 .select({ repo: repositories, owner: users })
85 .from(repositories)
86 .innerJoin(users, eq(repositories.ownerId, users.id))
87 .where(and(eq(users.username, owner), eq(repositories.name, repo)));
88
89 if (!repoRow) return c.notFound();
90
91 const authUser = c.get("user");
92 const isOwner = authUser?.id === repoRow.owner.id;
93 if (repoRow.repo.isPrivate && !isOwner) return c.notFound();
94
95 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // last 7 days
96
97 const [gateRows, deployRows] = await Promise.all([
98 db
99 .select({
100 id: gateRuns.id,
101 createdAt: gateRuns.createdAt,
102 completedAt: gateRuns.completedAt,
103 status: gateRuns.status,
104 gateName: gateRuns.gateName,
105 commitSha: gateRuns.commitSha,
106 ref: gateRuns.ref,
107 pullRequestId: gateRuns.pullRequestId,
108 repairAttempted: gateRuns.repairAttempted,
109 repairSucceeded: gateRuns.repairSucceeded,
110 durationMs: gateRuns.durationMs,
111 })
112 .from(gateRuns)
113 .where(and(eq(gateRuns.repositoryId, repoRow.repo.id), gte(gateRuns.createdAt, since)))
114 .orderBy(desc(gateRuns.createdAt))
115 .limit(100),
116 db
117 .select({
118 id: deployments.id,
119 createdAt: deployments.createdAt,
120 completedAt: deployments.completedAt,
121 status: deployments.status,
122 environment: deployments.environment,
123 ref: deployments.ref,
124 commitSha: deployments.commitSha,
125 blockedReason: deployments.blockedReason,
126 })
127 .from(deployments)
128 .where(and(eq(deployments.repositoryId, repoRow.repo.id), gte(deployments.createdAt, since)))
129 .orderBy(desc(deployments.createdAt))
130 .limit(50),
131 ]);
132
133 // Resolve PR numbers for gate rows that have a pullRequestId
134 const prIdSet = new Set(gateRows.map((g) => g.pullRequestId).filter(Boolean) as string[]);
135 const prNumberMap = new Map<string, number>();
136 if (prIdSet.size > 0) {
137 const prRows = await db
138 .select({ id: pullRequests.id, number: pullRequests.number })
139 .from(pullRequests)
140 .where(
141 or(...Array.from(prIdSet).map((id) => eq(pullRequests.id, id)))
142 );
143 prRows.forEach((p) => prNumberMap.set(p.id, p.number));
144 }
145
146 const events: TimelineEvent[] = [
147 ...gateRows.map((g): GateEvent => ({
148 kind: "gate",
149 id: g.id,
150 createdAt: g.createdAt,
151 completedAt: g.completedAt,
152 status: g.status,
153 gateName: g.gateName,
154 commitSha: g.commitSha,
155 ref: g.ref,
156 prNumber: g.pullRequestId ? (prNumberMap.get(g.pullRequestId) ?? null) : null,
157 repairAttempted: g.repairAttempted,
158 repairSucceeded: g.repairSucceeded,
159 durationMs: g.durationMs,
160 })),
161 ...deployRows.map((d): DeployEvent => ({
162 kind: "deploy",
163 id: d.id,
164 createdAt: d.createdAt,
165 completedAt: d.completedAt,
166 status: d.status,
167 environment: d.environment,
168 ref: d.ref,
169 commitSha: d.commitSha,
170 blockedReason: d.blockedReason,
171 })),
172 ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
173
174 const hasInFlight = events.some(
175 (e) => e.status === "running" || e.status === "pending" || e.status === "waiting_timer"
176 );
177
178 // Summary counts
179 const gateTotal = gateRows.length;
180 const gateFailed = gateRows.filter((g) => g.status === "failed").length;
181 const gateRepaired = gateRows.filter((g) => g.repairSucceeded).length;
182 const deployTotal = deployRows.length;
183 const deployFailed = deployRows.filter((d) => d.status === "failed" || d.status === "blocked").length;
184
185 const styles = `
186 .pl-wrap { max-width: 920px; margin: 0 auto; padding: 0 var(--space-4); }
187
188 .pl-summary {
189 display: flex;
190 gap: var(--space-3);
191 flex-wrap: wrap;
192 margin-bottom: var(--space-5);
193 padding: var(--space-4);
194 background: var(--bg-elevated);
195 border: 1px solid var(--border);
196 border-radius: 12px;
197 }
198 .pl-summary-stat {
199 display: flex;
200 flex-direction: column;
201 align-items: center;
202 gap: 2px;
203 min-width: 80px;
204 padding: var(--space-2) var(--space-3);
205 background: var(--bg-inset);
206 border: 1px solid var(--border);
207 border-radius: 10px;
208 }
209 .pl-summary-val {
210 font-size: 22px;
211 font-weight: 700;
212 line-height: 1;
213 }
214 .pl-summary-val.ok { color: #3fb950; }
215 .pl-summary-val.err { color: #f85149; }
216 .pl-summary-val.warn { color: #d29922; }
217 .pl-summary-val.neutral { color: var(--text); }
218 .pl-summary-label { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
219
220 .pl-filter {
221 display: flex;
222 gap: var(--space-2);
223 flex-wrap: wrap;
224 margin-bottom: var(--space-4);
225 }
226 .pl-filter-btn {
227 padding: 5px 14px;
228 border: 1px solid var(--border);
229 border-radius: 20px;
230 background: var(--bg-inset);
231 color: var(--text-secondary);
232 font-size: 12px;
233 font-weight: 600;
234 cursor: pointer;
235 text-decoration: none;
236 transition: border-color 120ms, color 120ms;
237 }
238 .pl-filter-btn:hover, .pl-filter-btn.active {
239 border-color: var(--accent);
240 color: var(--accent);
241 }
242
243 .pl-timeline { position: relative; }
244 .pl-timeline::before {
245 content: '';
246 position: absolute;
247 left: 19px;
248 top: 0;
249 bottom: 0;
250 width: 2px;
251 background: var(--border);
252 }
253
254 .pl-event {
255 position: relative;
256 display: flex;
257 gap: var(--space-3);
258 padding: var(--space-3) 0;
259 }
260 .pl-event-dot {
261 position: relative;
262 z-index: 1;
263 width: 20px;
264 height: 20px;
265 flex-shrink: 0;
266 margin-top: 2px;
267 border-radius: 50%;
268 border: 2px solid var(--bg);
269 display: flex;
270 align-items: center;
271 justify-content: center;
272 font-size: 10px;
273 }
274 .pl-event-dot.gate-passed { background: #3fb950; }
275 .pl-event-dot.gate-failed { background: #f85149; }
276 .pl-event-dot.gate-running { background: #58a6ff; }
277 .pl-event-dot.gate-pending { background: var(--text-muted); }
278 .pl-event-dot.gate-skipped { background: var(--border-strong); }
279 .pl-event-dot.gate-repaired { background: #d29922; }
280 .pl-event-dot.deploy-success { background: #3fb950; }
281 .pl-event-dot.deploy-failed { background: #f85149; }
282 .pl-event-dot.deploy-blocked { background: #d29922; }
283 .pl-event-dot.deploy-running { background: #58a6ff; }
284 .pl-event-dot.deploy-pending { background: var(--text-muted); }
285
286 .pl-event-body {
287 flex: 1;
288 min-width: 0;
289 background: var(--bg-elevated);
290 border: 1px solid var(--border);
291 border-radius: 10px;
292 padding: var(--space-3) var(--space-4);
293 transition: border-color 120ms;
294 }
295 .pl-event-body:hover { border-color: var(--border-strong); }
296
297 .pl-event-top {
298 display: flex;
299 align-items: center;
300 gap: var(--space-2);
301 flex-wrap: wrap;
302 }
303 .pl-event-kind {
304 font-size: 10px;
305 font-weight: 700;
306 text-transform: uppercase;
307 letter-spacing: 0.08em;
308 padding: 1px 7px;
309 border-radius: 8px;
310 }
311 .pl-event-kind.gate { background: rgba(88,166,255,0.12); color: #58a6ff; }
312 .pl-event-kind.deploy { background: rgba(91,110,232,0.12); color: #8b9cf4; }
313 .pl-event-name { font-size: 14px; font-weight: 600; color: var(--text); flex: 1; }
314 .pl-event-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
315 .pl-event-status {
316 font-size: 11px;
317 font-weight: 600;
318 padding: 2px 8px;
319 border-radius: 8px;
320 }
321 .pl-status-passed { background: rgba(63,185,80,0.12); color: #3fb950; }
322 .pl-status-failed { background: rgba(248,81,73,0.12); color: #f85149; }
323 .pl-status-running { background: rgba(88,166,255,0.12); color: #58a6ff; }
324 .pl-status-pending { background: rgba(130,80,223,0.10); color: #9a7fde; }
325 .pl-status-skipped { background: var(--bg-inset); color: var(--text-muted); }
326 .pl-status-repaired { background: rgba(210,153,34,0.12); color: #d29922; }
327 .pl-status-success { background: rgba(63,185,80,0.12); color: #3fb950; }
328 .pl-status-blocked { background: rgba(210,153,34,0.12); color: #d29922; }
329
330 .pl-event-meta {
331 margin-top: var(--space-1);
332 font-size: 12px;
333 color: var(--text-muted);
334 display: flex;
335 gap: var(--space-3);
336 flex-wrap: wrap;
337 align-items: center;
338 }
339 .pl-event-meta a { color: var(--accent); text-decoration: none; }
340 .pl-event-meta a:hover { text-decoration: underline; }
341 .pl-sha {
342 font-family: var(--font-mono);
343 font-size: 11px;
344 background: var(--bg-inset);
345 padding: 1px 6px;
346 border-radius: 5px;
347 }
348 .pl-repair-badge {
349 font-size: 11px;
350 font-weight: 600;
351 padding: 1px 7px;
352 border-radius: 8px;
353 background: rgba(210,153,34,0.12);
354 color: #d29922;
355 }
356
357 .pl-empty {
358 text-align: center;
359 padding: var(--space-8) var(--space-4);
360 color: var(--text-muted);
361 font-size: 14px;
362 }
363
364 @media (max-width: 600px) {
365 .pl-wrap { padding: 0 var(--space-2); }
366 .pl-summary { gap: var(--space-2); }
367 .pl-event-body { padding: var(--space-2) var(--space-3); }
368 }
369 `;
370
371 const gateStatusCls = (s: string) => `pl-status-${s}`;
372 const deployStatusCls = (s: string) => {
373 if (s === "success" || s === "succeeded") return "pl-status-success";
374 return `pl-status-${s}`;
375 };
376 const dotCls = (e: TimelineEvent) => {
377 if (e.kind === "gate") {
378 const s = e.repairSucceeded ? "repaired" : e.status;
379 return `gate-${s}`;
380 }
381 const s = e.status === "succeeded" ? "success" : e.status;
382 return `deploy-${s}`;
383 };
384
385 return c.html(
386 <Layout title={`Pipeline — ${owner}/${repo}`} user={authUser ?? undefined}>
387 <style>{styles}</style>
388 {hasInFlight && <meta http-equiv="refresh" content="15" />}
389
390 <div class="pl-wrap">
391 <RepoHeader
392 owner={owner}
393 repo={repo}
8ed88f2ccantynz-alt394 starCount={repoRow.repo.starCount ?? 0}
395 forkCount={repoRow.repo.forkCount ?? 0}
2c61840Claude396 />
8ed88f2ccantynz-alt397 <RepoNav owner={owner} repo={repo} active="pipeline" />
2c61840Claude398
399 <div style="margin: var(--space-5) 0 var(--space-3); display:flex; align-items:baseline; gap:var(--space-3)">
400 <h2 style="font-size:18px;font-weight:700;color:var(--text);margin:0">CI/CD Pipeline</h2>
401 <span style="font-size:12px;color:var(--text-muted)">last 7 days</span>
402 {hasInFlight && (
403 <span style="font-size:11px;color:#58a6ff;margin-left:auto">⟳ auto-refresh every 15s</span>
404 )}
405 </div>
406
407 {/* Summary bar */}
408 <div class="pl-summary">
409 <div class="pl-summary-stat">
410 <span class={`pl-summary-val ${gateFailed === 0 ? "ok" : "err"}`}>{gateFailed}</span>
411 <span class="pl-summary-label">Gate failures</span>
412 </div>
413 <div class="pl-summary-stat">
414 <span class={`pl-summary-val neutral`}>{gateTotal}</span>
415 <span class="pl-summary-label">Gate runs</span>
416 </div>
417 <div class="pl-summary-stat">
418 <span class={`pl-summary-val ${gateRepaired > 0 ? "warn" : "neutral"}`}>{gateRepaired}</span>
419 <span class="pl-summary-label">AI-repaired</span>
420 </div>
421 <div class="pl-summary-stat">
422 <span class={`pl-summary-val ${deployFailed === 0 ? "ok" : "err"}`}>{deployFailed}</span>
423 <span class="pl-summary-label">Deploy issues</span>
424 </div>
425 <div class="pl-summary-stat">
426 <span class={`pl-summary-val neutral`}>{deployTotal}</span>
427 <span class="pl-summary-label">Deployments</span>
428 </div>
429 </div>
430
431 {events.length === 0 ? (
432 <div class="pl-empty">
433 <div style="font-size:32px;margin-bottom:var(--space-3)">🚀</div>
434 <div style="font-weight:600;color:var(--text);margin-bottom:var(--space-2)">No pipeline events yet</div>
435 <div>Gate runs and deployments will appear here after your first push.</div>
436 </div>
437 ) : (
438 <div class="pl-timeline">
439 {events.map((e) => (
440 <div class="pl-event" key={e.id}>
441 <div class={`pl-event-dot ${dotCls(e)}`} />
442 <div class="pl-event-body">
443 <div class="pl-event-top">
444 <span class={`pl-event-kind ${e.kind}`}>
445 {e.kind === "gate" ? "Gate" : "Deploy"}
446 </span>
447 <span class="pl-event-name">
448 {e.kind === "gate" ? e.gateName : `${e.environment} deployment`}
449 </span>
450 {e.kind === "gate" && e.repairSucceeded && (
451 <span class="pl-repair-badge">⚡ AI-repaired</span>
452 )}
453 <span class={`pl-event-status ${e.kind === "gate" ? gateStatusCls(e.repairSucceeded ? "repaired" : e.status) : deployStatusCls(e.status)}`}>
454 {e.kind === "gate"
455 ? (e.repairSucceeded ? "repaired" : e.status)
456 : (e.status === "succeeded" ? "success" : e.status)}
457 </span>
458 <span class="pl-event-time">{relTime(e.createdAt)}</span>
459 </div>
460 <div class="pl-event-meta">
461 <span class="pl-sha">{e.commitSha.slice(0, 8)}</span>
462 <span>{e.ref.replace("refs/heads/", "")}</span>
463 {e.kind === "gate" && e.prNumber && (
464 <a href={`/${owner}/${repo}/pulls/${e.prNumber}`}>
465 PR #{e.prNumber}
466 </a>
467 )}
468 {e.kind === "gate" && e.durationMs && (
469 <span>{durationStr(0, e.durationMs)}</span>
470 )}
471 {e.kind === "gate" && !e.durationMs && e.completedAt && (
472 <span>{durationStr(e.createdAt.getTime(), e.completedAt.getTime())}</span>
473 )}
474 {e.kind === "deploy" && e.blockedReason && (
475 <span style="color:var(--warning)">{e.blockedReason.slice(0, 80)}</span>
476 )}
477 {e.kind === "deploy" && (
478 <a href={`/${owner}/${repo}/cloud-deployments`}>
479 Details →
480 </a>
481 )}
482 </div>
483 </div>
484 </div>
485 ))}
486 </div>
487 )}
488 </div>
489 </Layout>
490 );
491});
492
493export default app;