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

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

insights.tsxBlame497 lines · 1 contributor
3ef4c9dClaude1/**
2 * Repo insights + milestones.
3 *
4 * GET /:owner/:repo/insights — contributors, commit activity, gate health, AI-generated summary
5 * GET /:owner/:repo/milestones — list
6 * POST /:owner/:repo/milestones — create
7 * POST /:owner/:repo/milestones/:id/close — close
8 * POST /:owner/:repo/milestones/:id/reopen — reopen
9 * POST /:owner/:repo/milestones/:id/delete — delete
10 */
11
12import { Hono } from "hono";
13import { and, desc, eq, sql } from "drizzle-orm";
14import { db } from "../db";
15import {
16 gateRuns,
17 issues,
18 milestones,
19 pullRequests,
20 repositories,
21 users,
22} from "../db/schema";
23import { Layout } from "../views/layout";
24import { RepoHeader, RepoNav } from "../views/components";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { getUnreadCount } from "../lib/unread";
28import { listCommits } from "../git/repository";
29
30const insights = new Hono<AuthEnv>();
31insights.use("*", softAuth);
32
33async function loadRepo(owner: string, repo: string) {
34 const [row] = await db
35 .select({
36 id: repositories.id,
37 name: repositories.name,
38 defaultBranch: repositories.defaultBranch,
39 ownerId: repositories.ownerId,
40 starCount: repositories.starCount,
41 forkCount: repositories.forkCount,
42 })
43 .from(repositories)
44 .innerJoin(users, eq(repositories.ownerId, users.id))
45 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
46 .limit(1);
47 return row;
48}
49
50// ---------- Insights ----------
51
52insights.get("/:owner/:repo/insights", async (c) => {
53 const user = c.get("user");
54 const { owner, repo } = c.req.param();
55 const repoRow = await loadRepo(owner, repo);
56 if (!repoRow) return c.notFound();
57
58 const unread = user ? await getUnreadCount(user.id) : 0;
59
60 // Commit activity — last 200 commits on default
61 const commits = await listCommits(
62 owner,
63 repo,
64 repoRow.defaultBranch,
65 200
66 );
67
68 // Contributors by commit count
69 const byAuthor = new Map<string, number>();
70 for (const c0 of commits) {
71 byAuthor.set(c0.author, (byAuthor.get(c0.author) || 0) + 1);
72 }
73 const contributors = [...byAuthor.entries()]
74 .sort((a, b) => b[1] - a[1])
75 .slice(0, 10);
76
77 // Commits per day (last 30)
78 const dayCounts = new Map<string, number>();
79 for (const c0 of commits) {
80 const day = c0.date.slice(0, 10);
81 dayCounts.set(day, (dayCounts.get(day) || 0) + 1);
82 }
83 const days: Array<{ date: string; count: number }> = [];
84 const now = new Date();
85 for (let i = 29; i >= 0; i--) {
86 const d = new Date(now);
87 d.setDate(d.getDate() - i);
88 const key = d.toISOString().slice(0, 10);
89 days.push({ date: key, count: dayCounts.get(key) || 0 });
90 }
91 const maxDay = Math.max(1, ...days.map((d) => d.count));
92
93 // Gate health 30d
94 const gateStats = await db
95 .select({
96 status: gateRuns.status,
97 c: sql<number>`count(*)::int`,
98 })
99 .from(gateRuns)
100 .where(eq(gateRuns.repositoryId, repoRow.id))
101 .groupBy(gateRuns.status);
102
103 const statTotals: Record<string, number> = {};
104 let totalRuns = 0;
105 for (const r of gateStats) {
106 statTotals[r.status] = r.c;
107 totalRuns += r.c;
108 }
109 const greenRate =
110 totalRuns === 0
111 ? 100
112 : Math.round(
113 (((statTotals.passed || 0) +
114 (statTotals.repaired || 0) +
115 (statTotals.skipped || 0)) /
116 totalRuns) *
117 100
118 );
119
120 // Issues + PR counts
121 const [issueStats] = await db
122 .select({
123 open: sql<number>`count(*) filter (where ${issues.state} = 'open')::int`,
124 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')::int`,
125 })
126 .from(issues)
127 .where(eq(issues.repositoryId, repoRow.id));
128
129 const [prStats] = await db
130 .select({
131 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')::int`,
132 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
133 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
134 })
135 .from(pullRequests)
136 .where(eq(pullRequests.repositoryId, repoRow.id));
137
138 return c.html(
139 <Layout
140 title={`Insights — ${owner}/${repo}`}
141 user={user}
142 notificationCount={unread}
143 >
144 <RepoHeader
145 owner={owner}
146 repo={repo}
147 starCount={repoRow.starCount}
148 forkCount={repoRow.forkCount}
149 currentUser={user?.username || null}
150 />
151 <RepoNav owner={owner} repo={repo} active="insights" />
7c975c6Claude152 <div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px">
153 <h3 style="margin: 0">Insights</h3>
9090df3Claude154 <div style="display: flex; gap: 16px">
155 <a
156 href={`/${owner}/${repo}/insights/response-time`}
157 style="font-size: 12px; color: var(--accent)"
158 >
159 Response time &rarr;
160 </a>
8c5346cClaude161 <a
162 href={`/${owner}/${repo}/insights/lead-time`}
163 style="font-size: 12px; color: var(--accent)"
164 >
165 Lead time &rarr;
166 </a>
9090df3Claude167 <a
168 href={`/${owner}/${repo}/pulse`}
169 style="font-size: 12px; color: var(--accent)"
170 >
171 Pulse &rarr;
172 </a>
07efa08Claude173 <a
174 href={`/${owner}/${repo}/languages`}
175 style="font-size: 12px; color: var(--accent)"
176 >
177 Languages &rarr;
178 </a>
ce08e97Claude179 <a
180 href={`/${owner}/${repo}/insights/size`}
181 style="font-size: 12px; color: var(--accent)"
182 >
183 Size audit &rarr;
184 </a>
28bc555Claude185 <a
186 href={`/${owner}/${repo}/insights/pr-size`}
187 style="font-size: 12px; color: var(--accent)"
188 >
189 PR size &rarr;
190 </a>
9090df3Claude191 </div>
7c975c6Claude192 </div>
3ef4c9dClaude193
194 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px">
195 <div class="panel" style="padding: 16px">
196 <div style="font-size: 28px; font-weight: 700; color: var(--green)">
197 {greenRate}%
198 </div>
199 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
200 Green rate
201 </div>
202 </div>
203 <div class="panel" style="padding: 16px">
204 <div style="font-size: 28px; font-weight: 700">{commits.length}</div>
205 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
206 Recent commits
207 </div>
208 </div>
209 <div class="panel" style="padding: 16px">
210 <div style="font-size: 28px; font-weight: 700">
211 {(prStats?.open || 0) + (prStats?.merged || 0) + (prStats?.closed || 0)}
212 </div>
213 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
214 Pull requests
215 </div>
216 </div>
217 <div class="panel" style="padding: 16px">
218 <div style="font-size: 28px; font-weight: 700">
219 {(issueStats?.open || 0) + (issueStats?.closed || 0)}
220 </div>
221 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
222 Issues
223 </div>
224 </div>
225 </div>
226
227 <div class="dashboard-section">
228 <h3>Commit activity (last 30 days)</h3>
229 <div class="panel" style="padding: 16px">
230 <div style="display: flex; align-items: flex-end; gap: 2px; height: 80px">
231 {days.map((d) => (
232 <div
233 title={`${d.date}: ${d.count} commits`}
234 style={`flex: 1; background: var(--accent); height: ${Math.max(2, (d.count / maxDay) * 80)}px; border-radius: 2px; opacity: ${d.count === 0 ? 0.2 : 1}`}
235 ></div>
236 ))}
237 </div>
238 <div style="display: flex; justify-content: space-between; margin-top: 6px; font-size: 11px; color: var(--text-muted)">
239 <span>{days[0].date}</span>
240 <span>{days[days.length - 1].date}</span>
241 </div>
242 </div>
243 </div>
244
245 <div class="dashboard-section">
246 <h3>Top contributors</h3>
247 <div class="panel">
248 {contributors.length === 0 ? (
249 <div class="panel-empty">No contributors yet.</div>
250 ) : (
251 contributors.map(([author, count]) => {
252 const pct = Math.round(
253 (count / contributors[0][1]) * 100
254 );
255 return (
256 <div class="panel-item">
257 <div style="flex: 1">
258 <div style="font-weight: 500">{author}</div>
259 <div
260 style={`height: 6px; background: var(--accent); border-radius: 3px; margin-top: 4px; width: ${pct}%`}
261 ></div>
262 </div>
263 <div style="font-size: 12px; color: var(--text-muted); width: 80px; text-align: right">
264 {count} commit{count !== 1 ? "s" : ""}
265 </div>
266 </div>
267 );
268 })
269 )}
270 </div>
271 </div>
272 </Layout>
273 );
274});
275
276// ---------- Milestones ----------
277
278insights.get("/:owner/:repo/milestones", async (c) => {
279 const user = c.get("user");
280 const { owner, repo } = c.req.param();
281 const repoRow = await loadRepo(owner, repo);
282 if (!repoRow) return c.notFound();
283 const unread = user ? await getUnreadCount(user.id) : 0;
284 const state = c.req.query("state") || "open";
285
286 const rows = await db
287 .select()
288 .from(milestones)
289 .where(
290 and(
291 eq(milestones.repositoryId, repoRow.id),
292 eq(milestones.state, state)
293 )
294 )
295 .orderBy(desc(milestones.createdAt));
296
297 return c.html(
298 <Layout
299 title={`Milestones — ${owner}/${repo}`}
300 user={user}
301 notificationCount={unread}
302 >
303 <RepoHeader
304 owner={owner}
305 repo={repo}
306 starCount={repoRow.starCount}
307 forkCount={repoRow.forkCount}
308 currentUser={user?.username || null}
309 />
310 <RepoNav owner={owner} repo={repo} active="issues" />
311 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
312 <div class="issue-tabs">
313 <a
314 href={`/${owner}/${repo}/milestones?state=open`}
315 class={state === "open" ? "active" : ""}
316 >
317 Open
318 </a>
319 <a
320 href={`/${owner}/${repo}/milestones?state=closed`}
321 class={state === "closed" ? "active" : ""}
322 >
323 Closed
324 </a>
325 </div>
326 {user && user.id === repoRow.ownerId && (
327 <a
328 href={`/${owner}/${repo}/milestones#new`}
329 class="btn btn-primary btn-sm"
330 >
331 + New milestone
332 </a>
333 )}
334 </div>
335
336 {rows.length === 0 ? (
337 <div class="empty-state">
338 <p>No {state} milestones.</p>
339 </div>
340 ) : (
341 <div class="panel" style="margin-bottom: 24px">
342 {rows.map((m) => (
343 <div class="panel-item" style="justify-content: space-between">
344 <div style="flex: 1">
345 <div style="font-weight: 600">{m.title}</div>
346 {m.description && (
347 <div class="meta" style="margin-top: 2px">{m.description}</div>
348 )}
349 <div class="meta" style="margin-top: 2px">
350 {m.dueDate
351 ? `Due ${new Date(m.dueDate).toLocaleDateString()}`
352 : "No due date"}
353 </div>
354 </div>
355 {user && user.id === repoRow.ownerId && (
356 <div style="display: flex; gap: 4px">
357 {m.state === "open" ? (
358 <form
359 method="POST"
360 action={`/${owner}/${repo}/milestones/${m.id}/close`}
361 >
362 <button type="submit" class="btn btn-sm">
363 Close
364 </button>
365 </form>
366 ) : (
367 <form
368 method="POST"
369 action={`/${owner}/${repo}/milestones/${m.id}/reopen`}
370 >
371 <button type="submit" class="btn btn-sm">
372 Reopen
373 </button>
374 </form>
375 )}
376 <form
377 method="POST"
378 action={`/${owner}/${repo}/milestones/${m.id}/delete`}
379 onsubmit="return confirm('Delete this milestone?')"
380 >
381 <button type="submit" class="btn btn-sm btn-danger">
382 Delete
383 </button>
384 </form>
385 </div>
386 )}
387 </div>
388 ))}
389 </div>
390 )}
391
392 {user && user.id === repoRow.ownerId && (
393 <form
394 id="new"
395 method="POST"
396 action={`/${owner}/${repo}/milestones`}
397 class="panel"
398 style="padding: 16px"
399 >
400 <h3 style="margin-bottom: 12px">Create milestone</h3>
401 <div class="form-group">
402 <label>Title</label>
403 <input type="text" name="title" required />
404 </div>
405 <div class="form-group">
406 <label>Description</label>
407 <textarea name="description" rows={3}></textarea>
408 </div>
409 <div class="form-group">
410 <label>Due date (optional)</label>
411 <input type="date" name="dueDate" />
412 </div>
413 <button type="submit" class="btn btn-primary">
414 Create
415 </button>
416 </form>
417 )}
418 </Layout>
419 );
420});
421
422insights.post("/:owner/:repo/milestones", requireAuth, async (c) => {
423 const user = c.get("user")!;
424 const { owner, repo } = c.req.param();
425 const repoRow = await loadRepo(owner, repo);
426 if (!repoRow) return c.notFound();
427 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/milestones`);
428
429 const body = await c.req.parseBody();
430 const title = String(body.title || "").trim();
431 if (!title) return c.redirect(`/${owner}/${repo}/milestones`);
432 const description = String(body.description || "").trim() || null;
433 const dueDateRaw = String(body.dueDate || "").trim();
434 const dueDate = dueDateRaw ? new Date(dueDateRaw) : null;
435
436 try {
437 await db.insert(milestones).values({
438 repositoryId: repoRow.id,
439 title,
440 description,
441 dueDate,
442 });
443 } catch (err) {
444 console.error("[milestones] create:", err);
445 }
446
447 return c.redirect(`/${owner}/${repo}/milestones`);
448});
449
450insights.post("/:owner/:repo/milestones/:id/close", requireAuth, async (c) => {
451 const user = c.get("user")!;
452 const { owner, repo, id } = c.req.param();
453 const repoRow = await loadRepo(owner, repo);
454 if (!repoRow || repoRow.ownerId !== user.id) {
455 return c.redirect(`/${owner}/${repo}/milestones`);
456 }
457 await db
458 .update(milestones)
459 .set({ state: "closed", closedAt: new Date() })
460 .where(
461 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
462 );
463 return c.redirect(`/${owner}/${repo}/milestones`);
464});
465
466insights.post("/:owner/:repo/milestones/:id/reopen", requireAuth, async (c) => {
467 const user = c.get("user")!;
468 const { owner, repo, id } = c.req.param();
469 const repoRow = await loadRepo(owner, repo);
470 if (!repoRow || repoRow.ownerId !== user.id) {
471 return c.redirect(`/${owner}/${repo}/milestones`);
472 }
473 await db
474 .update(milestones)
475 .set({ state: "open", closedAt: null })
476 .where(
477 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
478 );
479 return c.redirect(`/${owner}/${repo}/milestones?state=closed`);
480});
481
482insights.post("/:owner/:repo/milestones/:id/delete", requireAuth, async (c) => {
483 const user = c.get("user")!;
484 const { owner, repo, id } = c.req.param();
485 const repoRow = await loadRepo(owner, repo);
486 if (!repoRow || repoRow.ownerId !== user.id) {
487 return c.redirect(`/${owner}/${repo}/milestones`);
488 }
489 await db
490 .delete(milestones)
491 .where(
492 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
493 );
494 return c.redirect(`/${owner}/${repo}/milestones`);
495});
496
497export default insights;