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

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

inbox.tsxBlame1081 lines · 1 contributor
e9aa4d8Claude1/**
2 * `/inbox` — Unified developer inbox.
3 *
4 * One screen, every signal that needs the user's attention. Replaces
5 * what Slack / email do for developers: a single chronological timeline
6 * of @mentions, review requests, CI failures, AI findings, and
7 * auto-merge events from every repo the user touches.
8 *
9 * Sources (all best-effort — any one failing returns empty rows rather
10 * than 500'ing the whole page):
11 * - mentions: pr_comments + issue_comments where body matches @username
12 * - review: open PRs in user's repos with no AI verdict yet
13 * - ci: workflow_runs status='failure' in user's repos, last 24h
14 * - ai (findings): repo_advisory_alerts on user's repos (status='open')
15 * - ai (auto-merge): pull_requests merged where mergedAt is set, in user's repos
16 *
17 * Filter tabs: all | mentions | review | ci | ai
18 * Scoped CSS under `.inbox-*`. Cap to 100 rows after merge + sort desc.
19 *
20 * The pure helpers `mergeAndCapInboxRows` and `filterInboxRows` are
21 * exported for unit testing — the route handler itself runs DB I/O and
22 * is best-effort by design.
23 */
24
25import { Hono } from "hono";
26import { eq, and, desc, inArray, sql, isNull, isNotNull, gte } from "drizzle-orm";
27import { db } from "../db";
28import {
29 pullRequests,
30 repositories,
31 users,
32 repoCollaborators,
33 prComments,
34 issues,
35 issueComments,
36 repoAdvisoryAlerts,
37 securityAdvisories,
38 workflowRuns,
39 workflows,
40} from "../db/schema";
41import { notifications as notifTable } from "../db/schema-extensions";
42import { Layout } from "../views/layout";
43import { softAuth, requireAuth } from "../middleware/auth";
44import type { AuthEnv } from "../middleware/auth";
45
46const inboxRoutes = new Hono<AuthEnv>();
47inboxRoutes.use("*", softAuth);
48
49// ---------------------------------------------------------------------------
50// Types — kept narrow & explicit so the merge step has a single shape.
51// ---------------------------------------------------------------------------
52
53export type InboxKind = "mention" | "review" | "ci" | "ai-finding" | "ai-merge";
54
55export interface InboxRow {
56 id: string; // stable per-row id (kind + source row id)
57 kind: InboxKind;
58 title: string; // primary text shown on the row
59 sourceText: string; // owner/repo#N or workflow-run-N
60 sourceUrl: string; // where the row opens to
61 createdAt: Date;
62 body?: string | null; // optional secondary line (comment snippet, etc)
63}
64
65// ---------------------------------------------------------------------------
66// Pure helpers — unit-tested.
67// ---------------------------------------------------------------------------
68
69/**
70 * Merge rows from every source, sort by timestamp desc, cap to `cap`.
71 * Defensive against undefined arrays so a missing source becomes [].
72 */
73export function mergeAndCapInboxRows(
74 sources: Array<InboxRow[] | undefined | null>,
75 cap = 100
76): InboxRow[] {
77 const merged: InboxRow[] = [];
78 for (const src of sources) {
79 if (!src) continue;
80 for (const row of src) merged.push(row);
81 }
82 merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
83 return merged.slice(0, cap);
84}
85
86/** Tab predicate. `all` returns everything; `ai` covers both AI kinds. */
87export function filterInboxRows(rows: InboxRow[], tab: InboxFilter): InboxRow[] {
88 if (tab === "all") return rows;
89 if (tab === "mentions") return rows.filter((r) => r.kind === "mention");
90 if (tab === "review") return rows.filter((r) => r.kind === "review");
91 if (tab === "ci") return rows.filter((r) => r.kind === "ci");
92 if (tab === "ai")
93 return rows.filter((r) => r.kind === "ai-finding" || r.kind === "ai-merge");
94 return rows;
95}
96
97export type InboxFilter = "all" | "mentions" | "review" | "ci" | "ai";
98
99const VALID_FILTERS: InboxFilter[] = ["all", "mentions", "review", "ci", "ai"];
100
101function relTime(d: Date): string {
102 const diff = Date.now() - d.getTime();
103 const s = Math.floor(diff / 1000);
104 if (s < 60) return `${s}s ago`;
105 const m = Math.floor(s / 60);
106 if (m < 60) return `${m}m ago`;
107 const h = Math.floor(m / 60);
108 if (h < 24) return `${h}h ago`;
109 const days = Math.floor(h / 24);
110 if (days < 30) return `${days}d ago`;
111 const months = Math.floor(days / 30);
112 if (months < 12) return `${months}mo ago`;
113 return `${Math.floor(months / 12)}y ago`;
114}
115
116// ---------------------------------------------------------------------------
117// Scoped CSS — all classes prefixed `.inbox-` so nothing leaks. Mirrors the
118// pulls-dashboard visual language: gradient hairline + orb + clamp() title.
119// ---------------------------------------------------------------------------
120const styles = `
eed4684Claude121 .inbox-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e9aa4d8Claude122
123 .inbox-hero {
124 position: relative;
125 margin-bottom: var(--space-5);
126 padding: var(--space-5) var(--space-6);
127 background: var(--bg-elevated);
128 border: 1px solid var(--border);
129 border-radius: 16px;
130 overflow: hidden;
131 }
132 .inbox-hero::before {
133 content: '';
134 position: absolute;
135 top: 0; left: 0; right: 0;
136 height: 2px;
137 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
138 opacity: 0.7;
139 pointer-events: none;
140 }
141 .inbox-orb {
142 position: absolute;
143 inset: -30% -10% auto auto;
144 width: 380px; height: 380px;
145 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
146 filter: blur(80px);
147 opacity: 0.7;
148 pointer-events: none;
149 }
150 .inbox-hero-inner { position: relative; z-index: 1; max-width: 760px; }
151 .inbox-eyebrow {
152 font-size: 12.5px;
153 color: var(--text-muted);
154 margin-bottom: 8px;
155 letter-spacing: 0.04em;
156 text-transform: uppercase;
157 font-weight: 600;
158 }
159 .inbox-title {
160 font-family: var(--font-display);
161 font-size: clamp(28px, 4vw, 40px);
162 font-weight: 800;
163 letter-spacing: -0.028em;
164 line-height: 1.05;
165 margin: 0 0 10px;
166 color: var(--text-strong);
167 }
168 .inbox-title-grad {
169 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
170 -webkit-background-clip: text;
171 background-clip: text;
172 -webkit-text-fill-color: transparent;
173 color: transparent;
174 }
175 .inbox-sub {
176 font-size: 15px;
177 color: var(--text-muted);
178 margin: 0;
179 line-height: 1.5;
180 }
181
182 .inbox-stats {
183 display: flex;
184 gap: 24px;
185 margin-top: 16px;
186 flex-wrap: wrap;
187 }
188 .inbox-stat {
189 display: flex;
190 flex-direction: column;
191 gap: 2px;
192 }
193 .inbox-stat-n {
194 font-family: var(--font-display);
195 font-size: 22px;
196 font-weight: 700;
197 color: var(--text-strong);
198 font-variant-numeric: tabular-nums;
199 }
200 .inbox-stat-l {
201 font-size: 11.5px;
202 color: var(--text-muted);
203 letter-spacing: 0.02em;
204 text-transform: uppercase;
205 }
206
207 .inbox-tabs {
208 display: inline-flex;
209 background: var(--bg-elevated);
210 border: 1px solid var(--border);
211 border-radius: 9999px;
212 padding: 4px;
213 gap: 2px;
214 margin-bottom: var(--space-4);
215 flex-wrap: wrap;
216 }
217 .inbox-tab {
218 display: inline-flex;
219 align-items: center;
220 gap: 6px;
221 padding: 7px 14px;
222 border-radius: 9999px;
223 font-size: 13.5px;
224 font-weight: 500;
225 color: var(--text-muted);
226 text-decoration: none;
227 transition: color 120ms ease, background 120ms ease;
228 }
229 .inbox-tab:hover { color: var(--text-strong); text-decoration: none; }
230 .inbox-tab.is-active {
231 background: rgba(140,109,255,0.14);
232 color: var(--text-strong);
233 }
234 .inbox-tab-count {
235 font-variant-numeric: tabular-nums;
236 font-size: 11.5px;
237 background: rgba(255,255,255,0.04);
238 padding: 1px 7px;
239 border-radius: 9999px;
240 }
241 .inbox-tab.is-active .inbox-tab-count {
242 background: rgba(140,109,255,0.22);
243 color: var(--text);
244 }
245
246 .inbox-list {
247 list-style: none;
248 margin: 0;
249 padding: 0;
250 border: 1px solid var(--border);
251 border-radius: 12px;
252 overflow: hidden;
253 background: var(--bg-elevated);
254 }
255 .inbox-row {
256 display: flex;
257 align-items: flex-start;
258 gap: 14px;
259 padding: 14px 18px;
260 border-bottom: 1px solid var(--border);
261 transition: background 120ms ease;
262 }
263 .inbox-row:last-child { border-bottom: none; }
264 .inbox-row:hover { background: rgba(140,109,255,0.04); }
265 .inbox-row-icon {
266 flex-shrink: 0;
267 width: 30px;
268 height: 30px;
269 border-radius: 9px;
270 background: var(--bg-secondary, var(--bg));
271 border: 1px solid var(--border);
272 display: inline-flex;
273 align-items: center;
274 justify-content: center;
275 font-size: 14px;
276 color: var(--text);
277 margin-top: 1px;
278 }
279 .inbox-row-icon.is-mention { color: #fbbf24; background: rgba(251,191,36,0.10); border-color: rgba(251,191,36,0.25); }
280 .inbox-row-icon.is-review { color: #60a5fa; background: rgba(96,165,250,0.10); border-color: rgba(96,165,250,0.25); }
281 .inbox-row-icon.is-ci { color: #f87171; background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.25); }
282 .inbox-row-icon.is-ai {
283 color: #fff;
284 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
285 border-color: rgba(140,109,255,0.55);
286 box-shadow: 0 0 12px rgba(140,109,255,0.40);
287 }
288 .inbox-row-icon.is-ci .inbox-ci-dot {
289 width: 8px; height: 8px;
290 border-radius: 9999px;
291 background: #f87171;
292 box-shadow: 0 0 8px rgba(248,113,113,0.55);
293 }
294
295 .inbox-row-main { flex: 1; min-width: 0; }
296 .inbox-row-title {
297 font-family: var(--font-display);
298 font-size: 14.5px;
299 font-weight: 600;
300 line-height: 1.4;
301 letter-spacing: -0.012em;
302 margin: 0 0 4px;
303 color: var(--text-strong);
304 display: flex;
305 flex-wrap: wrap;
306 align-items: center;
307 gap: 8px;
308 }
309 .inbox-row-title a {
310 color: var(--text-strong);
311 text-decoration: none;
312 }
313 .inbox-row-title a:hover { color: var(--accent); }
314 .inbox-row-body {
315 font-size: 13px;
316 color: var(--text-muted);
317 line-height: 1.5;
318 margin: 0 0 4px;
319 }
320 .inbox-row-meta {
321 font-size: 12px;
322 color: var(--text-muted);
323 font-variant-numeric: tabular-nums;
324 display: flex;
325 gap: 8px;
326 flex-wrap: wrap;
327 align-items: center;
328 }
329 .inbox-row-meta .inbox-sep { opacity: 0.45; }
330 .inbox-row-source {
331 font-family: var(--font-mono);
332 color: var(--text);
333 }
334 .inbox-row-source:hover { color: var(--accent); text-decoration: none; }
335
336 .inbox-row-kind {
337 display: inline-flex;
338 align-items: center;
339 padding: 1px 8px;
340 font-size: 10.5px;
341 font-weight: 700;
342 letter-spacing: 0.04em;
343 text-transform: uppercase;
344 border-radius: 9999px;
345 }
346 .inbox-row-kind.is-mention { background: rgba(251,191,36,0.13); color: #fbbf24; }
347 .inbox-row-kind.is-review { background: rgba(96,165,250,0.13); color: #93c5fd; }
348 .inbox-row-kind.is-ci { background: rgba(248,113,113,0.13); color: #fca5a5; }
349 .inbox-row-kind.is-ai-finding {
350 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.18));
351 color: #d6c7ff;
352 }
353 .inbox-row-kind.is-ai-merge {
354 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.18));
355 color: #d6c7ff;
356 }
357
358 .inbox-row-actions {
359 flex-shrink: 0;
360 display: inline-flex;
361 align-items: center;
362 gap: 6px;
363 }
364 .inbox-mark {
365 display: inline-flex;
366 align-items: center;
367 gap: 4px;
368 padding: 5px 10px;
369 font-size: 12px;
370 font-weight: 500;
371 color: var(--text-muted);
372 background: transparent;
373 border: 1px solid transparent;
374 border-radius: 9999px;
375 cursor: pointer;
376 text-decoration: none;
377 transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
378 }
379 .inbox-mark:hover {
380 color: var(--text-strong);
381 background: rgba(140,109,255,0.08);
382 border-color: rgba(140,109,255,0.30);
383 text-decoration: none;
384 }
385
386 .inbox-empty {
387 padding: 60px 20px;
388 text-align: center;
389 border: 1px dashed var(--border-strong);
390 border-radius: 14px;
391 background: var(--bg-elevated);
392 position: relative;
393 overflow: hidden;
394 }
395 .inbox-empty::before {
396 content: '';
397 position: absolute;
398 inset: -20% -10% auto auto;
399 width: 320px; height: 320px;
400 background: radial-gradient(circle, rgba(140,109,255,0.10), transparent 70%);
401 filter: blur(60px);
402 pointer-events: none;
403 }
404 .inbox-empty-title {
405 font-family: var(--font-display);
406 font-size: 22px;
407 font-weight: 700;
408 color: var(--text-strong);
409 margin: 0 0 6px;
410 position: relative;
411 }
412 .inbox-empty-sub {
413 color: var(--text-muted);
414 font-size: 14px;
415 margin: 0;
416 position: relative;
417 }
418
419 @media (max-width: 720px) {
420 .inbox-hero { padding: 24px 20px; }
421 .inbox-row { padding: 12px 14px; flex-direction: column; }
422 .inbox-row-actions { align-self: flex-end; }
423 }
424`;
425
426// ---------------------------------------------------------------------------
427// Icon glyphs (inline SVG for a/i/c; emoji-ish glyph for mention).
428// ---------------------------------------------------------------------------
429function KindIcon({ kind }: { kind: InboxKind }) {
430 if (kind === "mention") {
431 return (
432 <span class="inbox-row-icon is-mention" aria-hidden="true">@</span>
433 );
434 }
435 if (kind === "review") {
436 return (
437 <span class="inbox-row-icon is-review" aria-hidden="true">
438 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
439 <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
440 <circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" />
441 </svg>
442 </span>
443 );
444 }
445 if (kind === "ci") {
446 return (
447 <span class="inbox-row-icon is-ci" aria-hidden="true">
448 <span class="inbox-ci-dot" />
449 </span>
450 );
451 }
452 // ai-finding | ai-merge — gradient sparkle
453 return (
454 <span class="inbox-row-icon is-ai" aria-hidden="true">
455 <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
456 <path d="M8 1.5l1.6 4.4 4.4 1.6-4.4 1.6L8 13.5 6.4 9.1 2 7.5l4.4-1.6L8 1.5z" />
457 </svg>
458 </span>
459 );
460}
461
462function KindLabel(kind: InboxKind): string {
463 if (kind === "mention") return "Mention";
464 if (kind === "review") return "Review";
465 if (kind === "ci") return "CI failure";
466 if (kind === "ai-finding") return "AI finding";
467 return "Auto-merge";
468}
469
470// ---------------------------------------------------------------------------
471// Best-effort source loaders. Every one swallows errors → returns [].
472// ---------------------------------------------------------------------------
473
474/**
475 * Compute the set of repos relevant to the user: owned + accepted collab.
476 * Returns [] on any error; the page degrades gracefully.
477 */
478async function getUserRepoIds(userId: string): Promise<string[]> {
479 try {
480 const owned = await db
481 .select({ id: repositories.id })
482 .from(repositories)
483 .where(eq(repositories.ownerId, userId));
484 const collab = await db
485 .select({ id: repoCollaborators.repositoryId })
486 .from(repoCollaborators)
487 .where(
488 and(
489 eq(repoCollaborators.userId, userId),
490 isNotNull(repoCollaborators.acceptedAt)
491 )
492 );
493 const set = new Set<string>();
494 for (const r of owned) set.add(r.id);
495 for (const r of collab) set.add(r.id);
496 return Array.from(set);
497 } catch {
498 return [];
499 }
500}
501
502interface RepoMeta {
503 name: string;
504 ownerUsername: string;
505}
506
507/** Map repo.id → {name, ownerUsername} for source-link rendering. */
508async function getRepoMetaMap(
509 repoIds: string[]
510): Promise<Map<string, RepoMeta>> {
511 const map = new Map<string, RepoMeta>();
512 if (repoIds.length === 0) return map;
513 try {
514 const rows = await db
515 .select({
516 id: repositories.id,
517 name: repositories.name,
518 ownerUsername: users.username,
519 })
520 .from(repositories)
521 .innerJoin(users, eq(users.id, repositories.ownerId))
522 .where(inArray(repositories.id, repoIds));
523 for (const r of rows) {
524 map.set(r.id, { name: r.name, ownerUsername: r.ownerUsername });
525 }
526 } catch {
527 /* skip */
528 }
529 return map;
530}
531
532/**
533 * @mentions in pr_comments + issue_comments. We do a case-insensitive
534 * `body ILIKE '%@username%'` match against the last 200 comments per
535 * source. Cheap and accurate enough — false positives (e.g. "@username"
536 * inside a code block) are tolerable for an inbox.
537 */
538async function loadMentions(
539 username: string
540): Promise<InboxRow[]> {
541 const rows: InboxRow[] = [];
542 const needle = `%@${username}%`;
543 // PR comments
544 try {
545 const prRows = await db
546 .select({
547 id: prComments.id,
548 body: prComments.body,
549 createdAt: prComments.createdAt,
550 prId: prComments.pullRequestId,
551 prNumber: pullRequests.number,
552 repoId: pullRequests.repositoryId,
553 })
554 .from(prComments)
555 .innerJoin(pullRequests, eq(pullRequests.id, prComments.pullRequestId))
cb5a796Claude556 .where(
557 and(
558 sql`${prComments.body} ILIKE ${needle}`,
559 eq(prComments.moderationStatus, "approved")
560 )
561 )
e9aa4d8Claude562 .orderBy(desc(prComments.createdAt))
563 .limit(50);
564 const repoIds = Array.from(new Set(prRows.map((r) => r.repoId)));
565 const meta = await getRepoMetaMap(repoIds);
566 for (const r of prRows) {
567 const m = meta.get(r.repoId);
568 if (!m) continue;
569 const snippet = (r.body || "").slice(0, 140);
570 rows.push({
571 id: `mention-pr-${r.id}`,
572 kind: "mention",
573 title: `Mentioned in ${m.ownerUsername}/${m.name}#${r.prNumber}`,
574 body: snippet,
575 sourceText: `${m.ownerUsername}/${m.name}#${r.prNumber}`,
576 sourceUrl: `/${m.ownerUsername}/${m.name}/pulls/${r.prNumber}`,
577 createdAt: r.createdAt,
578 });
579 }
580 } catch {
581 /* skip */
582 }
583 // Issue comments
584 try {
585 const issueRows = await db
586 .select({
587 id: issueComments.id,
588 body: issueComments.body,
589 createdAt: issueComments.createdAt,
590 issueId: issueComments.issueId,
591 issueNumber: issues.number,
592 repoId: issues.repositoryId,
593 })
594 .from(issueComments)
595 .innerJoin(issues, eq(issues.id, issueComments.issueId))
cb5a796Claude596 .where(
597 and(
598 sql`${issueComments.body} ILIKE ${needle}`,
599 eq(issueComments.moderationStatus, "approved")
600 )
601 )
e9aa4d8Claude602 .orderBy(desc(issueComments.createdAt))
603 .limit(50);
604 const repoIds = Array.from(new Set(issueRows.map((r) => r.repoId)));
605 const meta = await getRepoMetaMap(repoIds);
606 for (const r of issueRows) {
607 const m = meta.get(r.repoId);
608 if (!m) continue;
609 const snippet = (r.body || "").slice(0, 140);
610 rows.push({
611 id: `mention-issue-${r.id}`,
612 kind: "mention",
613 title: `Mentioned in ${m.ownerUsername}/${m.name}#${r.issueNumber}`,
614 body: snippet,
615 sourceText: `${m.ownerUsername}/${m.name}#${r.issueNumber}`,
616 sourceUrl: `/${m.ownerUsername}/${m.name}/issues/${r.issueNumber}`,
617 createdAt: r.createdAt,
618 });
619 }
620 } catch {
621 /* skip */
622 }
623 return rows;
624}
625
626/**
627 * Review requests = open PRs in user's repos that have NO AI review
628 * comment yet (isAiReview=true). Filters out the user's own PRs since
629 * you don't review your own work.
630 */
631async function loadReviewRequests(
632 userId: string,
633 repoIds: string[]
634): Promise<InboxRow[]> {
635 if (repoIds.length === 0) return [];
636 const rows: InboxRow[] = [];
637 try {
638 const candidates = await db
639 .select({
640 prId: pullRequests.id,
641 prNumber: pullRequests.number,
642 prTitle: pullRequests.title,
643 prAuthorId: pullRequests.authorId,
644 prUpdatedAt: pullRequests.updatedAt,
645 repoId: pullRequests.repositoryId,
646 })
647 .from(pullRequests)
648 .where(
649 and(
650 inArray(pullRequests.repositoryId, repoIds),
651 eq(pullRequests.state, "open"),
652 eq(pullRequests.isDraft, false)
653 )
654 )
655 .orderBy(desc(pullRequests.updatedAt))
656 .limit(100);
657 if (candidates.length === 0) return [];
658 const meta = await getRepoMetaMap(
659 Array.from(new Set(candidates.map((c) => c.repoId)))
660 );
661 // Find which PRs already have an AI review.
662 const prIds = candidates.map((c) => c.prId);
663 let aiSet = new Set<string>();
664 try {
665 const aiRows = await db
666 .select({ prId: prComments.pullRequestId })
667 .from(prComments)
668 .where(
669 and(
670 inArray(prComments.pullRequestId, prIds),
671 eq(prComments.isAiReview, true)
672 )
673 );
674 aiSet = new Set(aiRows.map((r) => r.prId));
675 } catch {
676 /* keep empty set — treat all as awaiting */
677 }
678 for (const c of candidates) {
679 if (c.prAuthorId === userId) continue; // don't review your own
680 if (aiSet.has(c.prId)) continue; // already reviewed
681 const m = meta.get(c.repoId);
682 if (!m) continue;
683 rows.push({
684 id: `review-${c.prId}`,
685 kind: "review",
686 title: c.prTitle,
687 sourceText: `${m.ownerUsername}/${m.name}#${c.prNumber}`,
688 sourceUrl: `/${m.ownerUsername}/${m.name}/pulls/${c.prNumber}`,
689 createdAt: c.prUpdatedAt,
690 });
691 }
692 } catch {
693 /* skip */
694 }
695 return rows;
696}
697
698/**
699 * CI failures = workflow_runs with status='failure' in user's repos
700 * over the last 24h. Falls back gracefully if the workflow tables
701 * don't exist (e.g. older deployments).
702 */
703async function loadCiFailures(repoIds: string[]): Promise<InboxRow[]> {
704 if (repoIds.length === 0) return [];
705 const rows: InboxRow[] = [];
706 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
707 try {
708 const runs = await db
709 .select({
710 id: workflowRuns.id,
711 runNumber: workflowRuns.runNumber,
712 status: workflowRuns.status,
713 conclusion: workflowRuns.conclusion,
714 repoId: workflowRuns.repositoryId,
715 workflowId: workflowRuns.workflowId,
716 createdAt: workflowRuns.createdAt,
717 workflowName: workflows.name,
718 })
719 .from(workflowRuns)
720 .innerJoin(workflows, eq(workflows.id, workflowRuns.workflowId))
721 .where(
722 and(
723 inArray(workflowRuns.repositoryId, repoIds),
724 eq(workflowRuns.status, "failure"),
725 gte(workflowRuns.createdAt, cutoff)
726 )
727 )
728 .orderBy(desc(workflowRuns.createdAt))
729 .limit(50);
730 const meta = await getRepoMetaMap(
731 Array.from(new Set(runs.map((r) => r.repoId)))
732 );
733 for (const r of runs) {
734 const m = meta.get(r.repoId);
735 if (!m) continue;
736 rows.push({
737 id: `ci-${r.id}`,
738 kind: "ci",
739 title: `${r.workflowName} failed`,
740 body: r.conclusion || null,
741 sourceText: `${m.ownerUsername}/${m.name} · run #${r.runNumber}`,
742 sourceUrl: `/${m.ownerUsername}/${m.name}/actions/runs/${r.id}`,
743 createdAt: r.createdAt,
744 });
745 }
746 } catch {
747 /* table may not exist — skip */
748 }
749 return rows;
750}
751
752/**
753 * AI findings = open security advisory alerts on user's repos. The
754 * advisory row carries the human-readable summary + severity.
755 */
756async function loadAiFindings(repoIds: string[]): Promise<InboxRow[]> {
757 if (repoIds.length === 0) return [];
758 const rows: InboxRow[] = [];
759 try {
760 const alerts = await db
761 .select({
762 id: repoAdvisoryAlerts.id,
763 repoId: repoAdvisoryAlerts.repositoryId,
764 dependencyName: repoAdvisoryAlerts.dependencyName,
765 createdAt: repoAdvisoryAlerts.createdAt,
766 summary: securityAdvisories.summary,
767 severity: securityAdvisories.severity,
768 ghsaId: securityAdvisories.ghsaId,
769 })
770 .from(repoAdvisoryAlerts)
771 .innerJoin(
772 securityAdvisories,
773 eq(securityAdvisories.id, repoAdvisoryAlerts.advisoryId)
774 )
775 .where(
776 and(
777 inArray(repoAdvisoryAlerts.repositoryId, repoIds),
778 eq(repoAdvisoryAlerts.status, "open")
779 )
780 )
781 .orderBy(desc(repoAdvisoryAlerts.createdAt))
782 .limit(50);
783 const meta = await getRepoMetaMap(
784 Array.from(new Set(alerts.map((a) => a.repoId)))
785 );
786 for (const a of alerts) {
787 const m = meta.get(a.repoId);
788 if (!m) continue;
789 rows.push({
790 id: `ai-find-${a.id}`,
791 kind: "ai-finding",
792 title: `${a.severity?.toUpperCase() || "ADVISORY"}: ${a.summary}`,
793 body: `${a.dependencyName}${a.ghsaId ? ` · ${a.ghsaId}` : ""}`,
794 sourceText: `${m.ownerUsername}/${m.name}`,
795 sourceUrl: `/${m.ownerUsername}/${m.name}/security/advisories`,
796 createdAt: a.createdAt,
797 });
798 }
799 } catch {
800 /* skip */
801 }
802 return rows;
803}
804
805/**
806 * Auto-merge events = PRs in user's repos that have been merged
807 * (mergedAt is set). We use mergedAt as the timestamp so it sorts
808 * correctly into the timeline.
809 */
810async function loadAutoMergeEvents(repoIds: string[]): Promise<InboxRow[]> {
811 if (repoIds.length === 0) return [];
812 const rows: InboxRow[] = [];
813 const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
814 try {
815 const merged = await db
816 .select({
817 id: pullRequests.id,
818 number: pullRequests.number,
819 title: pullRequests.title,
820 mergedAt: pullRequests.mergedAt,
821 repoId: pullRequests.repositoryId,
822 })
823 .from(pullRequests)
824 .where(
825 and(
826 inArray(pullRequests.repositoryId, repoIds),
827 eq(pullRequests.state, "merged"),
828 isNotNull(pullRequests.mergedAt),
829 gte(pullRequests.mergedAt, cutoff)
830 )
831 )
832 .orderBy(desc(pullRequests.mergedAt))
833 .limit(50);
834 const meta = await getRepoMetaMap(
835 Array.from(new Set(merged.map((r) => r.repoId)))
836 );
837 for (const r of merged) {
838 const m = meta.get(r.repoId);
839 if (!m || !r.mergedAt) continue;
840 rows.push({
841 id: `ai-merge-${r.id}`,
842 kind: "ai-merge",
843 title: `Auto-merged: ${r.title}`,
844 sourceText: `${m.ownerUsername}/${m.name}#${r.number}`,
845 sourceUrl: `/${m.ownerUsername}/${m.name}/pulls/${r.number}`,
846 createdAt: r.mergedAt,
847 });
848 }
849 } catch {
850 /* skip */
851 }
852 return rows;
853}
854
855/**
856 * Unread notification count for the hero / nav badge. Defensive — the
857 * table may not exist on older deploys, in which case we report 0.
858 */
859async function getUnreadNotifCount(userId: string): Promise<number> {
860 try {
861 const [r] = await db
862 .select({ count: sql<number>`count(*)` })
863 .from(notifTable)
864 .where(and(eq(notifTable.userId, userId), eq(notifTable.isRead, false)));
865 return r?.count ?? 0;
866 } catch {
867 return 0;
868 }
869}
870
871// ---------------------------------------------------------------------------
872// GET /inbox — the unified surface.
873// ---------------------------------------------------------------------------
874inboxRoutes.get("/inbox", requireAuth, async (c) => {
875 const user = c.get("user")!;
876 const raw = c.req.query("filter") || "all";
877 const filter: InboxFilter = (VALID_FILTERS as string[]).includes(raw)
878 ? (raw as InboxFilter)
879 : "all";
880
881 const repoIds = await getUserRepoIds(user.id);
882
883 // Load every source in parallel — each is best-effort.
884 const [mentionRows, reviewRows, ciRows, aiFindingRows, aiMergeRows] =
885 await Promise.all([
886 loadMentions(user.username),
887 loadReviewRequests(user.id, repoIds),
888 loadCiFailures(repoIds),
889 loadAiFindings(repoIds),
890 loadAutoMergeEvents(repoIds),
891 ]);
892
893 const all = mergeAndCapInboxRows(
894 [mentionRows, reviewRows, ciRows, aiFindingRows, aiMergeRows],
895 100
896 );
897 const visible = filterInboxRows(all, filter);
898
899 // Counters for the hero + tab strip — derived from the full (pre-filter)
900 // dataset so the badges reflect everything, not just the active tab.
901 const counts = {
902 total: all.length,
903 mentions: all.filter((r) => r.kind === "mention").length,
904 review: all.filter((r) => r.kind === "review").length,
905 ci: all.filter((r) => r.kind === "ci").length,
906 ai: all.filter((r) => r.kind === "ai-finding" || r.kind === "ai-merge")
907 .length,
908 };
909
910 return c.html(
911 <Layout
912 title="Inbox · Gluecron"
913 user={user}
914 notificationCount={counts.total}
915 >
916 <div class="inbox-wrap">
917 <section class="inbox-hero">
918 <div class="inbox-orb" aria-hidden="true" />
919 <div class="inbox-hero-inner">
920 <div class="inbox-eyebrow">
921 Unified inbox · live ·{" "}
922 <span style="color:var(--accent);font-weight:600">
923 {user.username}
924 </span>
925 </div>
926 <h1 class="inbox-title">
927 <span class="inbox-title-grad">Everything that needs you.</span>
928 </h1>
929 <p class="inbox-sub">
930 One screen for every signal worth your attention. @mentions,
931 review requests, CI failures, AI findings, and auto-merge
932 events — from every repo you touch, sorted by what just
933 happened.
934 </p>
935 <div class="inbox-stats">
936 <div class="inbox-stat">
937 <div class="inbox-stat-n">{counts.total}</div>
938 <div class="inbox-stat-l">Total</div>
939 </div>
940 <div class="inbox-stat">
941 <div class="inbox-stat-n">{counts.mentions}</div>
942 <div class="inbox-stat-l">Mentions</div>
943 </div>
944 <div class="inbox-stat">
945 <div class="inbox-stat-n">{counts.review}</div>
946 <div class="inbox-stat-l">Review</div>
947 </div>
948 <div class="inbox-stat">
949 <div class="inbox-stat-n">{counts.ci}</div>
950 <div class="inbox-stat-l">CI</div>
951 </div>
952 <div class="inbox-stat">
953 <div class="inbox-stat-n">{counts.ai}</div>
954 <div class="inbox-stat-l">AI</div>
955 </div>
956 </div>
957 </div>
958 </section>
959
960 <nav class="inbox-tabs" aria-label="Inbox filters">
961 <a
962 href="/inbox?filter=all"
963 class={"inbox-tab " + (filter === "all" ? "is-active" : "")}
964 >
965 All <span class="inbox-tab-count">{counts.total}</span>
966 </a>
967 <a
968 href="/inbox?filter=mentions"
969 class={"inbox-tab " + (filter === "mentions" ? "is-active" : "")}
970 >
971 Mentions <span class="inbox-tab-count">{counts.mentions}</span>
972 </a>
973 <a
974 href="/inbox?filter=review"
975 class={"inbox-tab " + (filter === "review" ? "is-active" : "")}
976 >
977 Review <span class="inbox-tab-count">{counts.review}</span>
978 </a>
979 <a
980 href="/inbox?filter=ci"
981 class={"inbox-tab " + (filter === "ci" ? "is-active" : "")}
982 >
983 CI <span class="inbox-tab-count">{counts.ci}</span>
984 </a>
985 <a
986 href="/inbox?filter=ai"
987 class={"inbox-tab " + (filter === "ai" ? "is-active" : "")}
988 >
989 AI <span class="inbox-tab-count">{counts.ai}</span>
990 </a>
991 </nav>
992
993 {visible.length === 0 ? (
994 <div class="inbox-empty">
995 <h2 class="inbox-empty-title">
996 {filter === "all"
997 ? "Inbox zero. Nicely done."
998 : filter === "mentions"
999 ? "No mentions right now."
1000 : filter === "review"
1001 ? "Nothing waiting on your review."
1002 : filter === "ci"
1003 ? "No CI failures in the last 24h."
1004 : "No AI findings or auto-merges right now."}
1005 </h2>
1006 <p class="inbox-empty-sub">
1007 {filter === "all"
1008 ? "When something needs your attention — a mention, a review request, a CI failure, an AI advisory — it'll land here in real time."
1009 : "Switch tabs to see other signals, or check back in a few minutes."}
1010 </p>
1011 </div>
1012 ) : (
1013 <ul class="inbox-list">
1014 {visible.map((row) => {
1015 const kindCls =
1016 row.kind === "mention"
1017 ? "is-mention"
1018 : row.kind === "review"
1019 ? "is-review"
1020 : row.kind === "ci"
1021 ? "is-ci"
1022 : row.kind === "ai-finding"
1023 ? "is-ai-finding"
1024 : "is-ai-merge";
1025 return (
1026 <li class="inbox-row" data-kind={row.kind}>
1027 <KindIcon kind={row.kind} />
1028 <div class="inbox-row-main">
1029 <h3 class="inbox-row-title">
1030 <a href={row.sourceUrl}>{row.title}</a>
1031 <span class={"inbox-row-kind " + kindCls}>
1032 {KindLabel(row.kind)}
1033 </span>
1034 </h3>
1035 {row.body && (
1036 <p class="inbox-row-body">
1037 {row.body.length > 200
1038 ? row.body.slice(0, 200) + "…"
1039 : row.body}
1040 </p>
1041 )}
1042 <div class="inbox-row-meta">
1043 <a class="inbox-row-source" href={row.sourceUrl}>
1044 {row.sourceText}
1045 </a>
1046 <span class="inbox-sep">·</span>
1047 <span>{relTime(row.createdAt)}</span>
1048 </div>
1049 </div>
1050 <div class="inbox-row-actions">
1051 {/* Best-effort dismiss: route to /notifications which
1052 is where the read-state ledger actually lives. */}
1053 <a
1054 href="/notifications"
1055 class="inbox-mark"
1056 title="Mark read"
1057 >
1058 Mark read
1059 </a>
1060 </div>
1061 </li>
1062 );
1063 })}
1064 </ul>
1065 )}
1066 </div>
1067 <style dangerouslySetInnerHTML={{ __html: styles }} />
1068 </Layout>
1069 );
1070});
1071
1072// Tiny JSON endpoint the nav-link badge could poll for a live count.
1073// Same shape as /api/notifications/count so the client could swap easily.
1074inboxRoutes.get("/api/inbox/count", softAuth, async (c) => {
1075 const user = c.get("user");
1076 if (!user) return c.json({ count: 0 });
1077 const n = await getUnreadNotifCount(user.id);
1078 return c.json({ count: n });
1079});
1080
1081export default inboxRoutes;