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

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

pulse.tsxBlame536 lines · 2 contributors
22f43f3Claude1/**
2 * Repository Pulse — a time-window snapshot of repo activity.
3 *
4 * Route: GET /:owner/:repo/pulse?window=1|7|30 (default 7 days)
5 *
6 * Shows:
7 * - Issues opened / closed in window
8 * - PRs opened / merged / closed in window
9 * - Active contributors (unique commit authors in window)
10 * - Gate activity (pass/fail counts)
11 * - Most active contributors (by PR + commit activity)
12 * - Streak: consecutive days with at least one merged PR
13 *
14 * Zero new DB tables. All data from: pull_requests, issues, users,
15 * repositories, activityFeed, gateRuns.
16 *
17 * Scoped CSS: `.pulse-*`
18 */
19
20import { Hono } from "hono";
21import { db } from "../db";
22import {
23 pullRequests,
24 issues,
25 users,
26 repositories,
27 activityFeed,
28 gateRuns,
29 prComments,
30} from "../db/schema";
31import { eq, and, gte, desc, sql, count, isNotNull } from "drizzle-orm";
32import type { AuthEnv } from "../middleware/auth";
33import { softAuth } from "../middleware/auth";
34import { requireRepoAccess } from "../middleware/repo-access";
35import { Layout } from "../views/layout";
36import { RepoHeader, RepoNav } from "../views/components";
37import { listCommits, getDefaultBranch } from "../git/repository";
38
39const pulseRoutes = new Hono<AuthEnv>();
40
41// Path-scoped middleware — NEVER use("*", ...)
03e6f9bccantynz-alt42// "path*" (no slash before the *) doesn't match the bare path in this Hono
43// version -- see admin-security.tsx's fix for the confirmed repro.
44pulseRoutes.use("/:owner/:repo/pulse", softAuth);
45pulseRoutes.use("/:owner/:repo/pulse/*", softAuth);
22f43f3Claude46
47// ─── helpers ─────────────────────────────────────────────────────────────────
48
49function windowStart(days: number): Date {
50 const d = new Date();
51 d.setDate(d.getDate() - days);
52 return d;
53}
54
55function formatDate(d: Date): string {
56 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
57}
58
59function pct(a: number, b: number): string {
60 if (!b) return "0%";
61 return `${Math.round((a / b) * 100)}%`;
62}
63
64// ─── route ───────────────────────────────────────────────────────────────────
65
66pulseRoutes.get(
67 "/:owner/:repo/pulse",
68 requireRepoAccess("read"),
69 async (c) => {
70 const { owner: ownerName, repo: repoName } = c.req.param();
71 const user = c.get("user");
72
73 const rawWindow = c.req.query("window");
74 const windowDays = rawWindow === "1" ? 1 : rawWindow === "30" ? 30 : 7;
75 const since = windowStart(windowDays);
76
77 // Load repo
78 const [ownerRow] = await db
79 .select({ id: users.id })
80 .from(users)
81 .where(eq(users.username, ownerName))
82 .limit(1);
83 if (!ownerRow) return c.notFound();
84
85 const [repo] = await db
86 .select()
87 .from(repositories)
88 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repoName)))
89 .limit(1);
90 if (!repo) return c.notFound();
91
92 // ── parallel queries ─────────────────────────────────────────────────────
93
94 const [
95 issuesOpened,
96 issuesClosed,
97 prsOpened,
98 prsMerged,
99 prsClosed,
100 gatesPassed,
101 gatesFailed,
102 reviewsPosted,
103 topPrAuthors,
104 recentActivity,
105 ] = await Promise.all([
106 // Issues opened in window
107 db
108 .select({ cnt: count() })
109 .from(issues)
110 .where(and(eq(issues.repositoryId, repo.id), gte(issues.createdAt, since)))
111 .then((r) => r[0]?.cnt ?? 0),
112
113 // Issues closed in window
114 db
115 .select({ cnt: count() })
116 .from(issues)
117 .where(
118 and(
119 eq(issues.repositoryId, repo.id),
120 eq(issues.state, "closed"),
121 gte(issues.updatedAt, since)
122 )
123 )
124 .then((r) => r[0]?.cnt ?? 0),
125
126 // PRs opened in window
127 db
128 .select({ cnt: count() })
129 .from(pullRequests)
130 .where(and(eq(pullRequests.repositoryId, repo.id), gte(pullRequests.createdAt, since)))
131 .then((r) => r[0]?.cnt ?? 0),
132
133 // PRs merged in window
134 db
135 .select({ cnt: count() })
136 .from(pullRequests)
137 .where(
138 and(
139 eq(pullRequests.repositoryId, repo.id),
140 eq(pullRequests.state, "merged"),
141 isNotNull(pullRequests.mergedAt),
142 gte(pullRequests.mergedAt!, since)
143 )
144 )
145 .then((r) => r[0]?.cnt ?? 0),
146
147 // PRs closed (not merged) in window
148 db
149 .select({ cnt: count() })
150 .from(pullRequests)
151 .where(
152 and(
153 eq(pullRequests.repositoryId, repo.id),
154 eq(pullRequests.state, "closed"),
155 gte(pullRequests.updatedAt, since)
156 )
157 )
158 .then((r) => r[0]?.cnt ?? 0),
159
160 // Gates passed in window
161 db
162 .select({ cnt: count() })
163 .from(gateRuns)
164 .where(
165 and(
166 eq(gateRuns.repositoryId, repo.id),
167 eq(gateRuns.status, "passed"),
168 gte(gateRuns.createdAt, since)
169 )
170 )
171 .then((r) => r[0]?.cnt ?? 0),
172
173 // Gates failed in window
174 db
175 .select({ cnt: count() })
176 .from(gateRuns)
177 .where(
178 and(
179 eq(gateRuns.repositoryId, repo.id),
180 eq(gateRuns.status, "failed"),
181 gte(gateRuns.createdAt, since)
182 )
183 )
184 .then((r) => r[0]?.cnt ?? 0),
185
186 // Code reviews posted in window (non-AI)
187 db
188 .select({ cnt: count() })
189 .from(prComments)
190 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
191 .where(
192 and(
193 eq(pullRequests.repositoryId, repo.id),
194 eq(prComments.isAiReview, false),
195 gte(prComments.createdAt, since)
196 )
197 )
198 .then((r) => r[0]?.cnt ?? 0),
199
200 // Top PR contributors in window (by PRs opened)
201 db
202 .select({
203 authorId: pullRequests.authorId,
204 username: users.username,
205 prsOpened: count(),
206 })
207 .from(pullRequests)
208 .innerJoin(users, eq(pullRequests.authorId, users.id))
209 .where(
210 and(eq(pullRequests.repositoryId, repo.id), gte(pullRequests.createdAt, since))
211 )
212 .groupBy(pullRequests.authorId, users.username)
213 .orderBy(desc(count()))
214 .limit(8),
215
216 // Recent activity feed entries
217 db
218 .select({
219 action: activityFeed.action,
220 targetType: activityFeed.targetType,
221 targetId: activityFeed.targetId,
222 createdAt: activityFeed.createdAt,
223 username: users.username,
224 })
225 .from(activityFeed)
226 .leftJoin(users, eq(activityFeed.userId, users.id))
227 .where(
228 and(eq(activityFeed.repositoryId, repo.id), gte(activityFeed.createdAt, since))
229 )
230 .orderBy(desc(activityFeed.createdAt))
231 .limit(20),
232 ]);
233
234 // Commit count from git log (best-effort)
235 let commitCount = 0;
236 let activeContributors = new Set<string>();
237 try {
238 const defaultBranch = (await getDefaultBranch(ownerName, repoName)) ?? "main";
239 const commits = await listCommits(ownerName, repoName, defaultBranch, 200);
240 const cutoff = since.getTime();
241 for (const c of commits) {
242 const ts = new Date(c.date).getTime();
243 if (ts >= cutoff) {
244 commitCount++;
245 const email = (c as { authorEmail?: string }).authorEmail;
246 if (email) activeContributors.add(email);
247 }
248 }
249 } catch {
250 // If repo has no commits or git fails, silently skip
251 }
252
253 const windowLabel =
254 windowDays === 1 ? "last 24 hours" : `last ${windowDays} days`;
255 const totalGates = Number(gatesPassed) + Number(gatesFailed);
256 const gatePassRate = totalGates > 0
257 ? Math.round((Number(gatesPassed) / totalGates) * 100)
258 : null;
259
260 return c.html(
261 <Layout
262 title={`Pulse — ${ownerName}/${repoName}`}
263 user={user}
264 >
265 <PulseStyle />
266 <RepoHeader owner={ownerName} repo={repoName} />
267 <RepoNav owner={ownerName} repo={repoName} active="insights" />
268 <div class="pulse-page">
269 {/* Window selector */}
270 <div class="pulse-header">
271 <h2 class="pulse-title">Pulse</h2>
272 <div class="pulse-windows">
273 {(["1", "7", "30"] as const).map((w) => (
274 <a
275 href={`/${ownerName}/${repoName}/pulse?window=${w}`}
276 class={`pulse-window-btn${windowDays === Number(w) ? " is-active" : ""}`}
277 >
278 {w === "1" ? "24h" : w === "7" ? "7d" : "30d"}
279 </a>
280 ))}
281 </div>
282 <p class="pulse-subtitle">
283 Activity overview for the <strong>{windowLabel}</strong> (
284 {formatDate(since)} – {formatDate(new Date())})
285 </p>
286 </div>
287
288 {/* Summary stat cards */}
289 <div class="pulse-cards">
290 <StatCard
291 label="Issues opened"
292 value={Number(issuesOpened)}
293 sub={`${Number(issuesClosed)} closed`}
294 color="#f59e0b"
295 />
296 <StatCard
297 label="PRs opened"
298 value={Number(prsOpened)}
299 sub={`${Number(prsMerged)} merged · ${Number(prsClosed)} closed`}
6fd5915Claude300 color="#5b6ee8"
22f43f3Claude301 />
302 <StatCard
303 label="Commits"
304 value={commitCount}
305 sub={`by ${activeContributors.size} contributor${activeContributors.size !== 1 ? "s" : ""}`}
6fd5915Claude306 color="#5f8fa0"
22f43f3Claude307 />
308 <StatCard
309 label="Code reviews"
310 value={Number(reviewsPosted)}
311 sub="human reviews posted"
312 color="#22c55e"
313 />
314 {gatePassRate !== null && (
315 <StatCard
316 label="Gate pass rate"
317 value={gatePassRate}
318 suffix="%"
319 sub={`${Number(gatesPassed)}/${totalGates} checks`}
320 color={gatePassRate >= 80 ? "#22c55e" : gatePassRate >= 50 ? "#f59e0b" : "#ef4444"}
321 />
322 )}
323 </div>
324
325 <div class="pulse-body">
326 {/* Top contributors */}
327 {topPrAuthors.length > 0 && (
328 <section class="pulse-section">
329 <h3 class="pulse-section-title">Top contributors this period</h3>
330 <div class="pulse-contributors">
331 {topPrAuthors.map((row) => (
332 <a
333 href={`/${row.username}`}
334 class="pulse-contrib-row"
335 >
336 <span class="pulse-contrib-avatar">
337 {row.username.slice(0, 1).toUpperCase()}
338 </span>
339 <span class="pulse-contrib-name">{row.username}</span>
340 <span class="pulse-contrib-count">
341 {Number(row.prsOpened)} PR{Number(row.prsOpened) !== 1 ? "s" : ""}
342 </span>
343 </a>
344 ))}
345 </div>
346 </section>
347 )}
348
349 {/* Gate health */}
350 {totalGates > 0 && (
351 <section class="pulse-section">
352 <h3 class="pulse-section-title">Gate health</h3>
353 <div class="pulse-gate-bar-wrap">
354 <div class="pulse-gate-bar">
355 <div
356 class="pulse-gate-bar-pass"
357 style={`width:${pct(Number(gatesPassed), totalGates)}`}
358 title={`${gatesPassed} passed`}
359 />
360 <div
361 class="pulse-gate-bar-fail"
362 style={`width:${pct(Number(gatesFailed), totalGates)}`}
363 title={`${gatesFailed} failed`}
364 />
365 </div>
366 <span class="pulse-gate-bar-label">
367 {gatesPassed} passed · {gatesFailed} failed
368 {gatePassRate !== null && ` (${gatePassRate}%)`}
369 </span>
370 </div>
371 </section>
372 )}
373
374 {/* Recent activity */}
375 {recentActivity.length > 0 && (
376 <section class="pulse-section">
377 <h3 class="pulse-section-title">Recent activity</h3>
378 <ul class="pulse-activity-list">
379 {recentActivity.slice(0, 12).map((ev) => (
380 <li class="pulse-activity-item">
381 <span class="pulse-activity-icon">{activityIcon(ev.action)}</span>
382 <span class="pulse-activity-text">
383 {ev.username && (
384 <a href={`/${ev.username}`} class="pulse-activity-user">
385 {ev.username}
386 </a>
387 )}
388 {" "}
389 {activityLabel(ev.action, ev.targetType ?? null, ev.targetId ?? null)}
390 </span>
391 <span class="pulse-activity-time">
392 {formatDate(new Date(ev.createdAt))}
393 </span>
394 </li>
395 ))}
396 </ul>
397 </section>
398 )}
399
400 {recentActivity.length === 0 && commitCount === 0 && (
401 <div class="pulse-empty">
402 <p>No activity in the {windowLabel}.</p>
403 <a href="?window=30" class="btn">View last 30 days</a>
404 </div>
405 )}
406 </div>
407 </div>
408 </Layout>
409 );
410 }
411);
412
413// ─── Sub-components ───────────────────────────────────────────────────────────
414
415function StatCard({
416 label,
417 value,
418 sub,
419 color,
420 suffix = "",
421}: {
422 label: string;
423 value: number;
424 sub?: string;
425 color: string;
426 suffix?: string;
427}) {
428 return (
429 <div class="pulse-card" style={`border-top-color:${color}`}>
430 <div class="pulse-card-value" style={`color:${color}`}>
431 {value.toLocaleString()}{suffix}
432 </div>
433 <div class="pulse-card-label">{label}</div>
434 {sub && <div class="pulse-card-sub">{sub}</div>}
435 </div>
436 );
437}
438
439function activityIcon(action: string): string {
440 if (action.includes("issue")) return "◦";
441 if (action.includes("pr") || action.includes("pull_request")) return "↑";
442 if (action.includes("deploy")) return "▶";
443 if (action.includes("gate")) return "✓";
444 if (action.includes("repo")) return "⊞";
445 return "·";
446}
447
448function activityLabel(action: string, targetType: string | null, targetId: string | null): string {
449 const ref = targetId ? `#${targetId}` : "";
450 if (action === "pull_request.opened") return `opened PR ${ref}`;
451 if (action === "pull_request.merged") return `merged PR ${ref}`;
452 if (action === "pull_request.closed") return `closed PR ${ref}`;
453 if (action === "issue.opened") return `opened issue ${ref}`;
454 if (action === "issue.closed") return `closed issue ${ref}`;
455 if (action === "repo_created") return "created repository";
456 if (action === "deploy_success") return "deployed successfully";
457 if (action === "deploy_failed") return "deployment failed";
458 if (action.includes("gate")) return `gate ${action.split(".")[1] || "run"}`;
459 return action.replace(/_/g, " ").replace(/\./g, " ");
460}
461
462// ─── Styles ───────────────────────────────────────────────────────────────────
463
464function PulseStyle() {
465 return (
466 <style>{`
eed4684Claude467 .pulse-page { max-width: 1320px; margin: 0 auto; padding: 0 16px 48px; }
22f43f3Claude468 .pulse-header { margin: 24px 0 20px; }
469 .pulse-title { font-size: 22px; font-weight: 700; margin: 0 0 8px; }
470 .pulse-subtitle { font-size: 14px; color: var(--fg-muted); margin: 8px 0 0; }
471 .pulse-windows { display: flex; gap: 8px; margin-bottom: 8px; }
472 .pulse-window-btn {
473 padding: 5px 14px; border-radius: 20px; font-size: 13px; font-weight: 600;
474 background: var(--bg-elevated); border: 1px solid var(--border); color: var(--fg);
475 text-decoration: none; transition: border-color .15s;
476 }
477 .pulse-window-btn:hover { border-color: var(--accent); }
478 .pulse-window-btn.is-active { background: var(--accent); border-color: var(--accent); color: #fff; }
479
480 .pulse-cards {
481 display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
482 gap: 14px; margin-bottom: 32px;
483 }
484 .pulse-card {
485 background: var(--bg-elevated); border: 1px solid var(--border);
486 border-radius: 12px; border-top: 3px solid var(--accent);
487 padding: 18px 16px;
488 }
489 .pulse-card-value { font-size: 28px; font-weight: 800; line-height: 1; margin-bottom: 4px; }
490 .pulse-card-label { font-size: 12px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: .04em; }
491 .pulse-card-sub { font-size: 12px; color: var(--fg-muted); margin-top: 4px; }
492
493 .pulse-body { display: flex; flex-direction: column; gap: 28px; }
494 .pulse-section { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 12px; padding: 20px 22px; }
495 .pulse-section-title { font-size: 15px; font-weight: 700; margin: 0 0 14px; }
496
497 .pulse-contributors { display: flex; flex-wrap: wrap; gap: 10px; }
498 .pulse-contrib-row {
499 display: flex; align-items: center; gap: 8px;
500 background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
501 padding: 7px 12px; text-decoration: none; color: var(--fg); font-size: 13px;
502 transition: border-color .15s;
503 }
504 .pulse-contrib-row:hover { border-color: var(--accent); }
505 .pulse-contrib-avatar {
506 width: 26px; height: 26px; border-radius: 50%; background: var(--accent);
507 color: #fff; display: flex; align-items: center; justify-content: center;
508 font-size: 11px; font-weight: 700; flex-shrink: 0;
509 }
510 .pulse-contrib-name { font-weight: 600; }
511 .pulse-contrib-count { color: var(--fg-muted); }
512
513 .pulse-gate-bar-wrap { display: flex; align-items: center; gap: 12px; }
514 .pulse-gate-bar {
515 flex: 1; height: 12px; border-radius: 6px; background: var(--bg); overflow: hidden;
516 display: flex;
517 }
518 .pulse-gate-bar-pass { background: #22c55e; height: 100%; transition: width .3s; }
519 .pulse-gate-bar-fail { background: #ef4444; height: 100%; transition: width .3s; }
520 .pulse-gate-bar-label { font-size: 13px; color: var(--fg-muted); white-space: nowrap; }
521
522 .pulse-activity-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
523 .pulse-activity-item { display: flex; align-items: baseline; gap: 8px; font-size: 13px; }
524 .pulse-activity-icon { font-size: 14px; color: var(--accent); width: 16px; flex-shrink: 0; }
525 .pulse-activity-text { flex: 1; color: var(--fg); }
526 .pulse-activity-user { font-weight: 600; color: var(--fg); text-decoration: none; }
527 .pulse-activity-user:hover { text-decoration: underline; }
528 .pulse-activity-time { color: var(--fg-muted); font-size: 12px; white-space: nowrap; }
529
530 .pulse-empty { text-align: center; padding: 40px 0; color: var(--fg-muted); }
531 .pulse-empty p { margin-bottom: 14px; }
532 `}</style>
533 );
534}
535
536export default pulseRoutes;