Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

416 lines · 24.4 KBRaw History
1/**
2 * Who hears about what. GitHub's rules, applied to Diffinery's events:
3 *
4 * - Participating: you opened the thread, commented or reviewed, are assigned
5 * or asked to review, were @mentioned, or subscribed by hand.
6 * - Watching: "All activity" on a repository adds every new issue, pull
7 * request, comment, review and state change there.
8 * - Ignoring a repository or muting a thread stops them (a direct @mention,
9 * assignment or review request in the same event still gets through a mute).
10 * - Your own actions never notify you, unless you turn on "Include your own updates" for email.
11 * - People you blocked never notify you, @mentions included (lib/moderation.ts).
12 * - Workflow runs notify whoever triggered them (an agent's owner for agents;
13 * watchers for scheduled runs), for failures unless they ask for every run,
14 * and not while they ignore the repository.
15 * - Security emails (sign-ins, passkeys, tokens, agents, push protection) can't be turned off.
16 *
17 * Every recipient must still be able to read the repository.
18 */
19import type { DiffineryEvent } from "@diffinery/shared";
20import type { Env } from "../env.js";
21import { repoStub } from "../http/common.js";
22import type { Account, Directory, Repo } from "../lib/directory.js";
23import { sha256 } from "../lib/util.js";
24import { blockersOf } from "../lib/moderation.js";
25import { MailStore, type NotificationPrefs } from "./prefs.js";
26import { digestCron } from "./digest.js";
27import { enqueueMail } from "./send.js";
28import type { JobInfo, RunInfo } from "../repo/actions.js";
29import type { MailJob, Reason, ThreadData } from "./types.js";
30
31type ThreadEvent = Extract<DiffineryEvent, { type: "pull_request" | "issues" | "issue_comment" | "pull_request_review" }>;
32
33/** Logins @mentioned in Markdown, ignoring code. */
34export function mentions(md: string | undefined | null): string[] {
35 if (!md) return [];
36 const text = md.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ").replace(/`[^`\n]*`/g, " ");
37 const out = new Set<string>();
38 for (const m of text.matchAll(/(?:^|[^\w`/@.-])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))(?![\w@-]|\.\w)/g)) {
39 out.add(m[1]!.toLowerCase());
40 if (out.size >= 50) break;
41 }
42 return [...out];
43}
44
45/** Reasons in priority order: the first that applies is the one the email gives. */
46const PRIORITY: Reason[] = ["review_requested", "assign", "mention", "author", "comment", "manual", "state_change", "subscribed"];
47const DIRECT: Reason[] = ["review_requested", "assign", "mention"];
48
49class Recipients {
50 readonly map = new Map<string, Reason>();
51 add(id: string | undefined | null, reason: Reason): void {
52 if (!id) return;
53 const cur = this.map.get(id);
54 if (!cur || PRIORITY.indexOf(reason) < PRIORITY.indexOf(cur)) this.map.set(id, reason);
55 }
56}
57
58async function accountsByLogin(dir: Directory, logins: string[]): Promise<Account[]> {
59 return dir.accountsByLogins([...new Set(logins.map((x) => x.toLowerCase()))].slice(0, 60));
60}
61
62/** Recipients who may still read the repository, with their accounts. */
63async function readable(dir: Directory, repo: Repo, ids: string[]): Promise<Account[]> {
64 const first = ids.slice(0, 200);
65 const byId = new Map((await dir.accountsByIds(first)).map((a) => [a.id, a]));
66 const accounts = first.map((id) => byId.get(id)).filter((a): a is Account => !!a && a.kind !== "org" && a.status === "active");
67 const readers = await dir.readersOf(repo, accounts);
68 return accounts.filter((a) => readers.has(a.id));
69}
70
71async function threadEvent(env: Env, dir: Directory, ev: ThreadEvent): Promise<void> {
72 if (ev.type === "pull_request" && (ev.action === "enqueued" || ev.action === "dequeued" || ev.action === "edited" || ev.action === "auto_merge_enabled" || ev.action === "auto_merge_disabled")) return;
73 if (ev.type === "issues" && ev.action === "edited") return;
74 const repo = await dir.repoById(ev.repoId);
75 if (!repo) return;
76 const stub = repoStub(env, ev.repoId);
77 const { issue, comments, reviews } = await stub.issue(ev.number);
78 const isPull = issue.kind === "pull";
79 const threadUrl = `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${isPull ? "pull" : "issues"}/${ev.number}`;
80 const d: ThreadData = { repo: `${ev.owner}/${ev.repo}`, repoId: ev.repoId, number: ev.number, kind: issue.kind, title: issue.title, event: "opened", actor: ev.actor.login, url: threadUrl, base: issue.pull?.base, head: issue.pull?.head, intent: issue.pull?.intent ?? null, labels: issue.labels };
81 const r = new Recipients();
82 let text: string | undefined;
83 let participantsOnly = false;
84
85 if ((ev.type === "issues" || ev.type === "pull_request") && ev.action === "opened") {
86 d.event = "opened";
87 d.body = issue.body;
88 d.assignees = issue.assignees;
89 d.reviewers = issue.pull?.requestedReviewers;
90 text = issue.body;
91 for (const a of await accountsByLogin(dir, issue.assignees)) r.add(a.id, "assign");
92 for (const a of await accountsByLogin(dir, issue.pull?.requestedReviewers ?? [])) r.add(a.id, "review_requested");
93 if (isPull) d.commits = (await stub.pullCommits(ev.number).catch(() => [])).slice(-10).map((c) => ({ sha: c.sha, message: c.message }));
94 } else if (ev.type === "issue_comment") {
95 const cm = comments.find((c) => c.id === ev.commentId);
96 if (!cm) return;
97 d.event = "comment";
98 d.body = cm.body;
99 d.url = `${threadUrl}#c-${cm.id}`;
100 text = cm.body;
101 } else if (ev.type === "pull_request_review") {
102 const rv = [...reviews].reverse().find((x) => x.author.id === ev.actor.id);
103 d.event = "review";
104 d.state = ev.state;
105 const lineComments = rv ? comments.filter((c) => c.reviewId === rv.id) : [];
106 d.body = [rv?.body ?? "", ...lineComments.slice(0, 20).map((c) => `**${c.path}${c.line ? `:${c.line}` : ""}**\n\n${c.body}`)].filter((x) => x.trim()).join("\n\n---\n\n");
107 text = d.body;
108 } else if (ev.action === "closed" || ev.action === "merged" || ev.action === "reopened") {
109 if (ev.type === "pull_request" && ev.action === "closed" && issue.pull?.merged) return; // the "merged" event covers it
110 d.event = ev.action;
111 d.stateReason = issue.stateReason;
112 } else if (ev.type === "pull_request" && ev.action === "synchronize") {
113 d.event = "pushed";
114 d.commits = (await stub.pullCommits(ev.number).catch(() => [])).slice(-10).map((c) => ({ sha: c.sha, message: c.message }));
115 participantsOnly = true;
116 } else if (ev.action === "assigned") {
117 d.event = "assigned";
118 d.assignees = ev.assignees ?? [];
119 for (const a of await accountsByLogin(dir, d.assignees)) r.add(a.id, "assign");
120 participantsOnly = true;
121 } else if (ev.type === "pull_request" && ev.action === "review_requested") {
122 d.event = "review_requested";
123 d.reviewers = ev.reviewers ?? [];
124 for (const a of await accountsByLogin(dir, d.reviewers)) r.add(a.id, "review_requested");
125 participantsOnly = true;
126 } else return;
127
128 for (const a of await accountsByLogin(dir, mentions(text))) r.add(a.id, "mention");
129 r.add(issue.author.id, "author");
130 for (const c of comments) r.add(c.author.id, "comment");
131 for (const rv of reviews) r.add(rv.author.id, "comment");
132 // Assignees and requested reviewers take part in the thread from then on.
133 if (d.event !== "assigned") for (const a of await accountsByLogin(dir, issue.assignees)) r.add(a.id, "assign");
134 if (d.event !== "review_requested") for (const a of await accountsByLogin(dir, issue.pull?.requestedReviewers ?? [])) r.add(a.id, "review_requested");
135
136 const store = new MailStore(env.DB);
137 const [subs, watch] = await Promise.all([store.threadSubs(ev.repoId, ev.number), store.watchers(ev.repoId)]);
138 for (const [id, on] of subs) if (on) r.add(id, "manual");
139 // Assignments, review requests and pushes go to the people involved, not to every watcher.
140 if (!participantsOnly) for (const id of watch.all) r.add(id, "subscribed");
141 const direct = new Set([...(ev.type === "pull_request" && ev.action === "review_requested" ? d.reviewers ?? [] : []), ...(ev.action === "assigned" ? d.assignees ?? [] : []), ...mentions(text)].map((x) => x.toLowerCase()));
142
143 // Whoever blocked the actor (or the person an agent acts for) hears nothing from them.
144 const actorIds = [ev.actor.id, ev.actor.kind === "agent" && ev.actor.onBehalfOf ? (await dir.accountByLogin(ev.actor.onBehalfOf))?.id : null];
145 const blockers = await blockersOf(env, actorIds);
146 const candidates = [...r.map.keys()].filter((id) => !watch.ignore.has(id) && !blockers.has(id));
147 const accounts = await readable(dir, repo, candidates);
148 const prefs = await store.prefsFor(accounts.map((a) => a.id));
149 const web = new Map<Reason, string[]>();
150 const jobs: MailJob[] = [];
151 for (const a of accounts) {
152 let reason = r.map.get(a.id)!;
153 if (subs.get(a.id) === false && !(DIRECT.includes(reason) && direct.has(a.login.toLowerCase()))) continue;
154 const p = prefs.get(a.id)!;
155 const own = a.id === ev.actor.id;
156 if (own) reason = "your_activity";
157 const group: keyof NotificationPrefs = reason === "subscribed" ? "watching" : "participating";
158 if (!own && p[group].web) web.set(reason, [...(web.get(reason) ?? []), a.id]);
159 if (a.kind === "user" && (!own || p.ownActivity)) jobs.push({ kind: "thread", data: d, to: { accountId: a.id }, reason, at: ev.at });
160 }
161 const verb: Record<ThreadData["event"], string> = { opened: "opened", comment: "commented on", review: "reviewed", closed: "closed", merged: "merged", reopened: "reopened", assigned: "assigned", review_requested: "requested review on", pushed: "pushed to" };
162 const title = `${ev.actor.login} ${d.event === "review" ? `reviewed (${(ev as { state?: string }).state?.toLowerCase().replace(/_/g, " ")})` : verb[d.event]} ${d.repo}#${ev.number}: ${issue.title}`;
163 const webIds = [...web.values()].flat();
164 await Promise.all([webIds.length ? dir.notify(webIds.slice(0, 200), { repoId: ev.repoId, kind: ev.type, title, url: d.url }) : null, enqueueMail(env, jobs)]);
165}
166
167async function workflowRun(env: Env, dir: Directory, ev: Extract<DiffineryEvent, { type: "workflow_run" }>): Promise<void> {
168 if (ev.action !== "completed") return;
169 const failed = ev.conclusion === "failure" || ev.conclusion === "timed_out";
170 if (!failed && ev.conclusion !== "success") return;
171 const repo = await dir.repoById(ev.repoId);
172 if (!repo) return;
173 const store = new MailStore(env.DB);
174 const watch = await store.watchers(ev.repoId);
175 let ids: string[] = [];
176 if (ev.actor.kind === "user") ids = [ev.actor.id];
177 else if (ev.actor.kind === "agent" && ev.actor.onBehalfOf) {
178 const owner = await dir.accountByLogin(ev.actor.onBehalfOf);
179 if (owner?.kind === "user") ids = [owner.id];
180 } else ids = watch.all;
181 // Ignoring a repository silences its runs too, even the ones you started.
182 const accounts = await readable(dir, repo, ids.filter((id) => !watch.ignore.has(id)));
183 if (!accounts.length) return;
184 const prefs = await store.prefsFor(accounts.map((a) => a.id));
185 const url = `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}`;
186 const webIds = failed ? accounts.filter((a) => prefs.get(a.id)!.actions.web).map((a) => a.id) : [];
187 const mailTo = accounts.filter((a) => a.kind === "user" && (failed || !prefs.get(a.id)!.actions.failuresOnly));
188 let jobs: MailJob[] = [];
189 if (mailTo.length) {
190 // The RPC return type is too deep for TypeScript to instantiate; it is exactly getRun()'s.
191 const run = (await (repoStub(env, ev.repoId).run as unknown as (id: string) => Promise<unknown>)(ev.runId)) as RunInfo & { jobs: JobInfo[] };
192 const data = {
193 repo: `${ev.owner}/${ev.repo}`,
194 repoId: ev.repoId,
195 runId: ev.runId,
196 runNumber: run.number,
197 workflow: run.workflowName,
198 title: run.title,
199 conclusion: ev.conclusion ?? "",
200 event: run.event,
201 branch: run.headBranch || run.ref.replace(/^refs\/(heads|tags)\//, ""),
202 sha: run.headSha || run.sha,
203 actor: run.actor.login,
204 url,
205 // What the email shows (mail/templates.ts): the first 20 jobs, each failure's first 160
206 // characters. A large matrix with long failures would pass the queue's 128 KB per message.
207 jobs: run.jobs.slice(0, 20).map((j) => ({ name: j.name, conclusion: j.conclusion, url: `${url}/job/${j.id}`, failure: j.failure?.message?.slice(0, 160) ?? null })),
208 };
209 jobs = mailTo.map((a) => ({ kind: "run" as const, data, to: { accountId: a.id }, reason: "ci_activity" as const, at: ev.at }));
210 }
211 await Promise.all([webIds.length ? dir.notify(webIds, { repoId: ev.repoId, kind: "run_failed", title: `Workflow run failed in ${ev.owner}/${ev.repo}`, url }) : null, enqueueMail(env, jobs)]);
212}
213
214/** Web notifications and emails for one event from the events queue. */
215export async function notifyEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
216 if (ev.type === "workflow_run") return workflowRun(env, dir, ev);
217 if (ev.type === "deployment_review" && ev.action === "requested") return deploymentReview(env, dir, ev);
218 if (ev.type === "pull_request" || ev.type === "issues" || ev.type === "issue_comment" || ev.type === "pull_request_review") return threadEvent(env, dir, ev);
219}
220
221/**
222 * A run's jobs wait on an environment's required reviewers: each reviewer who can still read the
223 * repository gets a notification and an email (unless they turned Actions email off).
224 */
225async function deploymentReview(env: Env, dir: Directory, ev: Extract<DiffineryEvent, { payload: unknown }>): Promise<void> {
226 const p = ev.payload as { environment?: string; workflow_run?: { id?: string; run_number?: number; name?: string; display_title?: string; head_sha?: string; head_branch?: string }; reviewers?: Array<{ reviewer?: { id?: string } }> };
227 const repo = await dir.repoById(ev.repoId);
228 const run = p.workflow_run;
229 if (!repo || !run?.id || !p.environment) return;
230 const ids = (p.reviewers ?? []).map((r) => r.reviewer?.id).filter((x): x is string => typeof x === "string" && x !== ev.actor.id);
231 const accounts = (await readable(dir, repo, ids)).filter((a) => a.kind === "user");
232 if (!accounts.length) return;
233 const url = `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${run.id}`;
234 const prefs = await new MailStore(env.DB).prefsFor(accounts.map((a) => a.id));
235 const data = { repo: `${ev.owner}/${ev.repo}`, environment: p.environment, workflow: run.name ?? "", runNumber: run.run_number ?? 0, title: run.display_title ?? "", branch: run.head_branch ?? "", sha: run.head_sha ?? "", requestor: ev.actor.login, url };
236 await Promise.all([
237 dir.notify(
238 accounts.filter((a) => prefs.get(a.id)?.actions.web !== false).map((a) => a.id),
239 { repoId: ev.repoId, kind: "deployment_review", title: `Deployment to ${p.environment} in ${ev.owner}/${ev.repo} is waiting for your review`, url },
240 ),
241 enqueueMail(env, accounts.map((a) => ({ kind: "deployment_review_requested" as const, data, to: { accountId: a.id }, reason: "review_requested" as const, at: ev.at }))),
242 ]);
243}
244
245// ---------------------------------------------------------------- account and security emails
246
247/** People who administer a repository: its owner (or the org's owners and admins) and admin collaborators. */
248export async function repoAdmins(dir: Directory, repo: Repo): Promise<string[]> {
249 const ids = new Set<string>();
250 if (repo.owner_kind === "org") for (const m of await dir.orgMembers(repo.owner_id)) if (m.role === "owner" || m.role === "admin") ids.add(m.id);
251 if (repo.owner_kind === "user") ids.add(repo.owner_id);
252 for (const c of await dir.collaborators(repo.id)) if (c.role === "admin" && c.account.kind === "user") ids.add(c.account.id);
253 return [...ids];
254}
255
256/** The people an account's security emails go to: the person, or an agent's owner (or its org's owners). */
257export async function humansFor(dir: Directory, accountId: string | undefined | null): Promise<string[]> {
258 if (!accountId) return [];
259 const a = await dir.account(accountId);
260 if (!a) return [];
261 if (a.kind === "user") return [a.id];
262 if (a.kind === "agent" && a.owner_id) {
263 const o = await dir.account(a.owner_id);
264 if (o?.kind === "user") return [o.id];
265 if (o?.kind === "org") return (await dir.orgMembers(o.id)).filter((m) => m.role === "owner" || m.role === "admin").map((m) => m.id);
266 }
267 return [];
268}
269
270export interface AuditEntry {
271 actorId?: string;
272 actorLogin?: string;
273 action: string;
274 target?: string;
275 detail?: unknown;
276 at?: string;
277}
278
279/** A sign-in device: the passkey (when there is one) plus the browser and OS. */
280export async function deviceFingerprint(device: string, passkeyId?: string | null): Promise<string> {
281 return (await sha256(`${passkeyId ?? "-"}|${device}`)).slice(0, 32);
282}
283
284/** Security emails for audited actions. Called for every audit entry; ignores the rest. */
285export async function mailForAudit(env: Env, dir: Directory, e: AuditEntry): Promise<void> {
286 const d = (e.detail && typeof e.detail === "object" ? e.detail : {}) as Record<string, unknown>;
287 const s = (k: string) => (typeof d[k] === "string" ? (d[k] as string) : undefined);
288 const at = e.at ?? new Date().toISOString();
289 const to = (ids: string[], body: Omit<MailJob, "to" | "at" | "reason">, reason: Reason = "security_alert") => ids.map((id) => ({ ...body, to: { accountId: id }, reason, at }) as MailJob);
290 const store = new MailStore(env.DB);
291 let jobs: MailJob[] = [];
292 switch (e.action) {
293 case "session.create": {
294 if (!e.actorId) return;
295 const device = s("device") ?? "an unknown browser";
296 const method = (s("method") ?? "passkey") as "passkey" | "access" | "link";
297 const fresh = await store.seeDevice(e.actorId, await deviceFingerprint(device, s("passkeyId")), device);
298 if (!fresh && method === "passkey") return;
299 jobs = to([e.actorId], { kind: "signin_new", data: { method, device, ip: s("ip"), location: s("location"), passkey: s("passkey") } });
300 break;
301 }
302 case "passkey.add":
303 if (!e.actorId) return;
304 if (s("device")) await store.seeDevice(e.actorId, await deviceFingerprint(s("device")!, s("passkeyId")), s("device")!);
305 jobs = to([e.actorId], { kind: "passkey_added", data: { name: s("name") ?? "passkey", via: s("via") === "signin-link" ? "signin-link" : "settings" } });
306 break;
307 case "passkey.remove":
308 if (!e.actorId) return;
309 jobs = to([e.actorId], { kind: "passkey_removed", data: { name: s("name") ?? "passkey" } });
310 break;
311 case "agent.create":
312 if (!e.actorId || !e.target) return;
313 jobs = to([e.actorId], { kind: "agent_created", data: { agent: e.target } });
314 break;
315 case "push.secret_bypassed":
316 case "api.secret_bypassed":
317 case "push.blocked_secret":
318 case "api.blocked_secret": {
319 const [owner, name] = (e.target ?? "").split(":")[0]!.split("/");
320 if (!owner || !name) return;
321 const repo = await dir.repo(owner, name);
322 if (!repo) return;
323 const kinds = Array.isArray(d.kinds) ? [...new Set(d.kinds as string[])] : [];
324 const refs = Array.isArray(d.refs) ? (d.refs as string[]) : undefined;
325 const pusher = await humansFor(dir, e.actorId);
326 // A commit made without git (web editor, API, MCP) names the files instead of refs.
327 const paths = Array.isArray(d.paths) ? (d.paths as string[]) : [];
328 if (e.action === "push.blocked_secret") jobs = to(pusher, { kind: "push_blocked", data: { repo: `${owner}/${name}`, kinds, refs } });
329 else if (e.action === "api.blocked_secret") jobs = to(pusher, { kind: "push_blocked", data: { repo: `${owner}/${name}`, kinds, paths } });
330 else jobs = to([...new Set([...pusher, ...(await repoAdmins(dir, repo))])], { kind: "push_bypassed", data: { repo: `${owner}/${name}`, pusher: e.actorLogin ?? "someone", kinds, reason: s("reason") ?? "", refs } });
331 break;
332 }
333 case "invite.accept":
334 case "signup":
335 // A new account made from an invite or open sign-up (with a passkey) gets a welcome email.
336 if (e.actorId && s("method") === "passkey") {
337 if (s("device")) await store.seeDevice(e.actorId, await deviceFingerprint(s("device")!, s("passkeyId")), s("device")!);
338 jobs = to([e.actorId], { kind: "welcome", data: { login: e.actorLogin ?? "" } }, "account");
339 }
340 break;
341 case "repo.delete": {
342 const [owner, name] = (e.target ?? "").split("/");
343 const ownerAcc = owner ? await dir.accountByLogin(owner) : null;
344 if (!ownerAcc || !name) return;
345 // The person who deleted it already knows.
346 const ids = (ownerAcc.kind === "org" ? (await dir.orgMembers(ownerAcc.id)).filter((m) => m.role === "owner" || m.role === "admin").map((m) => m.id) : [ownerAcc.id]).filter((id) => id !== e.actorId);
347 jobs = to(ids, { kind: "repo_deleted", data: { repo: `${owner}/${name}`, actor: e.actorLogin ?? "someone" } }, "admin");
348 break;
349 }
350 case "repo.transfer": {
351 // The admins of both owners: the old one's lose the repository, the new one's gain it.
352 const from = s("from");
353 const dest = s("to");
354 if (!from || !dest) return;
355 const admins = async (login: string) => {
356 const acc = await dir.accountByLogin(login);
357 if (!acc) return [];
358 return acc.kind === "org" ? (await dir.orgMembers(acc.id)).filter((m) => m.role === "owner" || m.role === "admin").map((m) => m.id) : acc.kind === "user" ? [acc.id] : [];
359 };
360 const ids = [...new Set([...(await admins(from.split("/")[0]!)), ...(await admins(dest.split("/")[0]!))])].filter((id) => id !== e.actorId);
361 jobs = to(ids, { kind: "repo_transferred", data: { from, to: dest, actor: e.actorLogin ?? "someone" } }, "admin");
362 break;
363 }
364 default:
365 return;
366 }
367 await enqueueMail(env, jobs);
368}
369
370/** A token was created for a person or an agent (from the web, the API or the CLI). */
371export async function tokenCreated(env: Env, dir: Directory, account: Account, t: { name: string; scopes: string[] }): Promise<void> {
372 const at = new Date().toISOString();
373 const ids = await humansFor(dir, account.id);
374 await enqueueMail(env, ids.map((id) => ({ kind: "token_created" as const, data: { name: t.name, scopes: t.scopes, agent: account.kind === "agent" ? account.login : undefined }, to: { accountId: id }, reason: "security_alert" as const, at })));
375}
376
377/** A repository was created: tell its creator (unless a script did it with a token) and the org's other owners and admins; the creator watches it. */
378export async function repoCreated(env: Env, dir: Directory, repo: Repo, creator: Account, opts: { viaToken: boolean }): Promise<void> {
379 const store = new MailStore(env.DB);
380 const human = creator.kind === "user";
381 if (human && (await store.prefs(creator.id)).autoWatch) await store.setWatch(creator.id, repo.id, "all");
382 const data = { repo: `${repo.owner_login}/${repo.name}`, creator: creator.login, visibility: repo.visibility, defaultBranch: repo.default_branch, description: repo.description };
383 const at = new Date().toISOString();
384 const jobs: MailJob[] = [];
385 if (human && !opts.viaToken) jobs.push({ kind: "repo_created", data, to: { accountId: creator.id }, reason: "your_activity", at });
386 if (repo.owner_kind === "org") {
387 for (const m of await dir.orgMembers(repo.owner_id)) {
388 if ((m.role === "owner" || m.role === "admin") && m.id !== creator.id && m.id !== creator.owner_id) jobs.push({ kind: "repo_created", data, to: { accountId: m.id }, reason: "admin", at });
389 }
390 }
391 await enqueueMail(env, jobs);
392}
393
394/** Jobs from the five-minute cron trigger: token expiry reminders, morning digests that are due, and log pruning. */
395export async function mailCron(env: Env, dir: Directory, at: Date): Promise<void> {
396 const store = new MailStore(env.DB);
397 const now = at.toISOString();
398 const soon = new Date(at.getTime() + 7 * 86400_000).toISOString();
399 const { results } = await env.DB.prepare(
400 "SELECT id, account_id, name, prefix, expires_at FROM tokens WHERE revoked_at IS NULL AND expiry_notified_at IS NULL AND expires_at > ? AND expires_at <= ? AND julianday(expires_at) - julianday(created_at) > 8 LIMIT 50",
401 )
402 .bind(now, soon)
403 .all<{ id: string; account_id: string; name: string; prefix: string; expires_at: string }>();
404 const jobs: MailJob[] = [];
405 for (const t of results) {
406 const a = await dir.account(t.account_id);
407 if (a && a.status === "active") {
408 const ids = await humansFor(dir, a.id);
409 for (const id of ids) jobs.push({ kind: "token_expiring", data: { name: t.name, prefix: t.prefix, expiresAt: t.expires_at, agent: a.kind === "agent" ? a.login : undefined }, to: { accountId: id }, reason: "security_alert", at: now });
410 }
411 await env.DB.prepare("UPDATE tokens SET expiry_notified_at = ? WHERE id = ?").bind(now, t.id).run();
412 }
413 await enqueueMail(env, jobs);
414 await digestCron(env.DB, at, (ids) => store.prefsFor(ids), (digests) => enqueueMail(env, digests));
415 if (at.getUTCHours() === 3 && at.getUTCMinutes() < 5) await store.prune();
416}