CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
sleep-mode.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.
| 46d6165 | 1 | /** |
| 2 | * Block L1 — Sleep Mode. | |
| 3 | * | |
| 4 | * Pitch: "Toggle Sleep Mode. Walk away. Wake up to a digest of what Claude | |
| 5 | * shipped overnight." | |
| 6 | * | |
| 7 | * Sleep Mode is a per-user toggle (`users.sleep_mode_enabled`) that, when on: | |
| 8 | * 1. Bumps the email digest cadence from weekly → daily. | |
| 9 | * 2. Reframes the digest as "what AI did in the last 24h" — PRs auto-merged | |
| 10 | * by the K3 autopilot, issues built from `ai:build` labels, AI reviews | |
| 11 | * posted, AI security scans, and gate failures that auto-repair fixed. | |
| 12 | * 3. Fires at the user-configured UTC hour (`sleep_mode_digest_hour_utc`, | |
| 13 | * default 9). | |
| 14 | * | |
| 15 | * Re-uses `sendEmail` from `src/lib/email.ts` and the locked `escapeHtml` | |
| 16 | * helper from `src/lib/email-digest.ts`. Does NOT modify either module — | |
| 17 | * Block L1 is strictly additive. | |
| 18 | * | |
| 19 | * Contract: every exported function NEVER throws. Failures are logged and | |
| 20 | * surface as either zero-valued reports or `{ok:false, reason}` results. | |
| 21 | */ | |
| 22 | ||
| 23 | import { and, eq, gte, inArray, sql } from "drizzle-orm"; | |
| 24 | import { db } from "../db"; | |
| 25 | import { | |
| 26 | auditLog, | |
| 27 | gateRuns, | |
| 28 | issueComments, | |
| 29 | issues, | |
| 30 | prComments, | |
| 31 | pullRequests, | |
| 32 | repositories, | |
| 33 | users, | |
| 34 | } from "../db/schema"; | |
| 35 | import { sendEmail, type EmailResult } from "./email"; | |
| 36 | import { config } from "./config"; | |
| 37 | import { __internal as digestInternals } from "./email-digest"; | |
| 38 | import { AI_BUILD_MARKER } from "./ai-build-tasks"; | |
| 39 | ||
| 40 | const { escapeHtml } = digestInternals; | |
| 41 | ||
| 42 | /** | |
| 43 | * Heuristic constants for the `hoursSaved` metric. These are deliberately | |
| 44 | * conservative — better to under-promise than to publish absurd "Claude | |
| 45 | * saved you 40 hours this week" claims. | |
| 46 | * | |
| 47 | * PR_AUTO_MERGE_HOURS = 0.30 — minutes-of-merge-button-pressing | |
| 48 | * AI_BUILT_ISSUE_HOURS = 1.50 — design + plumbing for a small feature | |
| 49 | * AI_REVIEW_HOURS = 0.25 — one careful PR review | |
| 50 | * AUTO_FIX_HOURS = 0.50 — secret rotation / gate repair | |
| 51 | * | |
| 52 | * Documented in the L9 ticket for the eventual "savings model v2". | |
| 53 | */ | |
| 54 | const PR_AUTO_MERGE_HOURS = 0.3; | |
| 55 | const AI_BUILT_ISSUE_HOURS = 1.5; | |
| 56 | const AI_REVIEW_HOURS = 0.25; | |
| 57 | const AUTO_FIX_HOURS = 0.5; | |
| 58 | ||
| 59 | /** Default look-back window. Sleep Mode is a daily digest. */ | |
| 60 | const DEFAULT_WINDOW_HOURS = 24; | |
| 61 | /** Per-tick cap on users we'll email — runaway protection. */ | |
| 62 | export const SLEEP_MODE_USER_CAP_PER_TICK = 100; | |
| 63 | /** Cooldown anchored to `users.last_digest_sent_at`. */ | |
| 64 | export const SLEEP_MODE_COOLDOWN_HOURS = 23; | |
| 65 | ||
| 66 | export type SleepModeReport = { | |
| 67 | windowHours: number; | |
| 68 | prsAutoMerged: { number: number; title: string; repo: string }[]; | |
| 69 | issuesBuiltByAi: { number: number; title: string; repo: string; prNumber?: number }[]; | |
| 70 | aiReviewsPosted: number; | |
| 71 | securityIssuesAutoFixed: number; | |
| 72 | gateFailuresAutoRepaired: number; | |
| 73 | /** Derived. Rounded to one decimal. See heuristic constants above. */ | |
| 74 | hoursSaved: number; | |
| 75 | }; | |
| 76 | ||
| 77 | /** | |
| 78 | * Pure helper — exported for tests. Given the four count inputs, returns | |
| 79 | * the hoursSaved value rounded to one decimal. | |
| 80 | */ | |
| 81 | export function computeHoursSaved(input: { | |
| 82 | prsAutoMerged: number; | |
| 83 | issuesBuiltByAi: number; | |
| 84 | aiReviewsPosted: number; | |
| 85 | securityIssuesAutoFixed: number; | |
| 86 | gateFailuresAutoRepaired: number; | |
| 87 | }): number { | |
| 88 | const raw = | |
| 89 | input.prsAutoMerged * PR_AUTO_MERGE_HOURS + | |
| 90 | input.issuesBuiltByAi * AI_BUILT_ISSUE_HOURS + | |
| 91 | input.aiReviewsPosted * AI_REVIEW_HOURS + | |
| 92 | (input.securityIssuesAutoFixed + input.gateFailuresAutoRepaired) * | |
| 93 | AUTO_FIX_HOURS; | |
| 94 | return Math.round(raw * 10) / 10; | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Compose a Sleep Mode report for one user. Pulls from: | |
| 99 | * - audit_log `auto_merge.merged` → prsAutoMerged | |
| 100 | * - issue_comments matching `AI_BUILD_MARKER` on this user's repos → issuesBuiltByAi | |
| 101 | * - pr_comments where is_ai_review=true on this user's repos → aiReviewsPosted | |
| 102 | * - gate_runs status='repaired' AND gate_name LIKE '%Secret%' → securityIssuesAutoFixed | |
| 103 | * - gate_runs status='repaired' (others) → gateFailuresAutoRepaired | |
| 104 | * | |
| 105 | * Never throws. On DB error, returns a zero-valued report. | |
| 106 | */ | |
| 107 | export async function composeSleepModeReport( | |
| 108 | userId: string, | |
| 109 | opts?: { sinceHoursAgo?: number; now?: Date } | |
| 110 | ): Promise<SleepModeReport> { | |
| 111 | const windowHours = opts?.sinceHoursAgo ?? DEFAULT_WINDOW_HOURS; | |
| 112 | const now = opts?.now ?? new Date(); | |
| 113 | const since = new Date(now.getTime() - windowHours * 60 * 60 * 1000); | |
| 114 | ||
| 115 | const empty: SleepModeReport = { | |
| 116 | windowHours, | |
| 117 | prsAutoMerged: [], | |
| 118 | issuesBuiltByAi: [], | |
| 119 | aiReviewsPosted: 0, | |
| 120 | securityIssuesAutoFixed: 0, | |
| 121 | gateFailuresAutoRepaired: 0, | |
| 122 | hoursSaved: 0, | |
| 123 | }; | |
| 124 | ||
| 125 | try { | |
| 126 | // Resolve the user's repos (owner only — orgs aren't user-toggled). | |
| 127 | const ownedRepos = await db | |
| 128 | .select({ id: repositories.id, name: repositories.name }) | |
| 129 | .from(repositories) | |
| 130 | .where(eq(repositories.ownerId, userId)); | |
| 131 | if (ownedRepos.length === 0) return empty; | |
| 132 | ||
| 133 | const repoIds = ownedRepos.map((r) => r.id); | |
| 134 | const repoNameById = new Map(ownedRepos.map((r) => [r.id, r.name])); | |
| 135 | ||
| 136 | // ----------------------------------------------------------------- | |
| 137 | // PRs auto-merged: audit_log `auto_merge.merged` in window. | |
| 138 | // Join PR for title+number. | |
| 139 | // ----------------------------------------------------------------- | |
| 140 | let prsAutoMerged: SleepModeReport["prsAutoMerged"] = []; | |
| 141 | try { | |
| 142 | const rows = await db | |
| 143 | .select({ | |
| 144 | targetId: auditLog.targetId, | |
| 145 | repositoryId: auditLog.repositoryId, | |
| 146 | }) | |
| 147 | .from(auditLog) | |
| 148 | .where( | |
| 149 | and( | |
| 150 | eq(auditLog.action, "auto_merge.merged"), | |
| 151 | inArray(auditLog.repositoryId, repoIds), | |
| 152 | gte(auditLog.createdAt, since) | |
| 153 | ) | |
| 154 | ) | |
| 155 | .limit(50); | |
| 156 | const prIds = rows | |
| 157 | .map((r) => r.targetId) | |
| 158 | .filter((v): v is string => typeof v === "string" && v.length > 0); | |
| 159 | if (prIds.length > 0) { | |
| 160 | const prRows = await db | |
| 161 | .select({ | |
| 162 | id: pullRequests.id, | |
| 163 | number: pullRequests.number, | |
| 164 | title: pullRequests.title, | |
| 165 | repositoryId: pullRequests.repositoryId, | |
| 166 | }) | |
| 167 | .from(pullRequests) | |
| 168 | .where(inArray(pullRequests.id, prIds)); | |
| 169 | prsAutoMerged = prRows.map((p) => ({ | |
| 170 | number: p.number, | |
| 171 | title: p.title, | |
| 172 | repo: repoNameById.get(p.repositoryId) || "?", | |
| 173 | })); | |
| 174 | } | |
| 175 | } catch (err) { | |
| 176 | console.error("[sleep-mode] prsAutoMerged query failed:", err); | |
| 177 | } | |
| 178 | ||
| 179 | // ----------------------------------------------------------------- | |
| 180 | // Issues built by AI: issue_comments whose body includes the | |
| 181 | // K3 AI-build marker, within the window, on repos this user owns. | |
| 182 | // ----------------------------------------------------------------- | |
| 183 | let issuesBuiltByAi: SleepModeReport["issuesBuiltByAi"] = []; | |
| 184 | try { | |
| 185 | const rows = await db | |
| 186 | .select({ | |
| 187 | issueId: issueComments.issueId, | |
| 188 | createdAt: issueComments.createdAt, | |
| 189 | }) | |
| 190 | .from(issueComments) | |
| 191 | .innerJoin(issues, eq(issues.id, issueComments.issueId)) | |
| 192 | .where( | |
| 193 | and( | |
| 194 | inArray(issues.repositoryId, repoIds), | |
| 195 | gte(issueComments.createdAt, since), | |
| 196 | sql`${issueComments.body} LIKE ${"%" + AI_BUILD_MARKER + "%"}` | |
| 197 | ) | |
| 198 | ) | |
| 199 | .limit(50); | |
| 200 | const issueIds = Array.from(new Set(rows.map((r) => r.issueId))); | |
| 201 | if (issueIds.length > 0) { | |
| 202 | const issueRows = await db | |
| 203 | .select({ | |
| 204 | id: issues.id, | |
| 205 | number: issues.number, | |
| 206 | title: issues.title, | |
| 207 | repositoryId: issues.repositoryId, | |
| 208 | }) | |
| 209 | .from(issues) | |
| 210 | .where(inArray(issues.id, issueIds)); | |
| 211 | issuesBuiltByAi = issueRows.map((i) => ({ | |
| 212 | number: i.number, | |
| 213 | title: i.title, | |
| 214 | repo: repoNameById.get(i.repositoryId) || "?", | |
| 215 | })); | |
| 216 | } | |
| 217 | } catch (err) { | |
| 218 | console.error("[sleep-mode] issuesBuiltByAi query failed:", err); | |
| 219 | } | |
| 220 | ||
| 221 | // ----------------------------------------------------------------- | |
| 222 | // AI reviews posted on this user's repos in the window. | |
| 223 | // pr_comments.is_ai_review=true, joined to pullRequests for repo | |
| 224 | // filter. We count, we don't list — the digest just reports the | |
| 225 | // total. (Per-PR list would balloon the email.) | |
| 226 | // ----------------------------------------------------------------- | |
| 227 | let aiReviewsPosted = 0; | |
| 228 | try { | |
| 229 | const rows = await db | |
| 230 | .select({ c: sql<number>`count(*)::int` }) | |
| 231 | .from(prComments) | |
| 232 | .innerJoin( | |
| 233 | pullRequests, | |
| 234 | eq(pullRequests.id, prComments.pullRequestId) | |
| 235 | ) | |
| 236 | .where( | |
| 237 | and( | |
| 238 | eq(prComments.isAiReview, true), | |
| 239 | inArray(pullRequests.repositoryId, repoIds), | |
| 240 | gte(prComments.createdAt, since) | |
| 241 | ) | |
| 242 | ); | |
| 243 | aiReviewsPosted = Number(rows[0]?.c ?? 0); | |
| 244 | } catch (err) { | |
| 245 | console.error("[sleep-mode] aiReviewsPosted query failed:", err); | |
| 246 | } | |
| 247 | ||
| 248 | // ----------------------------------------------------------------- | |
| 249 | // Gate auto-repairs in window. Split into "security" (secret-scan | |
| 250 | // gates) and "everything else" (test repair, etc). | |
| 251 | // ----------------------------------------------------------------- | |
| 252 | let securityIssuesAutoFixed = 0; | |
| 253 | let gateFailuresAutoRepaired = 0; | |
| 254 | try { | |
| 255 | const rows = await db | |
| 256 | .select({ | |
| 257 | gateName: gateRuns.gateName, | |
| 258 | }) | |
| 259 | .from(gateRuns) | |
| 260 | .where( | |
| 261 | and( | |
| 262 | inArray(gateRuns.repositoryId, repoIds), | |
| 263 | eq(gateRuns.status, "repaired"), | |
| 264 | gte(gateRuns.createdAt, since) | |
| 265 | ) | |
| 266 | ) | |
| 267 | .limit(500); | |
| 268 | for (const r of rows) { | |
| 269 | const lower = (r.gateName || "").toLowerCase(); | |
| 270 | if (lower.includes("secret") || lower.includes("security")) { | |
| 271 | securityIssuesAutoFixed += 1; | |
| 272 | } else { | |
| 273 | gateFailuresAutoRepaired += 1; | |
| 274 | } | |
| 275 | } | |
| 276 | } catch (err) { | |
| 277 | console.error("[sleep-mode] gate-runs query failed:", err); | |
| 278 | } | |
| 279 | ||
| 280 | const hoursSaved = computeHoursSaved({ | |
| 281 | prsAutoMerged: prsAutoMerged.length, | |
| 282 | issuesBuiltByAi: issuesBuiltByAi.length, | |
| 283 | aiReviewsPosted, | |
| 284 | securityIssuesAutoFixed, | |
| 285 | gateFailuresAutoRepaired, | |
| 286 | }); | |
| 287 | ||
| 288 | return { | |
| 289 | windowHours, | |
| 290 | prsAutoMerged, | |
| 291 | issuesBuiltByAi, | |
| 292 | aiReviewsPosted, | |
| 293 | securityIssuesAutoFixed, | |
| 294 | gateFailuresAutoRepaired, | |
| 295 | hoursSaved, | |
| 296 | }; | |
| 297 | } catch (err) { | |
| 298 | console.error("[sleep-mode] composeSleepModeReport error:", err); | |
| 299 | return empty; | |
| 300 | } | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Render a Sleep Mode digest. Returns `{subject, text, html}` ready to hand | |
| 305 | * off to `sendEmail`. Pure — no DB, no env, no I/O. | |
| 306 | * | |
| 307 | * HTML escaping is handled here for every user-controlled value so a | |
| 308 | * crafted PR/issue title cannot break out and inject script. The plaintext | |
| 309 | * branch is plaintext by definition (consumers display as-is). | |
| 310 | */ | |
| 311 | export function renderSleepModeDigest( | |
| 312 | report: SleepModeReport, | |
| 313 | opts: { username: string } | |
| 314 | ): { subject: string; text: string; html: string } { | |
| 315 | const username = opts.username; | |
| 316 | const base = config.appBaseUrl || "https://gluecron.com"; | |
| 317 | const total = | |
| 318 | report.prsAutoMerged.length + | |
| 319 | report.issuesBuiltByAi.length + | |
| 320 | report.aiReviewsPosted + | |
| 321 | report.securityIssuesAutoFixed + | |
| 322 | report.gateFailuresAutoRepaired; | |
| 323 | ||
| 324 | const subject = | |
| 325 | total === 0 | |
| 326 | ? `Sleep Mode: a quiet night on Gluecron` | |
| 327 | : `Sleep Mode: while you slept, Claude shipped ${total} thing${total === 1 ? "" : "s"}`; | |
| 328 | ||
| 329 | // ---- Plaintext ---- | |
| 330 | const textLines: string[] = []; | |
| 331 | textLines.push(`Hi ${username},`); | |
| 332 | textLines.push(""); | |
| 333 | if (total === 0) { | |
| 334 | textLines.push( | |
| 335 | `Nothing fired in the last ${report.windowHours} hours. Quiet night.` | |
| 336 | ); | |
| 337 | } else { | |
| 338 | textLines.push( | |
| 339 | `While you slept, Claude opened/merged ${report.prsAutoMerged.length} PR${report.prsAutoMerged.length === 1 ? "" : "s"}, built ${report.issuesBuiltByAi.length} issue${report.issuesBuiltByAi.length === 1 ? "" : "s"}, posted ${report.aiReviewsPosted} AI review${report.aiReviewsPosted === 1 ? "" : "s"}, fixed ${report.securityIssuesAutoFixed} security issue${report.securityIssuesAutoFixed === 1 ? "" : "s"}, and auto-repaired ${report.gateFailuresAutoRepaired} gate failure${report.gateFailuresAutoRepaired === 1 ? "" : "s"}.` | |
| 340 | ); | |
| 341 | textLines.push(""); | |
| 342 | textLines.push(`Estimated time saved: ${report.hoursSaved} hours.`); | |
| 343 | } | |
| 344 | textLines.push(""); | |
| 345 | ||
| 346 | if (report.prsAutoMerged.length > 0) { | |
| 347 | textLines.push("## PRs auto-merged"); | |
| 348 | for (const pr of report.prsAutoMerged.slice(0, 10)) { | |
| 349 | textLines.push(`- ${pr.repo} #${pr.number} ${pr.title}`); | |
| 350 | } | |
| 351 | textLines.push(""); | |
| 352 | } | |
| 353 | if (report.issuesBuiltByAi.length > 0) { | |
| 354 | textLines.push("## Issues built by AI"); | |
| 355 | for (const it of report.issuesBuiltByAi.slice(0, 10)) { | |
| 356 | const tail = it.prNumber ? ` -> PR #${it.prNumber}` : ""; | |
| 357 | textLines.push(`- ${it.repo} #${it.number} ${it.title}${tail}`); | |
| 358 | } | |
| 359 | textLines.push(""); | |
| 360 | } | |
| 361 | if ( | |
| 362 | report.aiReviewsPosted + | |
| 363 | report.securityIssuesAutoFixed + | |
| 364 | report.gateFailuresAutoRepaired > | |
| 365 | 0 | |
| 366 | ) { | |
| 367 | textLines.push("## Automated guardrails"); | |
| 368 | textLines.push(`- AI reviews posted: ${report.aiReviewsPosted}`); | |
| 369 | textLines.push( | |
| 370 | `- Security issues auto-fixed: ${report.securityIssuesAutoFixed}` | |
| 371 | ); | |
| 372 | textLines.push( | |
| 373 | `- Gate failures auto-repaired: ${report.gateFailuresAutoRepaired}` | |
| 374 | ); | |
| 375 | textLines.push(""); | |
| 376 | } | |
| 377 | ||
| 378 | textLines.push("---"); | |
| 379 | textLines.push( | |
| 380 | `Sleep Mode delivers this daily. Toggle off at ${base}/settings.` | |
| 381 | ); | |
| 382 | const text = textLines.join("\n"); | |
| 383 | ||
| 384 | // ---- HTML ---- | |
| 385 | // We do NOT route through email-digest's textToHtml because the Sleep | |
| 386 | // Mode digest has a custom hero block + bespoke styling. We DO use the | |
| 387 | // shared `escapeHtml` for every user-supplied string. | |
| 388 | const html = renderHtml(report, { username, base, total }); | |
| 389 | ||
| 390 | return { subject, text, html }; | |
| 391 | } | |
| 392 | ||
| 393 | function renderHtml( | |
| 394 | report: SleepModeReport, | |
| 395 | ctx: { username: string; base: string; total: number } | |
| 396 | ): string { | |
| 397 | const u = escapeHtml(ctx.username); | |
| 398 | const heroLine = | |
| 399 | ctx.total === 0 | |
| 400 | ? `Nothing fired in the last ${report.windowHours} hours. Quiet night.` | |
| 401 | : `While you slept, Claude opened/merged <strong>${report.prsAutoMerged.length}</strong> PRs, built <strong>${report.issuesBuiltByAi.length}</strong> issues, posted <strong>${report.aiReviewsPosted}</strong> AI reviews, fixed <strong>${report.securityIssuesAutoFixed}</strong> security issues, and auto-repaired <strong>${report.gateFailuresAutoRepaired}</strong> gate failures.`; | |
| 402 | ||
| 403 | const parts: string[] = []; | |
| 404 | parts.push( | |
| 405 | `<html><body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;max-width:640px;margin:0 auto;padding:24px;color:#0f1019;background:#fff">` | |
| 406 | ); | |
| 407 | parts.push( | |
| 408 | `<div style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;padding:24px;border-radius:12px;margin-bottom:24px">` + | |
| 409 | `<div style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase;opacity:0.85">Sleep Mode</div>` + | |
| 410 | `<h1 style="margin:8px 0 0;font-size:22px;font-weight:600">Good morning, ${u}.</h1>` + | |
| 411 | `<p style="margin:12px 0 0;font-size:14px;line-height:1.5;opacity:0.95">${heroLine}</p>` + | |
| 412 | (ctx.total > 0 | |
| 413 | ? `<p style="margin:12px 0 0;font-size:14px;font-weight:600">Estimated time saved: ${report.hoursSaved} hours</p>` | |
| 414 | : "") + | |
| 415 | `</div>` | |
| 416 | ); | |
| 417 | ||
| 418 | if (report.prsAutoMerged.length > 0) { | |
| 419 | parts.push( | |
| 420 | `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px;font-size:14px">PRs auto-merged</h3>` | |
| 421 | ); | |
| 422 | parts.push(`<ul style="padding-left:18px;margin:8px 0">`); | |
| 423 | for (const pr of report.prsAutoMerged.slice(0, 10)) { | |
| 424 | parts.push( | |
| 425 | `<li><strong>${escapeHtml(pr.repo)}</strong> #${pr.number} ${escapeHtml(pr.title)}</li>` | |
| 426 | ); | |
| 427 | } | |
| 428 | parts.push(`</ul>`); | |
| 429 | } | |
| 430 | if (report.issuesBuiltByAi.length > 0) { | |
| 431 | parts.push( | |
| 432 | `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px;font-size:14px">Issues built by AI</h3>` | |
| 433 | ); | |
| 434 | parts.push(`<ul style="padding-left:18px;margin:8px 0">`); | |
| 435 | for (const it of report.issuesBuiltByAi.slice(0, 10)) { | |
| 436 | const tail = it.prNumber ? ` → PR #${it.prNumber}` : ""; | |
| 437 | parts.push( | |
| 438 | `<li><strong>${escapeHtml(it.repo)}</strong> #${it.number} ${escapeHtml(it.title)}${tail}</li>` | |
| 439 | ); | |
| 440 | } | |
| 441 | parts.push(`</ul>`); | |
| 442 | } | |
| 443 | if ( | |
| 444 | report.aiReviewsPosted + | |
| 445 | report.securityIssuesAutoFixed + | |
| 446 | report.gateFailuresAutoRepaired > | |
| 447 | 0 | |
| 448 | ) { | |
| 449 | parts.push( | |
| 450 | `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px;font-size:14px">Automated guardrails</h3>` | |
| 451 | ); | |
| 452 | parts.push(`<ul style="padding-left:18px;margin:8px 0">`); | |
| 453 | parts.push(`<li>AI reviews posted: ${report.aiReviewsPosted}</li>`); | |
| 454 | parts.push( | |
| 455 | `<li>Security issues auto-fixed: ${report.securityIssuesAutoFixed}</li>` | |
| 456 | ); | |
| 457 | parts.push( | |
| 458 | `<li>Gate failures auto-repaired: ${report.gateFailuresAutoRepaired}</li>` | |
| 459 | ); | |
| 460 | parts.push(`</ul>`); | |
| 461 | } | |
| 462 | ||
| 463 | parts.push( | |
| 464 | `<hr style="border:none;border-top:1px solid #eee;margin:24px 0" />` | |
| 465 | ); | |
| 466 | parts.push( | |
| 467 | `<p style="font-size:12px;color:#777">Sleep Mode delivers this daily. Toggle off at <a href="${escapeHtml(ctx.base)}/settings">${escapeHtml(ctx.base)}/settings</a>.</p>` | |
| 468 | ); | |
| 469 | parts.push(`</body></html>`); | |
| 470 | return parts.join("\n"); | |
| 471 | } | |
| 472 | ||
| 473 | /** | |
| 474 | * Compose + send a Sleep Mode digest for one user. Stamps | |
| 475 | * `users.last_digest_sent_at` on success so the autopilot cooldown holds | |
| 476 | * for 23h. Never throws. | |
| 477 | * | |
| 478 | * Skips (returns `{ok:false, reason}`) when: | |
| 479 | * - user not found | |
| 480 | * - user.sleepModeEnabled is false | |
| 481 | * - composer returns the zero report AND owner has no repos (graceful) | |
| 482 | * -> we DO still send if they have repos but a quiet window. | |
| 483 | */ | |
| 484 | export async function sendSleepModeDigestForUser( | |
| 485 | userId: string | |
| 486 | ): Promise<{ ok: boolean; reason?: string }> { | |
| 487 | try { | |
| 488 | const [user] = await db | |
| 489 | .select() | |
| 490 | .from(users) | |
| 491 | .where(eq(users.id, userId)) | |
| 492 | .limit(1); | |
| 493 | if (!user) return { ok: false, reason: "user not found" }; | |
| 494 | if (!user.sleepModeEnabled) return { ok: false, reason: "sleep mode off" }; | |
| 495 | ||
| 496 | const report = await composeSleepModeReport(userId); | |
| 497 | const { subject, text, html } = renderSleepModeDigest(report, { | |
| 498 | username: user.username, | |
| 499 | }); | |
| 500 | ||
| 501 | const result: EmailResult = await sendEmail({ | |
| 502 | to: user.email, | |
| 503 | subject, | |
| 504 | text, | |
| 505 | html, | |
| 506 | }); | |
| 507 | if (result.ok) { | |
| 508 | try { | |
| 509 | await db | |
| 510 | .update(users) | |
| 511 | .set({ lastDigestSentAt: new Date() }) | |
| 512 | .where(eq(users.id, userId)); | |
| 513 | } catch (err) { | |
| 514 | console.error("[sleep-mode] lastDigestSentAt update failed:", err); | |
| 515 | } | |
| 516 | return { ok: true }; | |
| 517 | } | |
| 518 | return { | |
| 519 | ok: false, | |
| 520 | reason: result.skipped || result.error || "send failed", | |
| 521 | }; | |
| 522 | } catch (err) { | |
| 523 | console.error("[sleep-mode] sendSleepModeDigestForUser error:", err); | |
| 524 | return { ok: false, reason: "error" }; | |
| 525 | } | |
| 526 | } | |
| 527 | ||
| 528 | /** Test-only surface. */ | |
| 529 | export const __test = { | |
| 530 | PR_AUTO_MERGE_HOURS, | |
| 531 | AI_BUILT_ISSUE_HOURS, | |
| 532 | AI_REVIEW_HOURS, | |
| 533 | AUTO_FIX_HOURS, | |
| 534 | DEFAULT_WINDOW_HOURS, | |
| 535 | renderHtml, | |
| 536 | }; |