CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
email-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.
| 08420cd | 1 | /** |
| 2 | * Block I7 — Weekly email digest. | |
| 3 | * | |
| 4 | * Composes a per-user digest of activity over the last 7 days (or a custom | |
| 5 | * window) and sends it via the shared email module. Run from a cron or | |
| 6 | * manually via `POST /admin/digests/run`. | |
| 7 | * | |
| 8 | * Data sources: | |
| 9 | * - notifications (unread + read-last-7d) | |
| 10 | * - gate_runs (failed / repaired) | |
| 11 | * - pull_requests (merged by the user's repos) | |
| 12 | * | |
| 13 | * Never throws — the caller can fire-and-forget. | |
| 14 | */ | |
| 15 | ||
| 16 | import { and, desc, eq, gte, inArray, sql } from "drizzle-orm"; | |
| 17 | import { db } from "./../db"; | |
| 18 | import { | |
| 19 | gateRuns, | |
| 20 | notifications, | |
| 21 | pullRequests, | |
| 22 | repositories, | |
| 23 | users, | |
| 24 | } from "./../db/schema"; | |
| 25 | import { sendEmail, type EmailResult } from "./email"; | |
| 26 | import { config } from "./config"; | |
| 27 | ||
| 28 | export interface DigestInput { | |
| 29 | userId: string; | |
| 30 | since?: Date; | |
| 31 | /** When false, skip `sendEmail` and just compose. Used for preview. */ | |
| 32 | send?: boolean; | |
| 33 | } | |
| 34 | ||
| 35 | export interface DigestBody { | |
| 36 | subject: string; | |
| 37 | text: string; | |
| 38 | html: string; | |
| 39 | counts: { | |
| 40 | notifications: number; | |
| 41 | failedGates: number; | |
| 42 | repairedGates: number; | |
| 43 | mergedPrs: number; | |
| 44 | }; | |
| 45 | } | |
| 46 | ||
| 47 | function fmtRange(from: Date, to: Date): string { | |
| 48 | const f = from.toISOString().slice(0, 10); | |
| 49 | const t = to.toISOString().slice(0, 10); | |
| 50 | return f === t ? f : `${f} \u2192 ${t}`; | |
| 51 | } | |
| 52 | ||
| 53 | export async function composeDigest( | |
| 54 | userId: string, | |
| 55 | since?: Date | |
| 56 | ): Promise<DigestBody | null> { | |
| 57 | const now = new Date(); | |
| 58 | const from = since || new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); | |
| 59 | try { | |
| 60 | const [user] = await db | |
| 61 | .select() | |
| 62 | .from(users) | |
| 63 | .where(eq(users.id, userId)) | |
| 64 | .limit(1); | |
| 65 | if (!user) return null; | |
| 66 | ||
| 67 | // Pull notifications | |
| 68 | const notifs = await db | |
| 69 | .select() | |
| 70 | .from(notifications) | |
| 71 | .where( | |
| 72 | and( | |
| 73 | eq(notifications.userId, userId), | |
| 74 | gte(notifications.createdAt, from) | |
| 75 | ) | |
| 76 | ) | |
| 77 | .orderBy(desc(notifications.createdAt)) | |
| 78 | .limit(25); | |
| 79 | ||
| 80 | // User's repos (owner only — org-aware digest can come later) | |
| 81 | const ownedRepos = await db | |
| 82 | .select({ id: repositories.id, name: repositories.name }) | |
| 83 | .from(repositories) | |
| 84 | .where(eq(repositories.ownerId, userId)); | |
| 85 | const repoIds = ownedRepos.map((r) => r.id); | |
| 86 | ||
| 87 | let failedGates: Array<{ repoName: string; gateName: string; sha: string }> = []; | |
| 88 | let repairedGates: Array<{ repoName: string; gateName: string; sha: string }> = []; | |
| 89 | let mergedPrs: Array<{ repoName: string; title: string }> = []; | |
| 90 | ||
| 91 | if (repoIds.length > 0) { | |
| 92 | const gates = await db | |
| 93 | .select() | |
| 94 | .from(gateRuns) | |
| 95 | .where( | |
| 96 | and( | |
| 97 | inArray(gateRuns.repositoryId, repoIds), | |
| 98 | gte(gateRuns.createdAt, from) | |
| 99 | ) | |
| 100 | ) | |
| 101 | .orderBy(desc(gateRuns.createdAt)) | |
| 102 | .limit(50); | |
| 103 | const byId = new Map(ownedRepos.map((r) => [r.id, r.name])); | |
| 104 | for (const g of gates) { | |
| 105 | const repoName = byId.get(g.repositoryId) || "?"; | |
| 106 | if (g.status === "failed") { | |
| 107 | failedGates.push({ | |
| 108 | repoName, | |
| 109 | gateName: g.gateName, | |
| 110 | sha: g.commitSha.slice(0, 7), | |
| 111 | }); | |
| 112 | } else if (g.status === "repaired") { | |
| 113 | repairedGates.push({ | |
| 114 | repoName, | |
| 115 | gateName: g.gateName, | |
| 116 | sha: g.commitSha.slice(0, 7), | |
| 117 | }); | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | const merged = await db | |
| 122 | .select() | |
| 123 | .from(pullRequests) | |
| 124 | .where( | |
| 125 | and( | |
| 126 | inArray(pullRequests.repositoryId, repoIds), | |
| 127 | eq(pullRequests.state, "merged"), | |
| 128 | gte(pullRequests.updatedAt, from) | |
| 129 | ) | |
| 130 | ) | |
| 131 | .limit(25); | |
| 132 | for (const pr of merged) { | |
| 133 | mergedPrs.push({ | |
| 134 | repoName: byId.get(pr.repositoryId) || "?", | |
| 135 | title: pr.title, | |
| 136 | }); | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | const counts = { | |
| 141 | notifications: notifs.length, | |
| 142 | failedGates: failedGates.length, | |
| 143 | repairedGates: repairedGates.length, | |
| 144 | mergedPrs: mergedPrs.length, | |
| 145 | }; | |
| 146 | ||
| 147 | const base = config.appBaseUrl || "https://gluecron.com"; | |
| 148 | const subject = `Your Gluecron digest (${fmtRange(from, now)})`; | |
| 149 | const lines: string[] = []; | |
| 150 | lines.push(`Hi ${user.username},`); | |
| 151 | lines.push(""); | |
| 152 | lines.push(`Here's what happened across your repos this week.`); | |
| 153 | lines.push(""); | |
| 154 | lines.push( | |
| 155 | `Notifications: ${counts.notifications} · Failed gates: ${counts.failedGates} · Auto-repaired: ${counts.repairedGates} · PRs merged: ${counts.mergedPrs}` | |
| 156 | ); | |
| 157 | lines.push(""); | |
| 158 | ||
| 159 | if (notifs.length > 0) { | |
| 160 | lines.push("## Notifications"); | |
| 161 | for (const n of notifs.slice(0, 10)) { | |
| 162 | const when = new Date(n.createdAt).toLocaleDateString(); | |
| 163 | lines.push(`- [${n.kind}] ${n.title || "(untitled)"} — ${when}`); | |
| 164 | } | |
| 165 | lines.push(""); | |
| 166 | } | |
| 167 | ||
| 168 | if (failedGates.length > 0) { | |
| 169 | lines.push("## Failed gates"); | |
| 170 | for (const g of failedGates.slice(0, 10)) { | |
| 171 | lines.push(`- ${g.repoName} — ${g.gateName} (${g.sha})`); | |
| 172 | } | |
| 173 | lines.push(""); | |
| 174 | } | |
| 175 | ||
| 176 | if (repairedGates.length > 0) { | |
| 177 | lines.push("## Auto-repairs"); | |
| 178 | for (const g of repairedGates.slice(0, 10)) { | |
| 179 | lines.push(`- ${g.repoName} — ${g.gateName} (${g.sha})`); | |
| 180 | } | |
| 181 | lines.push(""); | |
| 182 | } | |
| 183 | ||
| 184 | if (mergedPrs.length > 0) { | |
| 185 | lines.push("## Merged PRs"); | |
| 186 | for (const pr of mergedPrs.slice(0, 10)) { | |
| 187 | lines.push(`- ${pr.repoName} — ${pr.title}`); | |
| 188 | } | |
| 189 | lines.push(""); | |
| 190 | } | |
| 191 | ||
| 192 | lines.push("---"); | |
| 193 | lines.push( | |
| 194 | `You're receiving this because you opted into weekly digests. Manage at ${base}/settings.` | |
| 195 | ); | |
| 196 | ||
| 197 | const text = lines.join("\n"); | |
| 198 | const html = textToHtml(text, base); | |
| 199 | return { subject, text, html, counts }; | |
| 200 | } catch (err) { | |
| 201 | console.error("[digest] composeDigest error:", err); | |
| 202 | return null; | |
| 203 | } | |
| 204 | } | |
| 205 | ||
| 206 | function textToHtml(text: string, base: string): string { | |
| 207 | const lines = text.split("\n"); | |
| 208 | const out: string[] = [ | |
| 209 | `<html><body style="font-family:system-ui,sans-serif;max-width:640px;margin:0 auto;padding:24px;color:#111">`, | |
| 210 | ]; | |
| 211 | for (const line of lines) { | |
| 212 | if (line.startsWith("## ")) { | |
| 213 | out.push( | |
| 214 | `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px">${escapeHtml(line.slice(3))}</h3>` | |
| 215 | ); | |
| 216 | } else if (line.startsWith("- ")) { | |
| 217 | out.push(`<li>${escapeHtml(line.slice(2))}</li>`); | |
| 218 | } else if (line === "---") { | |
| 219 | out.push(`<hr style="border:none;border-top:1px solid #eee;margin:24px 0" />`); | |
| 220 | } else if (line.trim() === "") { | |
| 221 | out.push("<br>"); | |
| 222 | } else { | |
| 223 | out.push(`<p>${escapeHtml(line)}</p>`); | |
| 224 | } | |
| 225 | } | |
| 226 | out.push( | |
| 227 | `<p style="font-size:12px;color:#777"><a href="${escapeHtml(base)}">${escapeHtml(base)}</a></p>` | |
| 228 | ); | |
| 229 | out.push("</body></html>"); | |
| 230 | return out.join("\n"); | |
| 231 | } | |
| 232 | ||
| 233 | function escapeHtml(s: string): string { | |
| 234 | return s | |
| 235 | .replace(/&/g, "&") | |
| 236 | .replace(/</g, "<") | |
| 237 | .replace(/>/g, ">") | |
| 238 | .replace(/"/g, """); | |
| 239 | } | |
| 240 | ||
| 241 | /** Compose + send for a single user. Records `last_digest_sent_at` on success. */ | |
| 242 | export async function sendDigestForUser( | |
| 243 | userId: string | |
| 244 | ): Promise<EmailResult | { ok: false; provider: "none"; skipped: string }> { | |
| 245 | try { | |
| 246 | const [user] = await db | |
| 247 | .select() | |
| 248 | .from(users) | |
| 249 | .where(eq(users.id, userId)) | |
| 250 | .limit(1); | |
| 251 | if (!user) return { ok: false, provider: "none", skipped: "user not found" }; | |
| 252 | if (!user.notifyEmailDigestWeekly) { | |
| 253 | return { ok: false, provider: "none", skipped: "opted out" }; | |
| 254 | } | |
| 255 | const body = await composeDigest(userId); | |
| 256 | if (!body) { | |
| 257 | return { ok: false, provider: "none", skipped: "compose failed" }; | |
| 258 | } | |
| 259 | const result = await sendEmail({ | |
| 260 | to: user.email, | |
| 261 | subject: body.subject, | |
| 262 | text: body.text, | |
| 263 | html: body.html, | |
| 264 | }); | |
| 265 | if (result.ok) { | |
| 266 | await db | |
| 267 | .update(users) | |
| 268 | .set({ lastDigestSentAt: new Date() }) | |
| 269 | .where(eq(users.id, userId)); | |
| 270 | } | |
| 271 | return result; | |
| 272 | } catch (err) { | |
| 273 | console.error("[digest] sendDigestForUser error:", err); | |
| 274 | return { ok: false, provider: "none", skipped: "error" }; | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | /** Iterates all opted-in users. Returns per-user results for logging. */ | |
| 279 | export async function sendDigestsToAll(): Promise< | |
| 280 | Array<{ userId: string; username: string; ok: boolean; skipped?: string }> | |
| 281 | > { | |
| 282 | const results: Array<{ | |
| 283 | userId: string; | |
| 284 | username: string; | |
| 285 | ok: boolean; | |
| 286 | skipped?: string; | |
| 287 | }> = []; | |
| 288 | try { | |
| 289 | const opted = await db | |
| 290 | .select({ id: users.id, username: users.username }) | |
| 291 | .from(users) | |
| 292 | .where(eq(users.notifyEmailDigestWeekly, true)); | |
| 293 | for (const u of opted) { | |
| 294 | const r = await sendDigestForUser(u.id); | |
| 295 | results.push({ | |
| 296 | userId: u.id, | |
| 297 | username: u.username, | |
| 298 | ok: r.ok, | |
| 299 | skipped: "skipped" in r ? r.skipped : undefined, | |
| 300 | }); | |
| 301 | } | |
| 302 | } catch (err) { | |
| 303 | console.error("[digest] sendDigestsToAll error:", err); | |
| 304 | } | |
| 305 | return results; | |
| 306 | } | |
| 307 | ||
| 308 | /** Pure helper exported for tests. */ | |
| 309 | export const __internal = { textToHtml, escapeHtml, fmtRange }; | |
| 310 | ||
| 311 | // Keep sql unused-import warnings silent | |
| 312 | void sql; |