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

smart-digest.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

smart-digest.tsBlame589 lines · 1 contributor
b93fc3eClaude1/**
2 * Smart Morning Digest — AI-curated daily developer notification queue.
3 *
4 * Replaces notification spam with a single, prioritised digest delivered
5 * once per day via the in-app notification system AND surfaced on /digest.
6 *
7 * Data sources (last 48h / 24h where noted):
8 * 1. Unread notifications (48h)
9 * 2. Open PRs where the user is a requested reviewer
10 * 3. Open PRs authored by the user with unread comments
11 * 4. Failed gate runs for repos the user owns (24h)
12 * 5. Dependency-update PRs on user's repos
13 *
14 * Claude Sonnet 4.6 prioritises the items and writes the headline + insight.
15 * Never throws — every path is wrapped in try/catch.
16 */
17
18import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
19import { db } from "../db";
20import {
21 gateRuns,
22 issues,
23 notifications,
24 prComments,
25 prReviews,
26 pullRequests,
27 repositories,
28 users,
29} from "../db/schema";
30import {
31 getAnthropic,
32 isAiAvailable,
33 MODEL_SONNET,
34 extractText,
35 parseJsonResponse,
36} from "./ai-client";
37import { config } from "./config";
38
39// ---------------------------------------------------------------------------
40// Public interfaces
41// ---------------------------------------------------------------------------
42
43export interface DigestItem {
44 priority: "blocking" | "important" | "fyi";
45 type:
46 | "pr_review"
47 | "pr_comment"
48 | "ci_failure"
49 | "mention"
50 | "dep_update"
51 | "new_issue";
52 title: string;
53 /** e.g. "waiting 2 days · 3 files changed" */
54 subtitle: string;
55 url: string;
56 repoName: string;
57}
58
59export interface SmartDigest {
60 userId: string;
61 generatedAt: string;
62 headline: string;
63 queue: DigestItem[];
64 stats: {
65 prsReviewed: number;
66 issuesClosed: number;
67 commitsThisWeek: number;
68 };
69 insight?: string;
70}
71
72// ---------------------------------------------------------------------------
73// Internal data loader
74// ---------------------------------------------------------------------------
75
76interface RawItem {
77 type: DigestItem["type"];
78 title: string;
79 subtitle: string;
80 url: string;
81 repoName: string;
82 createdAt: Date;
83}
84
85async function loadRawItems(
86 userId: string,
87 now: Date
88): Promise<RawItem[]> {
89 const base = config.appBaseUrl || "https://gluecron.com";
90 const items: RawItem[] = [];
91
92 // --- 1. Unread notifications (last 48h) ---
93 const since48h = new Date(now.getTime() - 48 * 60 * 60 * 1000);
94 try {
95 const notifs = await db
96 .select()
97 .from(notifications)
98 .where(
99 and(
100 eq(notifications.userId, userId),
101 gte(notifications.createdAt, since48h),
102 sql`${notifications.readAt} IS NULL`
103 )
104 )
105 .orderBy(desc(notifications.createdAt))
106 .limit(20);
107
108 for (const n of notifs) {
109 const ageH = Math.round(
110 (now.getTime() - new Date(n.createdAt).getTime()) / 3_600_000
111 );
112 items.push({
113 type: n.kind === "mention" ? "mention" : n.kind === "gate_failed" ? "ci_failure" : "pr_review",
114 title: n.title || "(untitled)",
115 subtitle: `${n.kind} · ${ageH}h ago`,
116 url: n.url ? (n.url.startsWith("http") ? n.url : `${base}${n.url}`) : `${base}/inbox`,
117 repoName: "—",
118 createdAt: new Date(n.createdAt),
119 });
120 }
121 } catch (err) {
122 console.error("[smart-digest] notifications query failed:", err);
123 }
124
125 // --- 2. PRs where user is requested reviewer (open) ---
126 try {
127 // We use pr_reviews to detect review requests: look for PRs where user
128 // has a review_requested state and the PR is still open.
129 const reviewRows = await db
130 .select({
131 pr: pullRequests,
132 repoName: repositories.name,
133 ownerUsername: users.username,
134 })
135 .from(prReviews)
136 .innerJoin(pullRequests, eq(pullRequests.id, prReviews.pullRequestId))
137 .innerJoin(
138 repositories,
139 eq(repositories.id, pullRequests.repositoryId)
140 )
141 .innerJoin(users, eq(users.id, repositories.ownerId))
142 .where(
143 and(
144 eq(prReviews.reviewerId, userId),
145 eq(pullRequests.state, "open"),
146 // Only include PRs they haven't reviewed yet (no approved/changes_requested)
147 sql`${prReviews.state} NOT IN ('approved','changes_requested')`
148 )
149 )
150 .orderBy(desc(pullRequests.createdAt))
151 .limit(10);
152
153 for (const row of reviewRows) {
154 const ageH = Math.round(
155 (now.getTime() - new Date(row.pr.createdAt).getTime()) / 3_600_000
156 );
157 const ageDays = Math.floor(ageH / 24);
158 const ageLabel = ageDays >= 1 ? `waiting ${ageDays}d` : `waiting ${ageH}h`;
159 items.push({
160 type: "pr_review",
161 title: `Review requested: ${row.pr.title}`,
162 subtitle: `${ageLabel} · ${row.repoName}`,
163 url: `${base}/${row.ownerUsername}/${row.repoName}/pulls/${row.pr.number}`,
164 repoName: row.repoName,
165 createdAt: new Date(row.pr.createdAt),
166 });
167 }
168 } catch (err) {
169 console.error("[smart-digest] review-requested query failed:", err);
170 }
171
172 // --- 3. Open PRs authored by user with unread comments ---
173 try {
174 const authoredPrs = await db
175 .select({
176 pr: pullRequests,
177 repoName: repositories.name,
178 ownerUsername: users.username,
179 commentCount: sql<number>`count(${prComments.id})::int`,
180 })
181 .from(pullRequests)
182 .innerJoin(
183 repositories,
184 eq(repositories.id, pullRequests.repositoryId)
185 )
186 .innerJoin(users, eq(users.id, repositories.ownerId))
187 .innerJoin(prComments, eq(prComments.pullRequestId, pullRequests.id))
188 .where(
189 and(
190 eq(pullRequests.authorId, userId),
191 eq(pullRequests.state, "open"),
192 ne(prComments.authorId, userId),
193 gte(prComments.createdAt, since48h)
194 )
195 )
196 .groupBy(pullRequests.id, repositories.name, users.username)
197 .orderBy(desc(pullRequests.updatedAt))
198 .limit(10);
199
200 for (const row of authoredPrs) {
201 const count = row.commentCount || 0;
202 if (count === 0) continue;
203 items.push({
204 type: "pr_comment",
205 title: `New comments on your PR: ${row.pr.title}`,
206 subtitle: `${count} new comment${count === 1 ? "" : "s"} · ${row.repoName}`,
207 url: `${base}/${row.ownerUsername}/${row.repoName}/pulls/${row.pr.number}`,
208 repoName: row.repoName,
209 createdAt: new Date(row.pr.updatedAt),
210 });
211 }
212 } catch (err) {
213 console.error("[smart-digest] pr-comments query failed:", err);
214 }
215
216 // --- 4. Failed gate runs for repos the user owns (last 24h) ---
217 const since24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
218 try {
219 const ownedRepos = await db
220 .select({ id: repositories.id, name: repositories.name })
221 .from(repositories)
222 .where(eq(repositories.ownerId, userId));
223
224 if (ownedRepos.length > 0) {
225 const repoIds = ownedRepos.map((r) => r.id);
226 const repoById = new Map(ownedRepos.map((r) => [r.id, r.name]));
227
228 const failedGates = await db
229 .select()
230 .from(gateRuns)
231 .where(
232 and(
233 inArray(gateRuns.repositoryId, repoIds),
234 eq(gateRuns.status, "failed"),
235 gte(gateRuns.createdAt, since24h)
236 )
237 )
238 .orderBy(desc(gateRuns.createdAt))
239 .limit(10);
240
241 for (const gate of failedGates) {
242 const repoName = repoById.get(gate.repositoryId) || "?";
243 items.push({
244 type: "ci_failure",
245 title: `Gate failed: ${gate.gateName} in ${repoName}`,
246 subtitle: `commit ${gate.commitSha.slice(0, 7)} · ${repoName}`,
247 url: `${base}/${userId}/${repoName}/pulls`,
248 repoName,
249 createdAt: new Date(gate.createdAt),
250 });
251 }
252
253 // --- 5. Dependency update PRs ---
254 if (repoIds.length > 0) {
255 const depPrs = await db
256 .select({
257 pr: pullRequests,
258 repoName: repositories.name,
259 })
260 .from(pullRequests)
261 .innerJoin(
262 repositories,
263 eq(repositories.id, pullRequests.repositoryId)
264 )
265 .where(
266 and(
267 inArray(pullRequests.repositoryId, repoIds),
268 eq(pullRequests.state, "open"),
269 sql`${pullRequests.headBranch} LIKE 'gluecron/dep-update%'`
270 )
271 )
272 .orderBy(desc(pullRequests.createdAt))
273 .limit(5);
274
275 for (const row of depPrs) {
276 items.push({
277 type: "dep_update",
278 title: `Dependency update ready: ${row.pr.title}`,
279 subtitle: `auto-PR · ${row.repoName}`,
280 url: `${base}/${userId}/${row.repoName}/pulls/${row.pr.number}`,
281 repoName: row.repoName,
282 createdAt: new Date(row.pr.createdAt),
283 });
284 }
285 }
286 }
287 } catch (err) {
288 console.error("[smart-digest] gate/dep-update query failed:", err);
289 }
290
291 return items;
292}
293
294// ---------------------------------------------------------------------------
295// Stats loader
296// ---------------------------------------------------------------------------
297
298async function loadStats(
299 userId: string,
300 now: Date
301): Promise<SmartDigest["stats"]> {
302 const since7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
303
304 let prsReviewed = 0;
305 let issuesClosed = 0;
306 const commitsThisWeek = 0; // Git log not easily queryable via SQL; default 0
307
308 try {
309 const [reviewCountRow] = await db
310 .select({ count: sql<number>`count(*)::int` })
311 .from(prReviews)
312 .where(
313 and(
314 eq(prReviews.reviewerId, userId),
315 gte(prReviews.createdAt, since7d),
316 sql`${prReviews.state} IN ('approved','changes_requested')`
317 )
318 );
319 prsReviewed = reviewCountRow?.count || 0;
320 } catch {
321 /* swallow */
322 }
323
324 try {
325 const [issueCountRow] = await db
326 .select({ count: sql<number>`count(*)::int` })
327 .from(issues)
328 .where(
329 and(
330 gte(issues.updatedAt, since7d),
331 eq(issues.state, "closed")
332 )
333 );
334 issuesClosed = issueCountRow?.count || 0;
335 } catch {
336 /* swallow */
337 }
338
339 return { prsReviewed, issuesClosed, commitsThisWeek };
340}
341
342// ---------------------------------------------------------------------------
343// Claude call
344// ---------------------------------------------------------------------------
345
346interface ClaudeDigestResponse {
347 headline: string;
348 queue: Array<{
349 priority: "blocking" | "important" | "fyi";
350 type: DigestItem["type"];
351 title: string;
352 subtitle: string;
353 url: string;
354 repoName: string;
355 }>;
356 insight?: string;
357}
358
359async function callClaude(
360 rawItems: RawItem[],
361 stats: SmartDigest["stats"],
362 username: string
363): Promise<ClaudeDigestResponse | null> {
364 if (!isAiAvailable()) return null;
365 try {
366 const client = getAnthropic();
367 const itemsJson = JSON.stringify(
368 rawItems.map((i) => ({
369 type: i.type,
370 title: i.title,
371 subtitle: i.subtitle,
372 url: i.url,
373 repoName: i.repoName,
374 })),
375 null,
376 2
377 );
378 const statsJson = JSON.stringify(stats, null, 2);
379
380 const prompt = `You are a developer productivity assistant for ${username}. Create a morning digest.
381
382Their pending items:
383${itemsJson}
384
385Their recent stats (last 7 days):
386${statsJson}
387
388Return JSON only (no markdown wrapper):
389{
390 "headline": "...",
391 "queue": [{"priority": "blocking"|"important"|"fyi", "type": "pr_review"|"pr_comment"|"ci_failure"|"mention"|"dep_update"|"new_issue", "title": "...", "subtitle": "...", "url": "...", "repoName": "..."}, ...],
392 "insight": "..."
393}
394
395Rules:
396- Max 8 items in queue
397- blocking = someone explicitly waiting on this person, or a CI failure blocking a deploy
398- important = needs action today
399- fyi = nice to know
400- headline should be specific and human ("PR #45 is blocking a deploy" not "You have notifications")
401- insight should be personal and brief (skip if nothing interesting)
402- If no items, headline = "All caught up — nothing needs your attention right now"
403- Return valid JSON only, no commentary`;
404
405 const msg = await client.messages.create({
406 model: MODEL_SONNET,
407 max_tokens: 1024,
408 messages: [{ role: "user", content: prompt }],
409 });
410
411 const text = extractText(msg);
412 const parsed = parseJsonResponse<ClaudeDigestResponse>(text);
413 return parsed;
414 } catch (err) {
415 console.error("[smart-digest] Claude call failed:", err);
416 return null;
417 }
418}
419
420// ---------------------------------------------------------------------------
421// Fallback (no AI)
422// ---------------------------------------------------------------------------
423
424function buildFallbackDigest(
425 rawItems: RawItem[],
426 stats: SmartDigest["stats"],
427 userId: string
428): Omit<SmartDigest, "userId" | "generatedAt" | "stats"> {
429 const queue: DigestItem[] = rawItems.slice(0, 8).map((item) => ({
430 priority:
431 item.type === "ci_failure"
432 ? "blocking"
433 : item.type === "pr_review"
434 ? "important"
435 : "fyi",
436 type: item.type,
437 title: item.title,
438 subtitle: item.subtitle,
439 url: item.url,
440 repoName: item.repoName,
441 }));
442
443 const blockingCount = queue.filter((i) => i.priority === "blocking").length;
444 const importantCount = queue.filter((i) => i.priority === "important").length;
445 let headline = "All caught up — nothing needs your attention right now";
446 if (queue.length > 0) {
447 if (blockingCount > 0) {
448 headline = `${blockingCount} blocking item${blockingCount === 1 ? "" : "s"} need${blockingCount === 1 ? "s" : ""} your attention`;
449 } else if (importantCount > 0) {
450 headline = `${importantCount} item${importantCount === 1 ? "" : "s"} to action today`;
451 } else {
452 headline = `${queue.length} item${queue.length === 1 ? "" : "s"} in your queue`;
453 }
454 }
455
456 return { headline, queue };
457}
458
459// ---------------------------------------------------------------------------
460// Public API
461// ---------------------------------------------------------------------------
462
463/**
464 * Compose a smart digest for the given user. Never throws.
465 * Returns null if the user is not found or any fatal error occurs.
466 */
467export async function composeSmartDigest(
468 userId: string
469): Promise<SmartDigest | null> {
470 try {
471 const [user] = await db
472 .select()
473 .from(users)
474 .where(eq(users.id, userId))
475 .limit(1);
476 if (!user) return null;
477
478 const now = new Date();
479 const [rawItems, stats] = await Promise.all([
480 loadRawItems(userId, now),
481 loadStats(userId, now),
482 ]);
483
484 let headline = "All caught up — nothing needs your attention right now";
485 let queue: DigestItem[] = [];
486 let insight: string | undefined;
487
488 const claudeResult = await callClaude(rawItems, stats, user.username);
489 if (claudeResult) {
490 headline = claudeResult.headline || headline;
491 queue = (claudeResult.queue || []).slice(0, 8) as DigestItem[];
492 insight = claudeResult.insight || undefined;
493 } else {
494 const fallback = buildFallbackDigest(rawItems, stats, userId);
495 headline = fallback.headline;
496 queue = fallback.queue;
497 }
498
499 return {
500 userId,
501 generatedAt: now.toISOString(),
502 headline,
503 queue,
504 stats,
505 insight,
506 };
507 } catch (err) {
508 console.error("[smart-digest] composeSmartDigest error:", err);
509 return null;
510 }
511}
512
513/** Minimum hours between digests. */
514const SMART_DIGEST_COOLDOWN_HOURS = 20;
515
516/**
517 * Compose + store a single notification for the user.
518 * Updates `last_smart_digest_sent_at`. Respects the 20h cooldown.
519 * Never throws.
520 */
521export async function sendSmartDigest(userId: string): Promise<void> {
522 try {
523 const [user] = await db
524 .select()
525 .from(users)
526 .where(eq(users.id, userId))
527 .limit(1);
528 if (!user) return;
529
530 // Cooldown check — skip if sent recently
531 const lastSent = user.lastSmartDigestSentAt as Date | null;
532 if (lastSent) {
533 const hoursSince =
534 (Date.now() - new Date(lastSent).getTime()) / 3_600_000;
535 if (hoursSince < SMART_DIGEST_COOLDOWN_HOURS) {
536 return;
537 }
538 }
539
540 const digest = await composeSmartDigest(userId);
541 if (!digest) return;
542
543 // Insert a single 'digest' notification with full JSON in body
544 await db.insert(notifications).values({
545 userId,
546 kind: "digest",
547 title: digest.headline,
548 body: JSON.stringify(digest),
549 url: "/digest",
550 });
551
552 // Update timestamp
553 await db
554 .update(users)
555 .set({ lastSmartDigestSentAt: new Date() })
556 .where(eq(users.id, userId));
557 } catch (err) {
558 console.error("[smart-digest] sendSmartDigest error:", err);
559 }
560}
561
562/**
563 * Send smart digests to all opted-in users. Called from the autopilot loop.
564 * Never throws.
565 */
566export async function sendSmartDigestsToAll(): Promise<void> {
567 try {
568 const candidates = await db
569 .select({ id: users.id })
570 .from(users)
571 .where(
572 sql`(${users.notifyEmailDigestWeekly} = true OR ${users.notifySmartDigest} = true)`
573 )
574 .limit(200);
575
576 for (const candidate of candidates) {
577 try {
578 await sendSmartDigest(candidate.id);
579 } catch (err) {
580 console.error(
581 `[smart-digest] per-user error for user=${candidate.id}:`,
582 err
583 );
584 }
585 }
586 } catch (err) {
587 console.error("[smart-digest] sendSmartDigestsToAll error:", err);
588 }
589}