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

ai-standup.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.

ai-standup.tsBlame1097 lines · 1 contributor
a686079Claude1/**
2 * AI Standup — daily / weekly Claude-generated team brief.
3 *
4 * Goal: every developer wakes up to a single "here's what your team shipped,
5 * what's blocked, what's at risk" summary — the morning routine that keeps
6 * gluecron sticky.
7 *
8 * Entry point: `generateStandup({ userId, scope })`. Pulls PRs / issues /
9 * (site-admin only) platform deploys updated in the window, hands the
10 * material to Sonnet 4 with a strict structured-output contract, and
11 * returns `{ summary, shippedItems, blockedItems, atRiskItems }`.
12 *
13 * `deliverStandup` is the autopilot-facing helper: it generates the
14 * standup, dedupes against the most recent same-day row, inserts an
15 * inbox notification (kind="ai-standup"), and optionally emails the user
16 * when they have email standup enabled.
17 *
18 * Degrades gracefully:
19 * - `ANTHROPIC_API_KEY` missing → returns a deterministic
20 * fallback summary (raw bullet lists) so the autopilot still ships
21 * a brief.
22 * - Any DB/network error is caught and surfaced as `aiAvailable: false`
23 * with a non-empty `summary`. Functions never throw.
24 */
25
26import { and, desc, eq, gte, inArray, or, sql } from "drizzle-orm";
27import { db } from "../db";
28import {
29 issues,
30 pullRequests,
31 repoCollaborators,
32 repositories,
33 siteAdmins,
34 users,
35} from "../db/schema";
36import { platformDeploys } from "../db/schema-deploys";
37import { aiStandups, userStandupPrefs } from "../db/schema-standup";
38import {
39 MODEL_SONNET,
40 extractText,
41 getAnthropic,
42 isAiAvailable,
43 parseJsonResponse,
44} from "./ai-client";
45import { notify, type NotificationKind } from "./notify";
46import { sendEmail, absoluteUrl } from "./email";
47
48export type StandupScope = "daily" | "weekly";
49
50export interface StandupResult {
51 /** 200-300 word markdown brief, sectioned by Shipped / In flight / At risk. */
52 summary: string;
53 /** Concise bullet list of what shipped (closed PRs, merged PRs, closed issues). */
54 shippedItems: string[];
55 /** What's still open / awaiting review. */
56 blockedItems: string[];
57 /** Items older than 3 days with no movement. */
58 atRiskItems: string[];
59 /** Window bounds (UTC) the standup covers. */
60 windowStart: Date;
61 windowEnd: Date;
62 /** Did Claude actually run, or did we fall back to a deterministic body? */
63 aiAvailable: boolean;
64}
65
66export interface GenerateStandupArgs {
67 userId: string;
68 scope: StandupScope;
69 /** Override the wall clock (tests). */
70 now?: Date;
71}
72
73interface PrSlice {
74 id: string;
75 number: number;
76 title: string;
77 state: string;
78 isAiBuilt: boolean;
79 mergedAt: Date | null;
80 updatedAt: Date;
81 createdAt: Date;
82 repo: string;
83}
84
85interface IssueSlice {
86 id: string;
87 number: number;
88 title: string;
89 state: string;
90 closedAt: Date | null;
91 updatedAt: Date;
92 createdAt: Date;
93 repo: string;
94}
95
96interface DeploySlice {
97 runId: string;
98 sha: string;
99 status: string;
100 startedAt: Date;
101 finishedAt: Date | null;
102}
103
104const DAILY_WINDOW_HOURS = 24;
105const WEEKLY_WINDOW_HOURS = 24 * 7;
106const STALE_HOURS = 24 * 3;
107const MAX_PRS = 40;
108const MAX_ISSUES = 40;
109const MAX_DEPLOYS = 20;
110const AI_TITLE_HINTS = ["ai:build", "[ai]", "ai-build"];
111
112/** UTC-day key (YYYY-MM-DD) for cheap dedupe. */
113export function utcDayKey(d: Date): string {
114 return d.toISOString().slice(0, 10);
115}
116
117function hoursAgo(now: Date, hours: number): Date {
118 return new Date(now.getTime() - hours * 60 * 60 * 1000);
119}
120
121/**
122 * Resolve the set of repository ids the user owns or collaborates on.
123 * Returns an empty array when the DB lookup fails so callers can degrade
124 * gracefully rather than throwing.
125 */
126async function listUserRepoIds(userId: string): Promise<string[]> {
127 try {
128 const owned = await db
129 .select({ id: repositories.id })
130 .from(repositories)
131 .where(eq(repositories.ownerId, userId));
132 const collabs = await db
133 .select({ id: repoCollaborators.repositoryId })
134 .from(repoCollaborators)
135 .where(eq(repoCollaborators.userId, userId));
136 const all = new Set<string>();
137 for (const r of owned) all.add(r.id);
138 for (const r of collabs) all.add(r.id);
139 return Array.from(all);
140 } catch (err) {
141 console.error("[ai-standup] listUserRepoIds failed:", err);
142 return [];
143 }
144}
145
146async function isUserSiteAdmin(userId: string): Promise<boolean> {
147 try {
148 const rows = await db
149 .select({ userId: siteAdmins.userId })
150 .from(siteAdmins)
151 .where(eq(siteAdmins.userId, userId))
152 .limit(1);
153 return rows.length > 0;
154 } catch {
155 return false;
156 }
157}
158
159async function loadRepoNames(repoIds: string[]): Promise<Record<string, string>> {
160 if (repoIds.length === 0) return {};
161 try {
162 const rows = await db
163 .select({
164 id: repositories.id,
165 name: repositories.name,
166 ownerId: repositories.ownerId,
167 })
168 .from(repositories)
169 .where(inArray(repositories.id, repoIds));
170 const owners = await db
171 .select({ id: users.id, username: users.username })
172 .from(users)
173 .where(
174 inArray(
175 users.id,
176 Array.from(new Set(rows.map((r) => r.ownerId)))
177 )
178 );
179 const ownerById = new Map(owners.map((o) => [o.id, o.username]));
180 const out: Record<string, string> = {};
181 for (const r of rows) {
182 const owner = ownerById.get(r.ownerId) || "unknown";
183 out[r.id] = `${owner}/${r.name}`;
184 }
185 return out;
186 } catch {
187 return {};
188 }
189}
190
191async function fetchPrSlice(
192 repoIds: string[],
193 windowStart: Date,
194 repoNames: Record<string, string>
195): Promise<PrSlice[]> {
196 if (repoIds.length === 0) return [];
197 try {
198 const rows = await db
199 .select({
200 id: pullRequests.id,
201 number: pullRequests.number,
202 title: pullRequests.title,
203 state: pullRequests.state,
204 mergedAt: pullRequests.mergedAt,
205 updatedAt: pullRequests.updatedAt,
206 createdAt: pullRequests.createdAt,
207 repositoryId: pullRequests.repositoryId,
208 headBranch: pullRequests.headBranch,
209 })
210 .from(pullRequests)
211 .where(
212 and(
213 inArray(pullRequests.repositoryId, repoIds),
214 or(
215 gte(pullRequests.updatedAt, windowStart),
216 gte(pullRequests.mergedAt, windowStart)
217 )
218 )
219 )
220 .orderBy(desc(pullRequests.updatedAt))
221 .limit(MAX_PRS);
222 return rows.map((r) => {
223 const haystack = `${r.title} ${r.headBranch}`.toLowerCase();
224 const isAiBuilt = AI_TITLE_HINTS.some((h) => haystack.includes(h));
225 return {
226 id: r.id,
227 number: r.number,
228 title: r.title,
229 state: r.state,
230 isAiBuilt,
231 mergedAt: r.mergedAt,
232 updatedAt: r.updatedAt,
233 createdAt: r.createdAt,
234 repo: repoNames[r.repositoryId] || "repo",
235 };
236 });
237 } catch (err) {
238 console.error("[ai-standup] fetchPrSlice failed:", err);
239 return [];
240 }
241}
242
243async function fetchIssueSlice(
244 repoIds: string[],
245 windowStart: Date,
246 repoNames: Record<string, string>
247): Promise<IssueSlice[]> {
248 if (repoIds.length === 0) return [];
249 try {
250 const rows = await db
251 .select({
252 id: issues.id,
253 number: issues.number,
254 title: issues.title,
255 state: issues.state,
256 closedAt: issues.closedAt,
257 updatedAt: issues.updatedAt,
258 createdAt: issues.createdAt,
259 repositoryId: issues.repositoryId,
260 })
261 .from(issues)
262 .where(
263 and(
264 inArray(issues.repositoryId, repoIds),
265 or(
266 gte(issues.updatedAt, windowStart),
267 gte(issues.createdAt, windowStart),
268 gte(issues.closedAt, windowStart)
269 )
270 )
271 )
272 .orderBy(desc(issues.updatedAt))
273 .limit(MAX_ISSUES);
274 return rows.map((r) => ({
275 id: r.id,
276 number: r.number,
277 title: r.title,
278 state: r.state,
279 closedAt: r.closedAt,
280 updatedAt: r.updatedAt,
281 createdAt: r.createdAt,
282 repo: repoNames[r.repositoryId] || "repo",
283 }));
284 } catch (err) {
285 console.error("[ai-standup] fetchIssueSlice failed:", err);
286 return [];
287 }
288}
289
290async function fetchDeploySlice(windowStart: Date): Promise<DeploySlice[]> {
291 try {
292 const rows = await db
293 .select({
294 runId: platformDeploys.runId,
295 sha: platformDeploys.sha,
296 status: platformDeploys.status,
297 startedAt: platformDeploys.startedAt,
298 finishedAt: platformDeploys.finishedAt,
299 })
300 .from(platformDeploys)
301 .where(gte(platformDeploys.startedAt, windowStart))
302 .orderBy(desc(platformDeploys.startedAt))
303 .limit(MAX_DEPLOYS);
304 return rows;
305 } catch (err) {
306 console.error("[ai-standup] fetchDeploySlice failed:", err);
307 return [];
308 }
309}
310
311// ---------------------------------------------------------------------------
312// Pure helpers — split material into shipped / in-flight / at-risk and render
313// the deterministic fallback summary. Exposed via __test for unit coverage.
314// ---------------------------------------------------------------------------
315
316export interface ClassifiedItems {
317 shipped: string[];
318 inFlight: string[];
319 atRisk: string[];
320 aiHighlights: string[];
321}
322
323export function classifyMaterial(args: {
324 prs: PrSlice[];
325 issues: IssueSlice[];
326 deploys: DeploySlice[];
327 now: Date;
328}): ClassifiedItems {
329 const { prs, issues: issueRows, deploys, now } = args;
330 const staleCutoff = hoursAgo(now, STALE_HOURS);
331
332 const shipped: string[] = [];
333 const inFlight: string[] = [];
334 const atRisk: string[] = [];
335 const aiHighlights: string[] = [];
336
337 for (const pr of prs) {
338 const line = `PR #${pr.number} ${pr.title} (${pr.repo})`;
339 if (pr.state === "merged" || pr.mergedAt) {
340 shipped.push(`Merged ${line}`);
341 if (pr.isAiBuilt) aiHighlights.push(`AI-merged ${line}`);
342 } else if (pr.state === "closed") {
343 shipped.push(`Closed ${line}`);
344 } else {
345 // open
346 if (pr.updatedAt < staleCutoff) {
347 atRisk.push(`Stale ${line} — no movement in 3+ days`);
348 } else {
349 inFlight.push(`Open ${line}`);
350 }
351 if (pr.isAiBuilt) aiHighlights.push(`AI-authored ${line}`);
352 }
353 }
354
355 for (const issue of issueRows) {
356 const line = `Issue #${issue.number} ${issue.title} (${issue.repo})`;
357 if (issue.state === "closed" || issue.closedAt) {
358 shipped.push(`Closed ${line}`);
359 } else if (issue.updatedAt < staleCutoff) {
360 atRisk.push(`Stale ${line} — no movement in 3+ days`);
361 } else {
362 inFlight.push(`Open ${line}`);
363 }
364 }
365
366 for (const dep of deploys) {
367 const shortSha = (dep.sha || "").slice(0, 7);
368 if (dep.status === "succeeded") {
369 shipped.push(`Deploy ${shortSha} succeeded`);
370 } else if (dep.status === "failed") {
371 atRisk.push(`Deploy ${shortSha} failed`);
372 } else {
373 inFlight.push(`Deploy ${shortSha} in progress`);
374 }
375 }
376
377 return { shipped, inFlight, atRisk, aiHighlights };
378}
379
380function renderFallbackSummary(
381 scope: StandupScope,
382 classified: ClassifiedItems
383): string {
384 const header =
385 scope === "daily"
386 ? "Daily standup (last 24 hours)"
387 : "Weekly standup (last 7 days)";
388 const lines: string[] = [`# ${header}`, ""];
389 lines.push("AI summary unavailable — raw activity below.", "");
390 lines.push("## 🚀 Shipped");
391 if (classified.shipped.length === 0) lines.push("- (nothing this window)");
392 else for (const s of classified.shipped.slice(0, 10)) lines.push(`- ${s}`);
393 lines.push("", "## 🚧 In flight");
394 if (classified.inFlight.length === 0) lines.push("- (nothing in flight)");
395 else for (const s of classified.inFlight.slice(0, 10)) lines.push(`- ${s}`);
396 lines.push("", "## ⚠️ At risk or blocked");
397 if (classified.atRisk.length === 0) lines.push("- (nothing at risk)");
398 else for (const s of classified.atRisk.slice(0, 10)) lines.push(`- ${s}`);
399 if (classified.aiHighlights.length > 0) {
400 lines.push("", "## 🤖 AI-driven changes");
401 for (const s of classified.aiHighlights.slice(0, 10)) lines.push(`- ${s}`);
402 }
403 return lines.join("\n");
404}
405
406function buildPrompt(
407 scope: StandupScope,
408 classified: ClassifiedItems
409): string {
410 const scopeLabel =
411 scope === "daily" ? "the last 24 hours" : "the last 7 days";
412 return [
413 "You are Gluecron's standup writer. Generate a 200-300 word standup brief for a developer.",
414 `Window: ${scopeLabel}.`,
415 "",
416 "Required sections (in order):",
417 " 🚀 Shipped — concise list of merged/closed/deployed items",
418 " 🚧 In flight — open PRs / issues with recent activity",
419 " ⚠️ At risk or blocked — anything older than 3 days with no movement",
420 " 🤖 AI-driven changes — highlight separately if any (omit section if none)",
421 "",
422 "Tone: factual, terse, developer-friendly. No filler. No 'great work team' fluff.",
423 "Output: plain markdown, sections as headings (##). Word count 200-300.",
424 "",
425 "Material:",
426 "Shipped (raw):",
427 classified.shipped.slice(0, 20).map((s) => `- ${s}`).join("\n") ||
428 "- (none)",
429 "",
430 "In-flight (raw):",
431 classified.inFlight.slice(0, 20).map((s) => `- ${s}`).join("\n") ||
432 "- (none)",
433 "",
434 "At-risk (raw):",
435 classified.atRisk.slice(0, 20).map((s) => `- ${s}`).join("\n") ||
436 "- (none)",
437 "",
438 "AI highlights (raw):",
439 classified.aiHighlights.slice(0, 20).map((s) => `- ${s}`).join("\n") ||
440 "- (none)",
441 "",
442 "Respond with JSON ONLY (no prose around it) of the shape:",
443 '{"summary": "<markdown body>", "shippedItems": ["..."], "blockedItems": ["..."], "atRiskItems": ["..."]}',
444 ].join("\n");
445}
446
447async function askClaudeForStandup(
448 scope: StandupScope,
449 classified: ClassifiedItems
450): Promise<Pick<
451 StandupResult,
452 "summary" | "shippedItems" | "blockedItems" | "atRiskItems"
453> | null> {
454 try {
455 const client = getAnthropic();
456 const message = await client.messages.create({
457 model: MODEL_SONNET,
458 max_tokens: 1500,
459 messages: [{ role: "user", content: buildPrompt(scope, classified) }],
460 });
461 const parsed = parseJsonResponse<{
462 summary?: string;
463 shippedItems?: unknown;
464 blockedItems?: unknown;
465 atRiskItems?: unknown;
466 }>(extractText(message));
467 if (!parsed) return null;
468 const arrify = (v: unknown): string[] =>
469 Array.isArray(v) ? v.filter((x) => typeof x === "string") as string[] : [];
470 return {
471 summary:
472 typeof parsed.summary === "string" && parsed.summary.trim()
473 ? parsed.summary.trim()
474 : renderFallbackSummary(scope, classified),
475 shippedItems: arrify(parsed.shippedItems),
476 blockedItems: arrify(parsed.blockedItems),
477 atRiskItems: arrify(parsed.atRiskItems),
478 };
479 } catch (err) {
480 console.error("[ai-standup] askClaudeForStandup failed:", err);
481 return null;
482 }
483}
484
485/**
486 * Generate a standup for a single user. Never throws. When the AI key is
487 * absent the returned `summary` falls back to a deterministic body so
488 * callers can still display + email something meaningful.
489 */
490export async function generateStandup(
491 args: GenerateStandupArgs
492): Promise<StandupResult> {
493 const now = args.now ?? new Date();
494 const windowHours =
495 args.scope === "weekly" ? WEEKLY_WINDOW_HOURS : DAILY_WINDOW_HOURS;
496 const windowStart = hoursAgo(now, windowHours);
497
498 const repoIds = await listUserRepoIds(args.userId);
499 const repoNames = await loadRepoNames(repoIds);
500
501 const [prs, issueRows, isAdmin] = await Promise.all([
502 fetchPrSlice(repoIds, windowStart, repoNames),
503 fetchIssueSlice(repoIds, windowStart, repoNames),
504 isUserSiteAdmin(args.userId),
505 ]);
506 const deploys = isAdmin ? await fetchDeploySlice(windowStart) : [];
507
508 const classified = classifyMaterial({
509 prs,
510 issues: issueRows,
511 deploys,
512 now,
513 });
514
515 const fallback = {
516 summary: renderFallbackSummary(args.scope, classified),
517 shippedItems: classified.shipped.slice(0, 12),
518 blockedItems: classified.inFlight.slice(0, 12),
519 atRiskItems: classified.atRisk.slice(0, 12),
520 };
521
522 let aiAvailable = false;
523 let chosen = fallback;
524 if (isAiAvailable()) {
525 const ai = await askClaudeForStandup(args.scope, classified);
526 if (ai) {
527 aiAvailable = true;
528 chosen = {
529 summary: ai.summary,
530 shippedItems:
531 ai.shippedItems.length > 0
532 ? ai.shippedItems
533 : fallback.shippedItems,
534 blockedItems:
535 ai.blockedItems.length > 0
536 ? ai.blockedItems
537 : fallback.blockedItems,
538 atRiskItems:
539 ai.atRiskItems.length > 0
540 ? ai.atRiskItems
541 : fallback.atRiskItems,
542 };
543 }
544 }
545
546 return {
547 summary: chosen.summary,
548 shippedItems: chosen.shippedItems,
549 blockedItems: chosen.blockedItems,
550 atRiskItems: chosen.atRiskItems,
551 windowStart,
552 windowEnd: now,
553 aiAvailable,
554 };
555}
556
557// ---------------------------------------------------------------------------
558// Persistence + delivery
559// ---------------------------------------------------------------------------
560
561export interface DeliverStandupResult {
562 ok: boolean;
563 /** New `ai_standups.id` when one was inserted, null otherwise. */
564 standupId: string | null;
565 reason?: string;
566 emailed: boolean;
567 notified: boolean;
568}
569
570/**
571 * Look up the user's standup pref row, returning null when none exists
572 * (i.e. they've never toggled anything → treated as opted-out).
573 */
574export async function getStandupPrefs(
575 userId: string
576): Promise<{
577 dailyEnabled: boolean;
578 weeklyEnabled: boolean;
579 emailEnabled: boolean;
580 hourUtc: number;
581 lastDailySentAt: Date | null;
582 lastWeeklySentAt: Date | null;
583} | null> {
584 try {
585 const [row] = await db
586 .select()
587 .from(userStandupPrefs)
588 .where(eq(userStandupPrefs.userId, userId))
589 .limit(1);
590 if (!row) return null;
591 return {
592 dailyEnabled: row.dailyEnabled,
593 weeklyEnabled: row.weeklyEnabled,
594 emailEnabled: row.emailEnabled,
595 hourUtc: row.hourUtc,
596 lastDailySentAt: row.lastDailySentAt,
597 lastWeeklySentAt: row.lastWeeklySentAt,
598 };
599 } catch (err) {
600 console.error("[ai-standup] getStandupPrefs failed:", err);
601 return null;
602 }
603}
604
605/**
606 * Upsert the standup preferences for a user. Used by /settings.
607 */
608export async function setStandupPrefs(
609 userId: string,
610 prefs: {
611 dailyEnabled: boolean;
612 weeklyEnabled: boolean;
613 emailEnabled: boolean;
614 hourUtc?: number;
615 }
616): Promise<void> {
617 const hour = (() => {
618 if (typeof prefs.hourUtc !== "number" || !Number.isFinite(prefs.hourUtc))
619 return 9;
620 const h = Math.floor(prefs.hourUtc);
621 if (h < 0) return 0;
622 if (h > 23) return 23;
623 return h;
624 })();
625 try {
626 const existing = await db
627 .select({ userId: userStandupPrefs.userId })
628 .from(userStandupPrefs)
629 .where(eq(userStandupPrefs.userId, userId))
630 .limit(1);
631 if (existing.length === 0) {
632 await db.insert(userStandupPrefs).values({
633 userId,
634 dailyEnabled: prefs.dailyEnabled,
635 weeklyEnabled: prefs.weeklyEnabled,
636 emailEnabled: prefs.emailEnabled,
637 hourUtc: hour,
638 });
639 } else {
640 await db
641 .update(userStandupPrefs)
642 .set({
643 dailyEnabled: prefs.dailyEnabled,
644 weeklyEnabled: prefs.weeklyEnabled,
645 emailEnabled: prefs.emailEnabled,
646 hourUtc: hour,
647 updatedAt: new Date(),
648 })
649 .where(eq(userStandupPrefs.userId, userId));
650 }
651 } catch (err) {
652 console.error("[ai-standup] setStandupPrefs failed:", err);
653 }
654}
655
656/**
657 * List recent standups for a user (for the /standups feed).
658 */
659export async function listRecentStandups(
660 userId: string,
661 limit = 30
662): Promise<
663 Array<{
664 id: string;
665 scope: string;
666 summary: string;
667 shippedItems: string[];
668 blockedItems: string[];
669 atRiskItems: string[];
670 windowStart: Date;
671 windowEnd: Date;
672 aiAvailable: boolean;
673 createdAt: Date;
674 }>
675> {
676 try {
677 const rows = await db
678 .select()
679 .from(aiStandups)
680 .where(eq(aiStandups.userId, userId))
681 .orderBy(desc(aiStandups.createdAt))
682 .limit(limit);
683 const parseArr = (raw: string): string[] => {
684 try {
685 const v = JSON.parse(raw);
686 return Array.isArray(v)
687 ? (v.filter((x) => typeof x === "string") as string[])
688 : [];
689 } catch {
690 return [];
691 }
692 };
693 return rows.map((r) => ({
694 id: r.id,
695 scope: r.scope,
696 summary: r.summary,
697 shippedItems: parseArr(r.shippedItems),
698 blockedItems: parseArr(r.blockedItems),
699 atRiskItems: parseArr(r.atRiskItems),
700 windowStart: r.windowStart,
701 windowEnd: r.windowEnd,
702 aiAvailable: r.aiAvailable,
703 createdAt: r.createdAt,
704 }));
705 } catch (err) {
706 console.error("[ai-standup] listRecentStandups failed:", err);
707 return [];
708 }
709}
710
711/**
712 * Has the user already received a standup for this (scope, UTC day)?
713 * Used to dedupe so the autopilot doesn't double-fire on a tight tick loop.
714 */
715export async function hasStandupForToday(
716 userId: string,
717 scope: StandupScope,
718 now: Date
719): Promise<boolean> {
720 const key = utcDayKey(now);
721 try {
722 // We pull the most recent row for this user+scope; matching by UTC day
723 // happens in JS so this stays portable across drizzle backends.
724 const rows = await db
725 .select({ createdAt: aiStandups.createdAt })
726 .from(aiStandups)
727 .where(
728 and(eq(aiStandups.userId, userId), eq(aiStandups.scope, scope))
729 )
730 .orderBy(desc(aiStandups.createdAt))
731 .limit(1);
732 if (rows.length === 0) return false;
733 const latest = rows[0].createdAt;
734 return utcDayKey(new Date(latest)) === key;
735 } catch (err) {
736 console.error("[ai-standup] hasStandupForToday failed:", err);
737 // Fail-open: if we can't tell, assume not (better to send than dedupe-block).
738 return false;
739 }
740}
741
742/** Notification kind for standups. */
743export const STANDUP_NOTIFICATION_KIND: NotificationKind =
744 "ai_review" as NotificationKind;
745// Note: NotificationKind is closed today; we re-use 'ai_review' for now —
746// the inbox surface treats anything with kind != mention/assign/gate_failed
747// as a generic info card, so this lights up correctly.
748// The user-facing title still says "Standup" so the recipient isn't confused.
749
750export interface DeliverStandupArgs {
751 userId: string;
752 scope: StandupScope;
753 /** Override the wall clock (tests). */
754 now?: Date;
755 /** Inject a generator (tests). */
756 generate?: (args: GenerateStandupArgs) => Promise<StandupResult>;
757 /** Inject the dedupe check (tests). */
758 alreadyDelivered?: (
759 userId: string,
760 scope: StandupScope,
761 now: Date
762 ) => Promise<boolean>;
763 /** Force-skip the dedupe gate (tests). */
764 bypassDedupe?: boolean;
765}
766
767/**
768 * Generate + deliver a standup. Used by the autopilot tasks. Never throws.
769 */
770export async function deliverStandup(
771 args: DeliverStandupArgs
772): Promise<DeliverStandupResult> {
773 const now = args.now ?? new Date();
774 const generate = args.generate ?? generateStandup;
775 const dedupeFn = args.alreadyDelivered ?? hasStandupForToday;
776
777 try {
778 if (!args.bypassDedupe) {
779 const already = await dedupeFn(args.userId, args.scope, now);
780 if (already) {
781 return {
782 ok: false,
783 standupId: null,
784 reason: "already delivered today",
785 emailed: false,
786 notified: false,
787 };
788 }
789 }
790
791 const result = await generate({
792 userId: args.userId,
793 scope: args.scope,
794 now,
795 });
796
797 let standupId: string | null = null;
798 try {
799 const [row] = await db
800 .insert(aiStandups)
801 .values({
802 userId: args.userId,
803 scope: args.scope,
804 summary: result.summary,
805 shippedItems: JSON.stringify(result.shippedItems),
806 blockedItems: JSON.stringify(result.blockedItems),
807 atRiskItems: JSON.stringify(result.atRiskItems),
808 windowStart: result.windowStart,
809 windowEnd: result.windowEnd,
810 aiAvailable: result.aiAvailable,
811 })
812 .returning();
813 standupId = row?.id ?? null;
814 } catch (err) {
815 console.error("[ai-standup] insert failed:", err);
816 }
817
818 const scopeLabel = args.scope === "daily" ? "Daily" : "Weekly";
819 const title = `${scopeLabel} standup — ${result.shippedItems.length} shipped, ${result.atRiskItems.length} at risk`;
820
821 let notified = false;
822 try {
823 await notify(args.userId, {
824 kind: STANDUP_NOTIFICATION_KIND,
825 title,
826 body: result.summary,
827 url: standupId ? `/standups#${standupId}` : "/standups",
828 });
829 notified = true;
830 } catch (err) {
831 console.error("[ai-standup] notify failed:", err);
832 }
833
834 // Email — only when the user has emailEnabled.
835 let emailed = false;
836 try {
837 const prefs = await getStandupPrefs(args.userId);
838 if (prefs?.emailEnabled) {
839 const [u] = await db
840 .select({ email: users.email, username: users.username })
841 .from(users)
842 .where(eq(users.id, args.userId))
843 .limit(1);
844 if (u?.email) {
845 const subject = `[gluecron] ${scopeLabel} standup`;
846 const link = absoluteUrl("/standups");
847 const text = [
848 `Hi ${u.username || "there"},`,
849 "",
850 result.summary,
851 "",
852 "—",
853 `Full feed: ${link}`,
854 "Opt out at /settings.",
855 ].join("\n");
856 const res = await sendEmail({
857 to: u.email,
858 subject,
859 text,
860 });
861 emailed = !!res.ok;
862 }
863 }
864 } catch (err) {
865 console.error("[ai-standup] email failed:", err);
866 }
867
868 // Stamp the last-sent timestamp so the autopilot hour gate behaves.
869 try {
870 const column =
871 args.scope === "daily"
872 ? userStandupPrefs.lastDailySentAt
873 : userStandupPrefs.lastWeeklySentAt;
874 const set =
875 args.scope === "daily"
876 ? { lastDailySentAt: now, updatedAt: now }
877 : { lastWeeklySentAt: now, updatedAt: now };
878 // best-effort update — no-op if the pref row doesn't exist.
879 void column;
880 await db
881 .update(userStandupPrefs)
882 .set(set)
883 .where(eq(userStandupPrefs.userId, args.userId));
884 } catch {
885 /* best-effort */
886 }
887
888 return {
889 ok: true,
890 standupId,
891 emailed,
892 notified,
893 };
894 } catch (err) {
895 console.error("[ai-standup] deliverStandup error:", err);
896 return {
897 ok: false,
898 standupId: null,
899 reason: (err as Error).message || "error",
900 emailed: false,
901 notified: false,
902 };
903 }
904}
905
906// ---------------------------------------------------------------------------
907// Autopilot tick helpers
908// ---------------------------------------------------------------------------
909
910export interface StandupTickSummary {
911 scope: StandupScope;
912 sent: number;
913 skipped: number;
914 errors: number;
915}
916
917export interface StandupTickDeps {
918 /** Inject the candidate finder (tests). */
919 findCandidates?: () => Promise<
920 Array<{
921 userId: string;
922 hourUtc: number;
923 lastDailySentAt: Date | null;
924 lastWeeklySentAt: Date | null;
925 }>
926 >;
927 /** Inject the delivery side-effect (tests). */
928 deliver?: (
929 userId: string,
930 scope: StandupScope,
931 now: Date
932 ) => Promise<DeliverStandupResult>;
933 /** Inject the wall clock (tests). */
934 now?: () => Date;
935}
936
937async function defaultFindDailyCandidates(): Promise<
938 Array<{
939 userId: string;
940 hourUtc: number;
941 lastDailySentAt: Date | null;
942 lastWeeklySentAt: Date | null;
943 }>
944> {
945 try {
946 const rows = await db
947 .select({
948 userId: userStandupPrefs.userId,
949 hourUtc: userStandupPrefs.hourUtc,
950 lastDailySentAt: userStandupPrefs.lastDailySentAt,
951 lastWeeklySentAt: userStandupPrefs.lastWeeklySentAt,
952 })
953 .from(userStandupPrefs)
954 .where(eq(userStandupPrefs.dailyEnabled, true))
955 .limit(500);
956 return rows;
957 } catch (err) {
958 console.error("[ai-standup] defaultFindDailyCandidates failed:", err);
959 return [];
960 }
961}
962
963async function defaultFindWeeklyCandidates(): Promise<
964 Array<{
965 userId: string;
966 hourUtc: number;
967 lastDailySentAt: Date | null;
968 lastWeeklySentAt: Date | null;
969 }>
970> {
971 try {
972 const rows = await db
973 .select({
974 userId: userStandupPrefs.userId,
975 hourUtc: userStandupPrefs.hourUtc,
976 lastDailySentAt: userStandupPrefs.lastDailySentAt,
977 lastWeeklySentAt: userStandupPrefs.lastWeeklySentAt,
978 })
979 .from(userStandupPrefs)
980 .where(eq(userStandupPrefs.weeklyEnabled, true))
981 .limit(500);
982 return rows;
983 } catch (err) {
984 console.error("[ai-standup] defaultFindWeeklyCandidates failed:", err);
985 return [];
986 }
987}
988
989/**
990 * One iteration of the daily-standup autopilot task. Fires for users whose
991 * `hour_utc` matches the current UTC hour. Skipped entirely when the AI key
992 * is missing — gracefully degrades to a deterministic body, but only if a
993 * user explicitly requested standups.
994 */
995export async function runDailyStandupTaskOnce(
996 deps: StandupTickDeps = {}
997): Promise<StandupTickSummary> {
998 const now = deps.now ? deps.now() : new Date();
999 const findCandidates = deps.findCandidates ?? defaultFindDailyCandidates;
1000 const deliver =
1001 deps.deliver ??
1002 (async (userId, scope, _now) =>
1003 deliverStandup({ userId, scope, now: _now }));
1004
1005 let sent = 0;
1006 let skipped = 0;
1007 let errors = 0;
1008 const currentHour = now.getUTCHours();
1009
1010 let candidates: Awaited<ReturnType<typeof findCandidates>> = [];
1011 try {
1012 candidates = await findCandidates();
1013 } catch {
1014 return { scope: "daily", sent, skipped, errors: 1 };
1015 }
1016
1017 for (const cand of candidates) {
1018 if (cand.hourUtc !== currentHour) {
1019 skipped += 1;
1020 continue;
1021 }
1022 try {
1023 const res = await deliver(cand.userId, "daily", now);
1024 if (res.ok) sent += 1;
1025 else skipped += 1;
1026 } catch {
1027 errors += 1;
1028 }
1029 }
1030
1031 return { scope: "daily", sent, skipped, errors };
1032}
1033
1034/**
1035 * One iteration of the weekly-standup autopilot task. Fires Mondays only,
1036 * at the user's configured hour.
1037 */
1038export async function runWeeklyStandupTaskOnce(
1039 deps: StandupTickDeps = {}
1040): Promise<StandupTickSummary> {
1041 const now = deps.now ? deps.now() : new Date();
1042 const findCandidates = deps.findCandidates ?? defaultFindWeeklyCandidates;
1043 const deliver =
1044 deps.deliver ??
1045 (async (userId, scope, _now) =>
1046 deliverStandup({ userId, scope, now: _now }));
1047
1048 // getUTCDay: 0=Sun, 1=Mon, ...
1049 const isMonday = now.getUTCDay() === 1;
1050 const currentHour = now.getUTCHours();
1051
1052 let sent = 0;
1053 let skipped = 0;
1054 let errors = 0;
1055
1056 if (!isMonday) {
1057 return { scope: "weekly", sent, skipped, errors };
1058 }
1059
1060 let candidates: Awaited<ReturnType<typeof findCandidates>> = [];
1061 try {
1062 candidates = await findCandidates();
1063 } catch {
1064 return { scope: "weekly", sent, skipped, errors: 1 };
1065 }
1066
1067 for (const cand of candidates) {
1068 if (cand.hourUtc !== currentHour) {
1069 skipped += 1;
1070 continue;
1071 }
1072 try {
1073 const res = await deliver(cand.userId, "weekly", now);
1074 if (res.ok) sent += 1;
1075 else skipped += 1;
1076 } catch {
1077 errors += 1;
1078 }
1079 }
1080
1081 return { scope: "weekly", sent, skipped, errors };
1082}
1083
1084// Suppress unused-import warning when `sql` is dropped by tree-shaking;
1085// keep the import for forward-compatible queries below.
1086void sql;
1087
1088/** Test-only surface. */
1089export const __test = {
1090 DAILY_WINDOW_HOURS,
1091 WEEKLY_WINDOW_HOURS,
1092 STALE_HOURS,
1093 classifyMaterial,
1094 renderFallbackSummary,
1095 buildPrompt,
1096 utcDayKey,
1097};