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

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