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

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.tsBlame1110 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 });
0c3eee5Claude461 try {
462 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
463 const usage = extractUsage(message);
464 await recordAiCost({
465 model: MODEL_SONNET,
466 inputTokens: usage.input,
467 outputTokens: usage.output,
468 category: "standup",
469 sourceKind: "standup",
470 });
471 } catch {
472 /* swallow — best-effort */
473 }
a686079Claude474 const parsed = parseJsonResponse<{
475 summary?: string;
476 shippedItems?: unknown;
477 blockedItems?: unknown;
478 atRiskItems?: unknown;
479 }>(extractText(message));
480 if (!parsed) return null;
481 const arrify = (v: unknown): string[] =>
482 Array.isArray(v) ? v.filter((x) => typeof x === "string") as string[] : [];
483 return {
484 summary:
485 typeof parsed.summary === "string" && parsed.summary.trim()
486 ? parsed.summary.trim()
487 : renderFallbackSummary(scope, classified),
488 shippedItems: arrify(parsed.shippedItems),
489 blockedItems: arrify(parsed.blockedItems),
490 atRiskItems: arrify(parsed.atRiskItems),
491 };
492 } catch (err) {
493 console.error("[ai-standup] askClaudeForStandup failed:", err);
494 return null;
495 }
496}
497
498/**
499 * Generate a standup for a single user. Never throws. When the AI key is
500 * absent the returned `summary` falls back to a deterministic body so
501 * callers can still display + email something meaningful.
502 */
503export async function generateStandup(
504 args: GenerateStandupArgs
505): Promise<StandupResult> {
506 const now = args.now ?? new Date();
507 const windowHours =
508 args.scope === "weekly" ? WEEKLY_WINDOW_HOURS : DAILY_WINDOW_HOURS;
509 const windowStart = hoursAgo(now, windowHours);
510
511 const repoIds = await listUserRepoIds(args.userId);
512 const repoNames = await loadRepoNames(repoIds);
513
514 const [prs, issueRows, isAdmin] = await Promise.all([
515 fetchPrSlice(repoIds, windowStart, repoNames),
516 fetchIssueSlice(repoIds, windowStart, repoNames),
517 isUserSiteAdmin(args.userId),
518 ]);
519 const deploys = isAdmin ? await fetchDeploySlice(windowStart) : [];
520
521 const classified = classifyMaterial({
522 prs,
523 issues: issueRows,
524 deploys,
525 now,
526 });
527
528 const fallback = {
529 summary: renderFallbackSummary(args.scope, classified),
530 shippedItems: classified.shipped.slice(0, 12),
531 blockedItems: classified.inFlight.slice(0, 12),
532 atRiskItems: classified.atRisk.slice(0, 12),
533 };
534
535 let aiAvailable = false;
536 let chosen = fallback;
537 if (isAiAvailable()) {
538 const ai = await askClaudeForStandup(args.scope, classified);
539 if (ai) {
540 aiAvailable = true;
541 chosen = {
542 summary: ai.summary,
543 shippedItems:
544 ai.shippedItems.length > 0
545 ? ai.shippedItems
546 : fallback.shippedItems,
547 blockedItems:
548 ai.blockedItems.length > 0
549 ? ai.blockedItems
550 : fallback.blockedItems,
551 atRiskItems:
552 ai.atRiskItems.length > 0
553 ? ai.atRiskItems
554 : fallback.atRiskItems,
555 };
556 }
557 }
558
559 return {
560 summary: chosen.summary,
561 shippedItems: chosen.shippedItems,
562 blockedItems: chosen.blockedItems,
563 atRiskItems: chosen.atRiskItems,
564 windowStart,
565 windowEnd: now,
566 aiAvailable,
567 };
568}
569
570// ---------------------------------------------------------------------------
571// Persistence + delivery
572// ---------------------------------------------------------------------------
573
574export interface DeliverStandupResult {
575 ok: boolean;
576 /** New `ai_standups.id` when one was inserted, null otherwise. */
577 standupId: string | null;
578 reason?: string;
579 emailed: boolean;
580 notified: boolean;
581}
582
583/**
584 * Look up the user's standup pref row, returning null when none exists
585 * (i.e. they've never toggled anything → treated as opted-out).
586 */
587export async function getStandupPrefs(
588 userId: string
589): Promise<{
590 dailyEnabled: boolean;
591 weeklyEnabled: boolean;
592 emailEnabled: boolean;
593 hourUtc: number;
594 lastDailySentAt: Date | null;
595 lastWeeklySentAt: Date | null;
596} | null> {
597 try {
598 const [row] = await db
599 .select()
600 .from(userStandupPrefs)
601 .where(eq(userStandupPrefs.userId, userId))
602 .limit(1);
603 if (!row) return null;
604 return {
605 dailyEnabled: row.dailyEnabled,
606 weeklyEnabled: row.weeklyEnabled,
607 emailEnabled: row.emailEnabled,
608 hourUtc: row.hourUtc,
609 lastDailySentAt: row.lastDailySentAt,
610 lastWeeklySentAt: row.lastWeeklySentAt,
611 };
612 } catch (err) {
613 console.error("[ai-standup] getStandupPrefs failed:", err);
614 return null;
615 }
616}
617
618/**
619 * Upsert the standup preferences for a user. Used by /settings.
620 */
621export async function setStandupPrefs(
622 userId: string,
623 prefs: {
624 dailyEnabled: boolean;
625 weeklyEnabled: boolean;
626 emailEnabled: boolean;
627 hourUtc?: number;
628 }
629): Promise<void> {
630 const hour = (() => {
631 if (typeof prefs.hourUtc !== "number" || !Number.isFinite(prefs.hourUtc))
632 return 9;
633 const h = Math.floor(prefs.hourUtc);
634 if (h < 0) return 0;
635 if (h > 23) return 23;
636 return h;
637 })();
638 try {
639 const existing = await db
640 .select({ userId: userStandupPrefs.userId })
641 .from(userStandupPrefs)
642 .where(eq(userStandupPrefs.userId, userId))
643 .limit(1);
644 if (existing.length === 0) {
645 await db.insert(userStandupPrefs).values({
646 userId,
647 dailyEnabled: prefs.dailyEnabled,
648 weeklyEnabled: prefs.weeklyEnabled,
649 emailEnabled: prefs.emailEnabled,
650 hourUtc: hour,
651 });
652 } else {
653 await db
654 .update(userStandupPrefs)
655 .set({
656 dailyEnabled: prefs.dailyEnabled,
657 weeklyEnabled: prefs.weeklyEnabled,
658 emailEnabled: prefs.emailEnabled,
659 hourUtc: hour,
660 updatedAt: new Date(),
661 })
662 .where(eq(userStandupPrefs.userId, userId));
663 }
664 } catch (err) {
665 console.error("[ai-standup] setStandupPrefs failed:", err);
666 }
667}
668
669/**
670 * List recent standups for a user (for the /standups feed).
671 */
672export async function listRecentStandups(
673 userId: string,
674 limit = 30
675): Promise<
676 Array<{
677 id: string;
678 scope: string;
679 summary: string;
680 shippedItems: string[];
681 blockedItems: string[];
682 atRiskItems: string[];
683 windowStart: Date;
684 windowEnd: Date;
685 aiAvailable: boolean;
686 createdAt: Date;
687 }>
688> {
689 try {
690 const rows = await db
691 .select()
692 .from(aiStandups)
693 .where(eq(aiStandups.userId, userId))
694 .orderBy(desc(aiStandups.createdAt))
695 .limit(limit);
696 const parseArr = (raw: string): string[] => {
697 try {
698 const v = JSON.parse(raw);
699 return Array.isArray(v)
700 ? (v.filter((x) => typeof x === "string") as string[])
701 : [];
702 } catch {
703 return [];
704 }
705 };
706 return rows.map((r) => ({
707 id: r.id,
708 scope: r.scope,
709 summary: r.summary,
710 shippedItems: parseArr(r.shippedItems),
711 blockedItems: parseArr(r.blockedItems),
712 atRiskItems: parseArr(r.atRiskItems),
713 windowStart: r.windowStart,
714 windowEnd: r.windowEnd,
715 aiAvailable: r.aiAvailable,
716 createdAt: r.createdAt,
717 }));
718 } catch (err) {
719 console.error("[ai-standup] listRecentStandups failed:", err);
720 return [];
721 }
722}
723
724/**
725 * Has the user already received a standup for this (scope, UTC day)?
726 * Used to dedupe so the autopilot doesn't double-fire on a tight tick loop.
727 */
728export async function hasStandupForToday(
729 userId: string,
730 scope: StandupScope,
731 now: Date
732): Promise<boolean> {
733 const key = utcDayKey(now);
734 try {
735 // We pull the most recent row for this user+scope; matching by UTC day
736 // happens in JS so this stays portable across drizzle backends.
737 const rows = await db
738 .select({ createdAt: aiStandups.createdAt })
739 .from(aiStandups)
740 .where(
741 and(eq(aiStandups.userId, userId), eq(aiStandups.scope, scope))
742 )
743 .orderBy(desc(aiStandups.createdAt))
744 .limit(1);
745 if (rows.length === 0) return false;
746 const latest = rows[0].createdAt;
747 return utcDayKey(new Date(latest)) === key;
748 } catch (err) {
749 console.error("[ai-standup] hasStandupForToday failed:", err);
750 // Fail-open: if we can't tell, assume not (better to send than dedupe-block).
751 return false;
752 }
753}
754
755/** Notification kind for standups. */
756export const STANDUP_NOTIFICATION_KIND: NotificationKind =
757 "ai_review" as NotificationKind;
758// Note: NotificationKind is closed today; we re-use 'ai_review' for now —
759// the inbox surface treats anything with kind != mention/assign/gate_failed
760// as a generic info card, so this lights up correctly.
761// The user-facing title still says "Standup" so the recipient isn't confused.
762
763export interface DeliverStandupArgs {
764 userId: string;
765 scope: StandupScope;
766 /** Override the wall clock (tests). */
767 now?: Date;
768 /** Inject a generator (tests). */
769 generate?: (args: GenerateStandupArgs) => Promise<StandupResult>;
770 /** Inject the dedupe check (tests). */
771 alreadyDelivered?: (
772 userId: string,
773 scope: StandupScope,
774 now: Date
775 ) => Promise<boolean>;
776 /** Force-skip the dedupe gate (tests). */
777 bypassDedupe?: boolean;
778}
779
780/**
781 * Generate + deliver a standup. Used by the autopilot tasks. Never throws.
782 */
783export async function deliverStandup(
784 args: DeliverStandupArgs
785): Promise<DeliverStandupResult> {
786 const now = args.now ?? new Date();
787 const generate = args.generate ?? generateStandup;
788 const dedupeFn = args.alreadyDelivered ?? hasStandupForToday;
789
790 try {
791 if (!args.bypassDedupe) {
792 const already = await dedupeFn(args.userId, args.scope, now);
793 if (already) {
794 return {
795 ok: false,
796 standupId: null,
797 reason: "already delivered today",
798 emailed: false,
799 notified: false,
800 };
801 }
802 }
803
804 const result = await generate({
805 userId: args.userId,
806 scope: args.scope,
807 now,
808 });
809
810 let standupId: string | null = null;
811 try {
812 const [row] = await db
813 .insert(aiStandups)
814 .values({
815 userId: args.userId,
816 scope: args.scope,
817 summary: result.summary,
818 shippedItems: JSON.stringify(result.shippedItems),
819 blockedItems: JSON.stringify(result.blockedItems),
820 atRiskItems: JSON.stringify(result.atRiskItems),
821 windowStart: result.windowStart,
822 windowEnd: result.windowEnd,
823 aiAvailable: result.aiAvailable,
824 })
825 .returning();
826 standupId = row?.id ?? null;
827 } catch (err) {
828 console.error("[ai-standup] insert failed:", err);
829 }
830
831 const scopeLabel = args.scope === "daily" ? "Daily" : "Weekly";
832 const title = `${scopeLabel} standup — ${result.shippedItems.length} shipped, ${result.atRiskItems.length} at risk`;
833
834 let notified = false;
835 try {
836 await notify(args.userId, {
837 kind: STANDUP_NOTIFICATION_KIND,
838 title,
839 body: result.summary,
840 url: standupId ? `/standups#${standupId}` : "/standups",
841 });
842 notified = true;
843 } catch (err) {
844 console.error("[ai-standup] notify failed:", err);
845 }
846
847 // Email — only when the user has emailEnabled.
848 let emailed = false;
849 try {
850 const prefs = await getStandupPrefs(args.userId);
851 if (prefs?.emailEnabled) {
852 const [u] = await db
853 .select({ email: users.email, username: users.username })
854 .from(users)
855 .where(eq(users.id, args.userId))
856 .limit(1);
857 if (u?.email) {
858 const subject = `[gluecron] ${scopeLabel} standup`;
859 const link = absoluteUrl("/standups");
860 const text = [
861 `Hi ${u.username || "there"},`,
862 "",
863 result.summary,
864 "",
865 "—",
866 `Full feed: ${link}`,
867 "Opt out at /settings.",
868 ].join("\n");
869 const res = await sendEmail({
870 to: u.email,
871 subject,
872 text,
873 });
874 emailed = !!res.ok;
875 }
876 }
877 } catch (err) {
878 console.error("[ai-standup] email failed:", err);
879 }
880
881 // Stamp the last-sent timestamp so the autopilot hour gate behaves.
882 try {
883 const column =
884 args.scope === "daily"
885 ? userStandupPrefs.lastDailySentAt
886 : userStandupPrefs.lastWeeklySentAt;
887 const set =
888 args.scope === "daily"
889 ? { lastDailySentAt: now, updatedAt: now }
890 : { lastWeeklySentAt: now, updatedAt: now };
891 // best-effort update — no-op if the pref row doesn't exist.
892 void column;
893 await db
894 .update(userStandupPrefs)
895 .set(set)
896 .where(eq(userStandupPrefs.userId, args.userId));
897 } catch {
898 /* best-effort */
899 }
900
901 return {
902 ok: true,
903 standupId,
904 emailed,
905 notified,
906 };
907 } catch (err) {
908 console.error("[ai-standup] deliverStandup error:", err);
909 return {
910 ok: false,
911 standupId: null,
912 reason: (err as Error).message || "error",
913 emailed: false,
914 notified: false,
915 };
916 }
917}
918
919// ---------------------------------------------------------------------------
920// Autopilot tick helpers
921// ---------------------------------------------------------------------------
922
923export interface StandupTickSummary {
924 scope: StandupScope;
925 sent: number;
926 skipped: number;
927 errors: number;
928}
929
930export interface StandupTickDeps {
931 /** Inject the candidate finder (tests). */
932 findCandidates?: () => Promise<
933 Array<{
934 userId: string;
935 hourUtc: number;
936 lastDailySentAt: Date | null;
937 lastWeeklySentAt: Date | null;
938 }>
939 >;
940 /** Inject the delivery side-effect (tests). */
941 deliver?: (
942 userId: string,
943 scope: StandupScope,
944 now: Date
945 ) => Promise<DeliverStandupResult>;
946 /** Inject the wall clock (tests). */
947 now?: () => Date;
948}
949
950async function defaultFindDailyCandidates(): Promise<
951 Array<{
952 userId: string;
953 hourUtc: number;
954 lastDailySentAt: Date | null;
955 lastWeeklySentAt: Date | null;
956 }>
957> {
958 try {
959 const rows = await db
960 .select({
961 userId: userStandupPrefs.userId,
962 hourUtc: userStandupPrefs.hourUtc,
963 lastDailySentAt: userStandupPrefs.lastDailySentAt,
964 lastWeeklySentAt: userStandupPrefs.lastWeeklySentAt,
965 })
966 .from(userStandupPrefs)
967 .where(eq(userStandupPrefs.dailyEnabled, true))
968 .limit(500);
969 return rows;
970 } catch (err) {
971 console.error("[ai-standup] defaultFindDailyCandidates failed:", err);
972 return [];
973 }
974}
975
976async function defaultFindWeeklyCandidates(): Promise<
977 Array<{
978 userId: string;
979 hourUtc: number;
980 lastDailySentAt: Date | null;
981 lastWeeklySentAt: Date | null;
982 }>
983> {
984 try {
985 const rows = await db
986 .select({
987 userId: userStandupPrefs.userId,
988 hourUtc: userStandupPrefs.hourUtc,
989 lastDailySentAt: userStandupPrefs.lastDailySentAt,
990 lastWeeklySentAt: userStandupPrefs.lastWeeklySentAt,
991 })
992 .from(userStandupPrefs)
993 .where(eq(userStandupPrefs.weeklyEnabled, true))
994 .limit(500);
995 return rows;
996 } catch (err) {
997 console.error("[ai-standup] defaultFindWeeklyCandidates failed:", err);
998 return [];
999 }
1000}
1001
1002/**
1003 * One iteration of the daily-standup autopilot task. Fires for users whose
1004 * `hour_utc` matches the current UTC hour. Skipped entirely when the AI key
1005 * is missing — gracefully degrades to a deterministic body, but only if a
1006 * user explicitly requested standups.
1007 */
1008export async function runDailyStandupTaskOnce(
1009 deps: StandupTickDeps = {}
1010): Promise<StandupTickSummary> {
1011 const now = deps.now ? deps.now() : new Date();
1012 const findCandidates = deps.findCandidates ?? defaultFindDailyCandidates;
1013 const deliver =
1014 deps.deliver ??
1015 (async (userId, scope, _now) =>
1016 deliverStandup({ userId, scope, now: _now }));
1017
1018 let sent = 0;
1019 let skipped = 0;
1020 let errors = 0;
1021 const currentHour = now.getUTCHours();
1022
1023 let candidates: Awaited<ReturnType<typeof findCandidates>> = [];
1024 try {
1025 candidates = await findCandidates();
1026 } catch {
1027 return { scope: "daily", sent, skipped, errors: 1 };
1028 }
1029
1030 for (const cand of candidates) {
1031 if (cand.hourUtc !== currentHour) {
1032 skipped += 1;
1033 continue;
1034 }
1035 try {
1036 const res = await deliver(cand.userId, "daily", now);
1037 if (res.ok) sent += 1;
1038 else skipped += 1;
1039 } catch {
1040 errors += 1;
1041 }
1042 }
1043
1044 return { scope: "daily", sent, skipped, errors };
1045}
1046
1047/**
1048 * One iteration of the weekly-standup autopilot task. Fires Mondays only,
1049 * at the user's configured hour.
1050 */
1051export async function runWeeklyStandupTaskOnce(
1052 deps: StandupTickDeps = {}
1053): Promise<StandupTickSummary> {
1054 const now = deps.now ? deps.now() : new Date();
1055 const findCandidates = deps.findCandidates ?? defaultFindWeeklyCandidates;
1056 const deliver =
1057 deps.deliver ??
1058 (async (userId, scope, _now) =>
1059 deliverStandup({ userId, scope, now: _now }));
1060
1061 // getUTCDay: 0=Sun, 1=Mon, ...
1062 const isMonday = now.getUTCDay() === 1;
1063 const currentHour = now.getUTCHours();
1064
1065 let sent = 0;
1066 let skipped = 0;
1067 let errors = 0;
1068
1069 if (!isMonday) {
1070 return { scope: "weekly", sent, skipped, errors };
1071 }
1072
1073 let candidates: Awaited<ReturnType<typeof findCandidates>> = [];
1074 try {
1075 candidates = await findCandidates();
1076 } catch {
1077 return { scope: "weekly", sent, skipped, errors: 1 };
1078 }
1079
1080 for (const cand of candidates) {
1081 if (cand.hourUtc !== currentHour) {
1082 skipped += 1;
1083 continue;
1084 }
1085 try {
1086 const res = await deliver(cand.userId, "weekly", now);
1087 if (res.ok) sent += 1;
1088 else skipped += 1;
1089 } catch {
1090 errors += 1;
1091 }
1092 }
1093
1094 return { scope: "weekly", sent, skipped, errors };
1095}
1096
1097// Suppress unused-import warning when `sql` is dropped by tree-shaking;
1098// keep the import for forward-compatible queries below.
1099void sql;
1100
1101/** Test-only surface. */
1102export const __test = {
1103 DAILY_WINDOW_HOURS,
1104 WEEKLY_WINDOW_HOURS,
1105 STALE_HOURS,
1106 classifyMaterial,
1107 renderFallbackSummary,
1108 buildPrompt,
1109 utcDayKey,
1110};