Morning digest: Today by email at the hour you choose, and Today on phones (phase 5c) (#168)
* WIP: morning digest (phase 5c) * WIP 5c: docs and e2e * 5c e2e: count digests after the template gallery's sample * Docs: what a hang-up mid-answer costs, and when its slot frees Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 --------- Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 7dd2e1ccommit c67c4e395a49a9c41450fa027d5b77d600f2e77fBrowse files
22 files changed+869−50
Files (22)
- apps/diffinery/docs/accounts/notifications.md+23−2
- apps/diffinery/docs/agents/organizations.md+1−0
- apps/diffinery/docs/agents/review.md+1−1
- apps/diffinery/docs/agents/today.md+23−2
- apps/diffinery/docs/reference/limits.md+1−0
- apps/diffinery/migrations/0032_mail_digests.sql+9−0
- apps/diffinery/src/accounts/lifecycle.ts+1−0
- apps/diffinery/src/agentic/digest.ts+72−0
- apps/diffinery/src/agentic/today.ts+29−2
- apps/diffinery/src/agentic/web.tsx+1−26
- apps/diffinery/src/mail/api.ts+15−2
- apps/diffinery/src/mail/digest.ts+118−0
- apps/diffinery/src/mail/notify.ts+3−1
- apps/diffinery/src/mail/prefs.ts+55−1
- apps/diffinery/src/mail/samples.ts+36−0
- apps/diffinery/src/mail/send.ts+16−1
- apps/diffinery/src/mail/templates.ts+35−1
- apps/diffinery/src/mail/types.ts+43−3
- apps/diffinery/src/mail/web.tsx+82−7
- apps/diffinery/src/web/assets.ts+2−0
- apps/diffinery/test/e2e/email.test.mjs+82−1
- apps/diffinery/test/unit/digest.test.ts+221−0
modifiedapps/diffinery/docs/accounts/notifications.md+23−2View file
| @@ -53,6 +53,7 @@ | ||
| 53 | 53 | | Actions | Workflow runs you started (failed runs only, unless you turn off **Only failed runs**), and scheduled runs in repositories you watch | Yes | On, failures only | |
| 54 | 54 | | Repositories | Repositories created or deleted in your organizations (for owners and admins), your own new repositories, and repository access granted, changed or removed | Yes | On | |
| 55 | 55 | | Organizations | Your organization role added, changed or removed (with who did it), invitations you sent being accepted, and access requests (for site administrators) | Yes | On | |
| 56 | +| Morning digest | [Today](/docs/agents/today) by email, once a day at the hour you choose | Yes | Off | | |
| 56 | 57 | |
| 57 | 58 | ## Notification settings |
| 58 | 59 | |
| @@ -74,9 +75,17 @@ | ||
| 74 | 75 | - **Email me about my own comments and changes** (off by default). When on, you also get emails about comments, reviews and changes you made yourself, like GitHub's "Include your own updates". |
| 75 | 76 | - **Watch repositories I create (All activity)** (on by default). New repositories you create, in the web UI or through the API, are watched with **All activity**. |
| 76 | 77 | |
| 78 | +Under **Morning digest**: | |
| 79 | + | |
| 80 | +- **Email me a morning digest** (off by default). | |
| 81 | +- **Hour** and **Time zone**: when it goes out, in your time zone. Until you save one, the time zone is your browser's. | |
| 82 | +- **On Saturdays and Sundays too** (off by default). | |
| 83 | + | |
| 84 | +When the digest is on, the page says when the next one goes out. See [The morning digest](/docs/agents/today#the-morning-digest). | |
| 85 | + | |
| 77 | 86 | Click **Save notification settings**. You see "Notification settings saved." |
| 78 | 87 | |
| 79 | -The same page lists **Watched repositories** (every repository you watch or ignore, with a menu to change the level) and **Recent emails to you** (the last 20, with their status), and has a **Send me a test email** button. | |
| 88 | +The same page lists **Watched repositories** (every repository you watch or ignore, with a menu to change the level) and **Recent emails to you** (the last 20, with their status), and has **Send me a test email** and **Send me my digest now** buttons. | |
| 80 | 89 | |
| 81 | 90 | ## Who gets notified about issues and pull requests |
| 82 | 91 | |
| @@ -220,6 +229,7 @@ | ||
| 220 | 229 | | `admin` | You are an administrator (of the organization, repository or instance). | |
| 221 | 230 | | `account` | You have a Diffinery account (account and membership changes). | |
| 222 | 231 | | `signup` | Someone asked to create an account with this address through open sign-up (the sign-up code). | |
| 232 | +| `digest` | You turned on the morning digest in your notification settings. | | |
| 223 | 233 | |
| 224 | 234 | ## When Diffinery stops emailing you |
| 225 | 235 | |
| @@ -263,6 +273,7 @@ | ||
| 263 | 273 | | `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you | |
| 264 | 274 | | `review_limit_reached` | `[acme/website] Agent review reached its daily limit` | Account | `your_activity` | [Agent review](/docs/agents/review) on a repository whose reviews use your model key reached its daily limit (at most once a day per repository) | |
| 265 | 275 | | `test` | `[Diffinery] Test email` | Account | `account` | You clicked **Send me a test email** (or used the API) | |
| 276 | +| `digest` | `[Diffinery] Today: 2 need you, 3 merged` | Morning digest | `digest` | At the hour you chose, when something needs you or happened since the last one; or when you click **Send me my digest now** | | |
| 266 | 277 | | `invite_accepted` | `[Diffinery] @diffy accepted your invitation` | Organizations | `account` | Someone accepted an invite you created | |
| 267 | 278 | | `access_request` | `[Diffinery] newcomer@example.com asked for access` | Organizations | `admin` | A person waiting for an invite requested access (sent to site administrators) | |
| 268 | 279 | | `org_membership` | `[Diffinery] You're now a member of acme` | Organizations | `account` | Your organization role was added, changed or removed by an owner or admin. The email names who added you ("Added by") or who changed your role ("Changed by"). | |
| @@ -316,6 +327,7 @@ | ||
| 316 | 327 | | `DELETE /api/v3/repos/{owner}/{repo}/subscription` | Back to participating | `write` or `admin` | |
| 317 | 328 | | `GET /api/v3/diffinery/emails` | Your recent emails: `kind`, `subject`, `status` (`sent`, `failed` or `skipped`), `detail`, `message_id`, `created_at`. Query: `kind`, `since` (ISO time), `per_page` (default 50, up to 500) | any | |
| 318 | 329 | | `POST /api/v3/diffinery/emails/test` | Send yourself a test email now | `write` or `admin`, all repositories | |
| 330 | +| `POST /api/v3/diffinery/emails/digest` | Send yourself the morning digest now, even when it is off or has nothing in it (3 an hour) | `write` or `admin`, all repositories | | |
| 319 | 331 | | `GET /api/v3/diffinery/email` | Your email address and whether it is suppressed | any | |
| 320 | 332 | | `POST /api/v3/diffinery/email/verification` | Send the email that lifts a bounce or complaint on your address | `write` or `admin`, all repositories | |
| 321 | 333 | |
| @@ -329,10 +341,19 @@ | ||
| 329 | 341 | "repos": { "email": true }, |
| 330 | 342 | "orgs": { "email": true }, |
| 331 | 343 | "ownActivity": false, |
| 332 | - "autoWatch": true | |
| 344 | + "autoWatch": true, | |
| 345 | + "digest": { "email": false, "hour": 8, "timeZone": "UTC", "weekends": false } | |
| 333 | 346 | } |
| 334 | 347 | ``` |
| 335 | 348 | |
| 349 | +`digest.hour` is 0 to 23 and `digest.timeZone` an IANA time zone, such as `Europe/Istanbul`; anything else returns `422`. To get the digest at 7:00 in Istanbul: | |
| 350 | + | |
| 351 | +```sh | |
| 352 | +curl -X PUT https://diffinery.com/api/v3/diffinery/notification-settings \ | |
| 353 | + -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ | |
| 354 | + -d '{"digest": {"email": true, "hour": 7, "timeZone": "Europe/Istanbul"}}' | |
| 355 | +``` | |
| 356 | + | |
| 336 | 357 | For example, to stop watching emails and get every workflow run result: |
| 337 | 358 | |
| 338 | 359 | ```sh |
modifiedapps/diffinery/docs/agents/organizations.md+1−0View file
| @@ -69,6 +69,7 @@ | ||
| 69 | 69 | - While an allowance is set, a person has at most 4 model requests on the organization's keys on their way at once, across all their runs. A fifth gets `429` with `retry-after`, and the agent's CLI tries again. |
| 70 | 70 | - So requests already on their way can take a person past the allowance by at most 4 requests. Each one's output is capped at what it booked; its input can cost more than its estimate when it tokenizes densely or carries images or PDFs, which bill by pixels and pages. |
| 71 | 71 | - A request still streaming after 15 minutes stops counting toward the 4. |
| 72 | +- If the agent hangs up while an answer is still streaming, Diffinery keeps reading it for about 30 seconds. An answer that takes longer stays charged at its booking, and its place among the 4 frees after 15 minutes. | |
| 72 | 73 | - An answer that reports no usage, or whose usage can't be read (a streamed line too long to keep, for example), is charged a token per byte sent, plus its output cap. |
| 73 | 74 | - A request books at most 1,000,000 output tokens, whatever cap it asks for, and is sent with that cap. |
| 74 | 75 | - Runs on a person's own key don't count. |
modifiedapps/diffinery/docs/agents/review.md+1−1View file
| @@ -116,7 +116,7 @@ | ||
| 116 | 116 | Diffinery counts tokens itself, as requests pass through the model proxy: |
| 117 | 117 | |
| 118 | 118 | - Before a request goes to the provider, the proxy books the most it may cost: a token per 3 bytes of request, plus the output it allows (its `max_tokens` or the endpoint's equivalent, or 32,000 when it sets none; a Codex compaction, which can't be capped, books 128,000). If that could take the review past its budget, the request is refused and the review stops. A review has at most 4 requests on their way at once (a fifth gets `429` and is tried again), so requests sent at once can overrun its budget by at most that many: each one's output is capped at what it booked, and only its input can cost more than its estimate (dense text, or images and PDFs, which bill by pixels and pages). |
| 119 | -- Once the response has streamed through, the booking is replaced by the usage the provider reports: input, output and cache writes in full, cache reads as a tenth, since providers bill them at about a tenth. A response that reports no usage, or whose usage can't be read (a streamed line too long to keep, for example), is charged a token per byte sent, plus its output cap. An error response costs nothing. | |
| 119 | +- Once the response has streamed through, the booking is replaced by the usage the provider reports: input, output and cache writes in full, cache reads as a tenth, since providers bill them at about a tenth. A response that reports no usage, or whose usage can't be read (a streamed line too long to keep, for example), is charged a token per byte sent, plus its output cap. An error response costs nothing. If the agent hangs up while an answer is still streaming, Diffinery keeps reading it for about 30 seconds; an answer that takes longer stays charged at its booking, and its place among the 4 frees after 15 minutes. | |
| 120 | 120 | - A review can't start background responses, pull in input its request doesn't carry (`previous_response_id`, `conversation`, `prompt` or `item_reference` inputs, an Anthropic `container`, or images, documents and files given by URL or file id), send a body that isn't a JSON object, ask for more than one answer (`n`), use an OpenRouter web search model (`:online`) or preset, read back stored responses, or set routing and paid extras (`models`, `route`, `provider`, `plugins`, `web_search_options`, `speed`, `best_of`, `preset`). A review through OpenRouter asks only for its configured model. A review's requests keep only tools the agent runs itself (function and custom tools, and client-run ones such as bash, text editor, memory, a local shell and apply patch); every tool that runs on the provider is stripped, along with MCP servers, so web search, web fetch, code execution, file search, image generation and any server tool a provider adds later never reach it. Claude Code reviews also run with WebSearch and WebFetch turned off, and Codex reviews with web search disabled. A review's request is sent with every output cap set to the one booked (the largest it set, or 32,000 when it set none), so its booking holds. |
| 121 | 121 | |
| 122 | 122 | Every run's model requests and tokens show in its transcript and in the API's `runs` (`model_requests`, `tokens`). |
modifiedapps/diffinery/docs/agents/today.md+23−2View file
| @@ -5,13 +5,13 @@ | ||
| 5 | 5 | |
| 6 | 6 | **Today** is the first page you see after signing in to Diffinery. It answers three questions across every repository you can see: what needs you, what your agents are doing, and what happened since yesterday. Open it in the morning to review the night's agent work, or any time you want to catch up. |
| 7 | 7 | |
| 8 | -The same view is available to scripts and agents from the REST API and the `today` MCP tool, so your own coding agent can read your morning too. | |
| 8 | +The same view is available to scripts and agents from the REST API and the `today` MCP tool, so your own coding agent can read your morning too. It can also come to you by email as a [morning digest](#the-morning-digest), and it fits a phone's screen. | |
| 9 | 9 | |
| 10 | 10 | ## What it shows |
| 11 | 11 | |
| 12 | 12 | At the top, four numbers link to the sections below them: how many items need you, how many agent pull requests are open, how many pull requests merged since yesterday (and how many of them were by agents), and how many workflow runs failed. |
| 13 | 13 | |
| 14 | -A side column lists your repositories: those you or your organizations own, up to 40. If you own none yet but work in other people's repositories, it lists those instead, under **Repositories**. | |
| 14 | +A side column lists your repositories: those you or your organizations own, up to 40. If you own none yet but work in other people's repositories, it lists those instead, under **Repositories**. On a phone, Today comes first and the repository list after it. | |
| 15 | 15 | |
| 16 | 16 | ### Needs you |
| 17 | 17 | |
| @@ -58,6 +58,27 @@ | ||
| 58 | 58 | |
| 59 | 59 | [Agent tasks](/docs/agents/tasks) you started that are running or changed in the last 24 hours, with their state and pull request. The section appears only when you have some. |
| 60 | 60 | |
| 61 | +## The morning digest | |
| 62 | + | |
| 63 | +Turn on **Email me a morning digest** in **Settings → Notifications** to get Today by email once a day. Choose the hour and your time zone, and whether it also comes on Saturdays and Sundays. | |
| 64 | + | |
| 65 | +The digest has the same sections as the page, each with up to 8 lines and a link to the rest: | |
| 66 | + | |
| 67 | +- **Needs you** and **Deployments waiting for you**, as they are when it is sent. | |
| 68 | +- **Agents at work**: your agents' open pull requests, without drafts. | |
| 69 | +- **Your agent tasks**: running, or changed since the last digest. | |
| 70 | +- **Failed runs**, **Merged** and **Released**, since the last digest. | |
| 71 | + | |
| 72 | +"Since the last digest" covers a weekend when weekends are off, and at most 7 days. A first digest covers the last 24 hours. | |
| 73 | + | |
| 74 | +- **When nothing needs you and nothing happened, no digest is sent.** The email log on the settings page lists it as skipped, with the reason. | |
| 75 | +- The digest is gathered when it is sent, so it shows what is true then. | |
| 76 | +- It goes out within 5 minutes of the hour. | |
| 77 | +- **Turning it off:** use **Unsubscribe from emails like this** in its footer or your mail app's unsubscribe button. That turns off only the digest. | |
| 78 | +- **Send me my digest now**, next to the test email button, sends it at once, even when it is off or empty. You can do that 3 times an hour. | |
| 79 | + | |
| 80 | +Like every email, the digest isn't sent to an address on the suppression list (see [When Diffinery stops emailing you](/docs/accounts/notifications#when-diffinery-stops-emailing-you)). Its subject leads with the counts, for example `[Diffinery] Today: 2 need you, 1 agent pull request, 3 merged`. | |
| 81 | + | |
| 61 | 82 | ## Which repositories it covers |
| 62 | 83 | |
| 63 | 84 | Today looks at the repositories you can see that had activity in the last 30 days, such as a push, a pull request, a review, a workflow run or a release, most recent first, up to 100 of them. Archived repositories are left out. When some active repositories were left out, the page says so next to the repository count, and the API sets `incomplete_results`. |
modifiedapps/diffinery/docs/reference/limits.md+1−0View file
| @@ -363,6 +363,7 @@ | ||
| 363 | 363 | | Recipients per event | 200 | At most 200 people are notified about one event, in the app or by email. | |
| 364 | 364 | | Invitations | 50 per person per 24 hours | Invitations to an organization, a repository or the site, counted per inviter. The 51st answers `429 you can send at most 50 invitations in 24 hours; try again later`. Site administrators aren't limited. | |
| 365 | 365 | | Test emails | 5 per account per hour | **Send me a test email** and `POST /api/v3/diffinery/emails/test`; the 6th answers `429 you can send at most 5 test emails an hour; try again later`. | |
| 366 | +| Morning digest | Once a day at the hour you choose, within 5 minutes; up to 8 lines per section | **Send me my digest now** and `POST /api/v3/diffinery/emails/digest`: 3 an hour, then `429 you can ask for your digest at most 3 times an hour; try again later`. "Since the last digest" reaches back at most 7 days. | | |
| 366 | 367 | | Images in emails | Linked, not loaded | An image in issue, pull request or comment text that isn't on Diffinery itself shows in the email as a link (`[alt text]`), so opening the email doesn't tell the image's host that you read it. | |
| 367 | 368 | |
| 368 | 369 | ## Platform limits for operators |
addedapps/diffinery/migrations/0032_mail_digests.sql+9−0View file
| @@ -1,0 +1,9 @@ | ||
| 1 | +-- The morning digest (phase 5c, mail/digest.ts): when each person who turned it on gets their next | |
| 2 | +-- one. Their choices (hour, time zone, weekends) live in notification_prefs; this row is only the | |
| 3 | +-- schedule, written whenever those settings are saved and moved on by the cron after each send. | |
| 4 | +CREATE TABLE IF NOT EXISTS mail_digests ( | |
| 5 | + account_id TEXT PRIMARY KEY REFERENCES accounts(id), | |
| 6 | + next_at TEXT NOT NULL, -- the next send, UTC ISO | |
| 7 | + last_sent_at TEXT -- the last one queued; the next digest covers what happened since | |
| 8 | +); | |
| 9 | +CREATE INDEX IF NOT EXISTS mail_digests_next ON mail_digests(next_at); |
modifiedapps/diffinery/src/accounts/lifecycle.ts+1−0View file
| @@ -99,6 +99,7 @@ | ||
| 99 | 99 | db.prepare("DELETE FROM invites WHERE accepted_at IS NULL AND (account_id = ? OR invited_by = ?)").bind(account.id, account.id), |
| 100 | 100 | forIds("DELETE FROM notifications WHERE account_id IN (?)"), |
| 101 | 101 | forMe("DELETE FROM notification_prefs WHERE account_id = ?"), |
| 102 | + forMe("DELETE FROM mail_digests WHERE account_id = ?"), | |
| 102 | 103 | forMe("DELETE FROM repo_watches WHERE account_id = ?"), |
| 103 | 104 | forMe("DELETE FROM thread_subscriptions WHERE account_id = ?"), |
| 104 | 105 | forMe("DELETE FROM known_devices WHERE account_id = ?"), |
addedapps/diffinery/src/agentic/digest.ts+72−0View file
| @@ -1,0 +1,72 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import type { Account, Directory } from "../lib/directory.js"; | |
| 3 | +import { localDate } from "../mail/digest.js"; | |
| 4 | +import type { DigestItem, DigestSection, DigestSummary } from "../mail/types.js"; | |
| 5 | +import { AGENT_TEXT, NEEDS_TEXT, TASK_TEXT, gatherToday, type Today } from "./today.js"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * The morning digest's content (phase 5c): Today (./today.ts) for one person, cut down to what fits | |
| 9 | + * in an email. The schedule and sending are in mail/digest.ts and mail/send.ts. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +/** Lines per section; the email links to Today for the rest. */ | |
| 13 | +export const DIGEST_MAX_ITEMS = 8; | |
| 14 | + | |
| 15 | +const short = (s: string, n = 120) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); | |
| 16 | + | |
| 17 | +/** Today as the digest's sections, in the order the page shows them. Pure, so it is unit tested. */ | |
| 18 | +export function digestSummary(base: string, t: Today, date: string): DigestSummary { | |
| 19 | + const section = (id: DigestSection["id"], title: string, all: DigestItem[]): DigestSection => ({ id, title, total: all.length, items: all.slice(0, DIGEST_MAX_ITEMS) }); | |
| 20 | + const pull = (repo: { fullName: string; url: string }, n: number) => ({ url: `${base}${repo.url}/pull/${n}`, where: `${repo.fullName}#${n}` }); | |
| 21 | + const working = t.agentsAtWork.filter((x) => x.state !== "draft"); | |
| 22 | + const sections = [ | |
| 23 | + section( | |
| 24 | + "needs_you", | |
| 25 | + "Needs you", | |
| 26 | + t.needsYou.map((x) => ({ title: short(x.pull.title), ...pull(x.repo, x.pull.number), note: `${NEEDS_TEXT[x.kind].label} · by ${x.pull.author.login}`, mark: x.kind === "checks_failing" ? "fail" : "dot" })), | |
| 27 | + ), | |
| 28 | + section( | |
| 29 | + "deployments", | |
| 30 | + "Deployments waiting for you", | |
| 31 | + t.deployments.map((x) => ({ title: `${x.deployment.environment}: ${short(x.deployment.title || x.deployment.workflow)}`, url: `${base}${x.repo.url}/actions/runs/${x.deployment.runId}`, where: x.repo.fullName, note: `${x.deployment.workflow} #${x.deployment.runNumber}`, mark: "dot" })), | |
| 32 | + ), | |
| 33 | + section( | |
| 34 | + "agents", | |
| 35 | + "Agents at work", | |
| 36 | + working.map((x) => ({ title: short(x.pull.title), ...pull(x.repo, x.pull.number), note: `${AGENT_TEXT[x.state].label} · ${x.pull.author.login}`, mark: x.state === "checks_failing" ? "fail" : x.state === "approved" || x.state === "landing" ? "ok" : "dot" })), | |
| 37 | + ), | |
| 38 | + section( | |
| 39 | + "tasks", | |
| 40 | + "Your agent tasks", | |
| 41 | + t.tasks.map((x) => ({ title: short(x.title), url: `${base}/${x.repository}/tasks/${x.number}`, where: `${x.repository} task #${x.number}`, note: `${TASK_TEXT[x.state].label} · ${x.agent}`, mark: x.state === "failed" ? "fail" : x.state === "ready" ? "ok" : "dot" })), | |
| 42 | + ), | |
| 43 | + section( | |
| 44 | + "failures", | |
| 45 | + "Failed runs", | |
| 46 | + t.failures.map((x) => ({ title: short(x.run.title || x.run.workflow), url: `${base}${x.repo.url}/actions/runs/${x.run.id}`, where: x.repo.fullName, note: `${x.run.workflow} on ${x.run.ref.replace(/^refs\/heads\//, "")} · ${x.run.actor.login}`, mark: "fail" })), | |
| 47 | + ), | |
| 48 | + section( | |
| 49 | + "landed", | |
| 50 | + "Merged", | |
| 51 | + t.landed.map((x) => ({ title: short(x.pull.title), ...pull(x.repo, x.pull.number), note: `by ${x.pull.author.login}${x.pull.author.kind === "agent" ? " (agent)" : ""}${x.pull.mergedBy ? `, merged by ${x.pull.mergedBy}` : ""}`, mark: "ok" })), | |
| 52 | + ), | |
| 53 | + section( | |
| 54 | + "releases", | |
| 55 | + "Released", | |
| 56 | + t.releases.map((x) => ({ title: short(x.release.name || x.release.tag), url: `${base}${x.repo.url}/releases/tag/${encodeURIComponent(x.release.tag)}`, where: x.repo.fullName, note: x.release.prerelease ? "Pre-release" : x.release.tag, mark: "ok" })), | |
| 57 | + ), | |
| 58 | + ].filter((s) => s.total > 0); | |
| 59 | + return { | |
| 60 | + date, | |
| 61 | + counts: { needsYou: t.needsYou.length + t.deployments.length, agents: working.length, merged: t.landed.length, failed: t.failures.length }, | |
| 62 | + sections, | |
| 63 | + repositories: t.repositories, | |
| 64 | + incomplete: t.incomplete, | |
| 65 | + }; | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** Gather one person's digest now: Today since `since`, dated in their time zone. */ | |
| 69 | +export async function gatherDigest(env: Env, dir: Directory, me: Account, since: string, timeZone: string, at = new Date()): Promise<DigestSummary> { | |
| 70 | + const today = await gatherToday(env, dir, me, { since, now: at }); | |
| 71 | + return digestSummary(env.PUBLIC_URL, today, localDate(timeZone, at)); | |
| 72 | +} |
modifiedapps/diffinery/src/agentic/today.ts+29−2View file
| @@ -64,6 +64,32 @@ | ||
| 64 | 64 | tasks: MyTask[]; |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | +/** Labels and badge tones, shared by the Today page (web.tsx) and the morning digest email (digest.ts). */ | |
| 68 | +export const NEEDS_TEXT: Record<NeedsKind, { label: string; tone: string }> = { | |
| 69 | + review_requested: { label: "Review requested", tone: "blue" }, | |
| 70 | + agent_approval: { label: "Agent PR waiting for a person", tone: "purple" }, | |
| 71 | + changes_requested: { label: "Changes requested", tone: "amber" }, | |
| 72 | + checks_failing: { label: "Checks failing", tone: "red" }, | |
| 73 | +}; | |
| 74 | + | |
| 75 | +export const AGENT_TEXT: Record<AgentPullState, { label: string; tone: string }> = { | |
| 76 | + draft: { label: "Draft", tone: "" }, | |
| 77 | + landing: { label: "Landing", tone: "green" }, | |
| 78 | + checks_failing: { label: "Checks failing", tone: "red" }, | |
| 79 | + changes_requested: { label: "Changes requested", tone: "amber" }, | |
| 80 | + approved: { label: "Approved", tone: "green" }, | |
| 81 | + checks_running: { label: "Checks running", tone: "blue" }, | |
| 82 | + waiting_for_approval: { label: "Waiting for approval", tone: "purple" }, | |
| 83 | +}; | |
| 84 | + | |
| 85 | +export const TASK_TEXT: Record<MyTask["state"], { label: string; tone: string }> = { | |
| 86 | + queued: { label: "Queued", tone: "" }, | |
| 87 | + running: { label: "Running", tone: "blue" }, | |
| 88 | + ready: { label: "Ready", tone: "green" }, | |
| 89 | + failed: { label: "Failed", tone: "red" }, | |
| 90 | + stopped: { label: "Stopped", tone: "" }, | |
| 91 | +}; | |
| 92 | + | |
| 67 | 93 | export interface ClassifyInput { |
| 68 | 94 | me: { id: string; login: string }; |
| 69 | 95 | /** Agents the viewer or their organizations own (and the viewer itself, when an agent asks). */ |
| @@ -155,9 +181,10 @@ | ||
| 155 | 181 | * Gather Today for one account (a person, or an agent asking over the API or MCP). |
| 156 | 182 | * `repoIds` limits it to the repositories a token is restricted to. |
| 157 | 183 | */ |
| 158 | -export async function gatherToday(env: Env, dir: Directory, me: Account, opts: { repoIds?: string[] | null; now?: Date } = {}): Promise<Today> { | |
| 184 | +export async function gatherToday(env: Env, dir: Directory, me: Account, opts: { repoIds?: string[] | null; now?: Date; since?: string } = {}): Promise<Today> { | |
| 159 | 185 | const now = opts.now ?? new Date(); |
| 160 | - const since = new Date(now.getTime() - SINCE_HOURS * 3600_000).toISOString(); | |
| 186 | + // The morning digest passes its own start: the last digest, which can be days ago after a weekend. | |
| 187 | + const since = opts.since ?? new Date(now.getTime() - SINCE_HOURS * 3600_000).toISOString(); | |
| 161 | 188 | const cutoff = new Date(now.getTime() - ACTIVE_DAYS * 86400_000).toISOString(); |
| 162 | 189 | const [visible, activityRows, orgs] = await Promise.all([ |
| 163 | 190 | dir.reposVisibleTo(me), |
modifiedapps/diffinery/src/agentic/web.tsx+1−26View file
| @@ -3,29 +3,12 @@ | ||
| 3 | 3 | import type { FC } from "hono/jsx"; |
| 4 | 4 | import { ICONS } from "../web/assets.js"; |
| 5 | 5 | import { Actor, Status, Time } from "../web/ui.js"; |
| 6 | -import type { AgentItem, AgentPullState, NeedsItem, NeedsKind, Today } from "./today.js"; | |
| 6 | +import { AGENT_TEXT, NEEDS_TEXT, TASK_TEXT, type AgentItem, type NeedsItem, type Today } from "./today.js"; | |
| 7 | 7 | import type { TodayPull } from "./today-repo.js"; |
| 8 | 8 | import type { MyTask } from "./tasks.js"; |
| 9 | 9 | |
| 10 | 10 | /** The Today view's sections on the dashboard (web/account.tsx): what needs you, your agents, and the last day. */ |
| 11 | 11 | |
| 12 | -const NEEDS_TEXT: Record<NeedsKind, { label: string; tone: string }> = { | |
| 13 | - review_requested: { label: "Review requested", tone: "blue" }, | |
| 14 | - agent_approval: { label: "Agent PR waiting for a person", tone: "purple" }, | |
| 15 | - changes_requested: { label: "Changes requested", tone: "amber" }, | |
| 16 | - checks_failing: { label: "Checks failing", tone: "red" }, | |
| 17 | -}; | |
| 18 | - | |
| 19 | -const AGENT_TEXT: Record<AgentPullState, { label: string; tone: string }> = { | |
| 20 | - draft: { label: "Draft", tone: "" }, | |
| 21 | - landing: { label: "Landing", tone: "green" }, | |
| 22 | - checks_failing: { label: "Checks failing", tone: "red" }, | |
| 23 | - changes_requested: { label: "Changes requested", tone: "amber" }, | |
| 24 | - approved: { label: "Approved", tone: "green" }, | |
| 25 | - checks_running: { label: "Checks running", tone: "blue" }, | |
| 26 | - waiting_for_approval: { label: "Waiting for approval", tone: "purple" }, | |
| 27 | -}; | |
| 28 | - | |
| 29 | 12 | const ChecksIcon: FC<{ p: TodayPull }> = ({ p }) => |
| 30 | 13 | p.checks === "none" ? ( |
| 31 | 14 | <span class="st faint" title="No checks">{raw(ICONS.pr)}</span> |
| @@ -90,14 +73,6 @@ | ||
| 90 | 73 | ); |
| 91 | 74 | }; |
| 92 | 75 | |
| 93 | -const TASK_TEXT: Record<MyTask["state"], { label: string; tone: string }> = { | |
| 94 | - queued: { label: "Queued", tone: "" }, | |
| 95 | - running: { label: "Running", tone: "blue" }, | |
| 96 | - ready: { label: "Ready", tone: "green" }, | |
| 97 | - failed: { label: "Failed", tone: "red" }, | |
| 98 | - stopped: { label: "Stopped", tone: "" }, | |
| 99 | -}; | |
| 100 | - | |
| 101 | 76 | const Tasks: FC<{ items: MyTask[] }> = ({ items }) => ( |
| 102 | 77 | <div class="box" id="tasks"> |
| 103 | 78 | <div class="head"> |
modifiedapps/diffinery/src/mail/api.ts+15−2View file
| @@ -5,11 +5,11 @@ | ||
| 5 | 5 | import type { Account } from "../lib/directory.js"; |
| 6 | 6 | import { loadRepo } from "../http/common.js"; |
| 7 | 7 | import { HttpError, now } from "../lib/util.js"; |
| 8 | -import { MailStore, mergePrefs, type WatchLevel } from "./prefs.js"; | |
| 8 | +import { MailStore, isDigestHour, isTimeZone, mergePrefs, type WatchLevel } from "./prefs.js"; | |
| 9 | 9 | import { sampleJobs } from "./samples.js"; |
| 10 | 10 | import { deliver, senderAddress } from "./send.js"; |
| 11 | 11 | import { Suppressions } from "./suppression.js"; |
| 12 | -import { adminSuppress, adminUnsuppress, sendTest, sendVerification, suppressionJson } from "./web.js"; | |
| 12 | +import { adminSuppress, adminUnsuppress, sendDigestNow, sendTest, sendVerification, suppressionJson } from "./web.js"; | |
| 13 | 13 | |
| 14 | 14 | /** |
| 15 | 15 | * Email over the REST API: repository watching in GitHub's shape |
| @@ -99,6 +99,12 @@ | ||
| 99 | 99 | if (base === undefined) throw new HttpError(422, `unknown setting ${k}`); |
| 100 | 100 | merged[k] = base && typeof base === "object" && v && typeof v === "object" ? { ...base, ...v } : v; |
| 101 | 101 | } |
| 102 | + // mergePrefs drops what it doesn't know; say so instead of saving something else. | |
| 103 | + const d = b.digest as Record<string, unknown> | undefined; | |
| 104 | + if (d && typeof d === "object") { | |
| 105 | + if ("hour" in d && !isDigestHour(d.hour)) throw new HttpError(422, "digest.hour must be a whole number from 0 to 23"); | |
| 106 | + if ("timeZone" in d && !isTimeZone(d.timeZone)) throw new HttpError(422, "digest.timeZone must be an IANA time zone, such as Europe/Istanbul or UTC"); | |
| 107 | + } | |
| 102 | 108 | const next = mergePrefs(merged); |
| 103 | 109 | await store.setPrefs(me.id, next); |
| 104 | 110 | return c.json(next); |
| @@ -126,6 +132,13 @@ | ||
| 126 | 132 | return c.json(await sendTest(c, me)); |
| 127 | 133 | }); |
| 128 | 134 | |
| 135 | +/** Your morning digest, now (digest.ts): sent even when it has nothing in it or is turned off. */ | |
| 136 | +mailApi.post("/diffinery/emails/digest", async (c) => { | |
| 137 | + requireAccountScope(c, "write"); | |
| 138 | + const me = writer(c); | |
| 139 | + return c.json(await sendDigestNow(c, me)); | |
| 140 | +}); | |
| 141 | + | |
| 129 | 142 | /** Your address, and whether it is on the suppression list (suppression.ts). */ |
| 130 | 143 | mailApi.get("/diffinery/email", async (c) => { |
| 131 | 144 | const me = user(c); |
addedapps/diffinery/src/mail/digest.ts+118−0View file
| @@ -1,0 +1,118 @@ | ||
| 1 | +import type { DigestPrefs, NotificationPrefs } from "./prefs.js"; | |
| 2 | +import type { MailJob } from "./types.js"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The morning digest's schedule (phase 5c). A person turns the digest on in their notification | |
| 6 | + * settings and picks a local hour, a time zone and whether weekends count. Saving those settings | |
| 7 | + * writes the next send time to `mail_digests`; the five-minute cron queues a `digest` job for every | |
| 8 | + * row that is due and moves it on to the next day. The job is an ordinary email: it goes through | |
| 9 | + * deliver() in send.ts, so the recipient's settings, the suppression list and the one-click | |
| 10 | + * unsubscribe header all apply. Today itself is gathered when the job is sent (agentic/digest.ts). | |
| 11 | + */ | |
| 12 | + | |
| 13 | +/** Digests queued per cron run; the rest wait for the next run, five minutes later. */ | |
| 14 | +export const DIGESTS_PER_RUN = 200; | |
| 15 | +/** "Since the last digest" never reaches back further than this. */ | |
| 16 | +export const MAX_SINCE_DAYS = 7; | |
| 17 | +/** Without a last digest (or after a long gap) the digest covers this many hours. */ | |
| 18 | +export const DEFAULT_SINCE_HOURS = 24; | |
| 19 | + | |
| 20 | +interface Local { | |
| 21 | + y: number; | |
| 22 | + m: number; | |
| 23 | + d: number; | |
| 24 | + h: number; | |
| 25 | + mi: number; | |
| 26 | + s: number; | |
| 27 | +} | |
| 28 | + | |
| 29 | +const formatters = new Map<string, Intl.DateTimeFormat>(); | |
| 30 | +function local(tz: string, t: number): Local { | |
| 31 | + let f = formatters.get(tz); | |
| 32 | + if (!f) { | |
| 33 | + f = new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" }); | |
| 34 | + formatters.set(tz, f); | |
| 35 | + } | |
| 36 | + const p: Record<string, number> = {}; | |
| 37 | + for (const x of f.formatToParts(new Date(t))) if (x.type !== "literal") p[x.type] = Number(x.value); | |
| 38 | + return { y: p.year!, m: p.month!, d: p.day!, h: p.hour! % 24, mi: p.minute!, s: p.second! }; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** How far the zone is ahead of UTC at instant t, in milliseconds. */ | |
| 42 | +function offsetAt(tz: string, t: number): number { | |
| 43 | + const l = local(tz, t); | |
| 44 | + return Date.UTC(l.y, l.m - 1, l.d, l.h, l.mi, l.s) - Math.floor(t / 1000) * 1000; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** The instant a local wall-clock hour happens on a local date. A time skipped by a clock change comes out an hour later. */ | |
| 48 | +function atLocal(tz: string, y: number, m: number, d: number, h: number): number { | |
| 49 | + const wall = Date.UTC(y, m - 1, d, h); | |
| 50 | + let t = wall - offsetAt(tz, wall); | |
| 51 | + const again = offsetAt(tz, t); | |
| 52 | + if (wall - again !== t) t = wall - again; | |
| 53 | + return t; | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** When the next digest goes out after `after`: the chosen local hour on the next day that counts. */ | |
| 57 | +export function nextDigestAt(p: Pick<DigestPrefs, "hour" | "timeZone" | "weekends">, after: Date): Date { | |
| 58 | + const today = local(p.timeZone, after.getTime()); | |
| 59 | + for (let i = 0; i < 10; i++) { | |
| 60 | + const day = new Date(Date.UTC(today.y, today.m - 1, today.d + i)); | |
| 61 | + const weekday = day.getUTCDay(); | |
| 62 | + if (!p.weekends && (weekday === 0 || weekday === 6)) continue; | |
| 63 | + const t = atLocal(p.timeZone, day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), p.hour); | |
| 64 | + if (t > after.getTime()) return new Date(t); | |
| 65 | + } | |
| 66 | + // Unreachable: some day in the next ten is a weekday with the hour still ahead. | |
| 67 | + return new Date(after.getTime() + 86400_000); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** The local date for the digest's title, e.g. "Wednesday, October 14". */ | |
| 71 | +export function localDate(tz: string, at: Date): string { | |
| 72 | + return at.toLocaleDateString("en-US", { timeZone: tz, weekday: "long", month: "long", day: "numeric" }); | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** Keep the schedule in step with someone's settings: a row while the digest is on, none while it is off. */ | |
| 76 | +export async function syncDigest(db: D1Database, accountId: string, prefs: NotificationPrefs, at = new Date()): Promise<void> { | |
| 77 | + if (!prefs.digest.email) { | |
| 78 | + await db.prepare("DELETE FROM mail_digests WHERE account_id = ?").bind(accountId).run(); | |
| 79 | + return; | |
| 80 | + } | |
| 81 | + await db | |
| 82 | + .prepare("INSERT INTO mail_digests (account_id, next_at) VALUES (?, ?) ON CONFLICT(account_id) DO UPDATE SET next_at = excluded.next_at") | |
| 83 | + .bind(accountId, nextDigestAt(prefs.digest, at).toISOString()) | |
| 84 | + .run(); | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** Where a digest sent at `at` starts: the last one, if it was within MAX_SINCE_DAYS, else DEFAULT_SINCE_HOURS ago. */ | |
| 88 | +export function digestSince(lastSentAt: string | null, at: Date): string { | |
| 89 | + const floor = new Date(at.getTime() - MAX_SINCE_DAYS * 86400_000).toISOString(); | |
| 90 | + if (lastSentAt && lastSentAt >= floor && lastSentAt < at.toISOString()) return lastSentAt; | |
| 91 | + return new Date(at.getTime() - DEFAULT_SINCE_HOURS * 3600_000).toISOString(); | |
| 92 | +} | |
| 93 | + | |
| 94 | +/** | |
| 95 | + * From the cron: queue the digests that are due and move each row to its next send. A row moves | |
| 96 | + * only if no other cron run moved it first, so overlapping runs never send one twice. | |
| 97 | + */ | |
| 98 | +export async function digestCron(db: D1Database, at: Date, prefsFor: (ids: string[]) => Promise<Map<string, NotificationPrefs>>, send: (jobs: MailJob[]) => Promise<void>): Promise<number> { | |
| 99 | + const now = at.toISOString(); | |
| 100 | + const due = ( | |
| 101 | + await db.prepare("SELECT account_id, next_at, last_sent_at FROM mail_digests WHERE next_at <= ? ORDER BY next_at LIMIT ?").bind(now, DIGESTS_PER_RUN).all<{ account_id: string; next_at: string; last_sent_at: string | null }>() | |
| 102 | + ).results; | |
| 103 | + if (!due.length) return 0; | |
| 104 | + const prefs = await prefsFor(due.map((r) => r.account_id)); | |
| 105 | + const jobs: MailJob[] = []; | |
| 106 | + for (const r of due) { | |
| 107 | + const p = prefs.get(r.account_id); | |
| 108 | + if (!p?.digest.email) { | |
| 109 | + await db.prepare("DELETE FROM mail_digests WHERE account_id = ? AND next_at = ?").bind(r.account_id, r.next_at).run(); | |
| 110 | + continue; | |
| 111 | + } | |
| 112 | + const moved = await db.prepare("UPDATE mail_digests SET next_at = ?, last_sent_at = ? WHERE account_id = ? AND next_at = ?").bind(nextDigestAt(p.digest, at).toISOString(), now, r.account_id, r.next_at).run(); | |
| 113 | + if (!moved.meta.changes) continue; | |
| 114 | + jobs.push({ kind: "digest", data: { since: digestSince(r.last_sent_at, at) }, to: { accountId: r.account_id }, reason: "digest", at: now }); | |
| 115 | + } | |
| 116 | + await send(jobs); | |
| 117 | + return jobs.length; | |
| 118 | +} |
modifiedapps/diffinery/src/mail/notify.ts+3−1View file
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | import { sha256 } from "../lib/util.js"; |
| 24 | 24 | import { blockersOf } from "../lib/moderation.js"; |
| 25 | 25 | import { MailStore, type NotificationPrefs } from "./prefs.js"; |
| 26 | +import { digestCron } from "./digest.js"; | |
| 26 | 27 | import { enqueueMail } from "./send.js"; |
| 27 | 28 | import type { JobInfo, RunInfo } from "../repo/actions.js"; |
| 28 | 29 | import type { MailJob, Reason, ThreadData } from "./types.js"; |
| @@ -395,7 +396,7 @@ | ||
| 395 | 396 | await enqueueMail(env, jobs); |
| 396 | 397 | } |
| 397 | 398 | |
| 398 | -/** Daily-ish jobs from the cron trigger: token expiry reminders and log pruning. */ | |
| 399 | +/** Jobs from the five-minute cron trigger: token expiry reminders, morning digests that are due, and log pruning. */ | |
| 399 | 400 | export async function mailCron(env: Env, dir: Directory, at: Date): Promise<void> { |
| 400 | 401 | const store = new MailStore(env.DB); |
| 401 | 402 | const now = at.toISOString(); |
| @@ -415,5 +416,6 @@ | ||
| 415 | 416 | await env.DB.prepare("UPDATE tokens SET expiry_notified_at = ? WHERE id = ?").bind(now, t.id).run(); |
| 416 | 417 | } |
| 417 | 418 | await enqueueMail(env, jobs); |
| 419 | + await digestCron(env.DB, at, (ids) => store.prefsFor(ids), (digests) => enqueueMail(env, digests)); | |
| 418 | 420 | if (at.getUTCHours() === 3 && at.getUTCMinutes() < 5) await store.prune(); |
| 419 | 421 | } |
modifiedapps/diffinery/src/mail/prefs.ts+55−1View file
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { newId, now } from "../lib/util.js"; |
| 2 | +import { syncDigest } from "./digest.js"; | |
| 2 | 3 | import type { Category } from "./types.js"; |
| 3 | 4 | |
| 4 | 5 | /** Per-person notification settings. Missing keys fall back to DEFAULT_PREFS. */ |
| @@ -17,8 +18,33 @@ | ||
| 17 | 18 | ownActivity: boolean; |
| 18 | 19 | /** Watch repositories I create. */ |
| 19 | 20 | autoWatch: boolean; |
| 21 | + /** The morning digest (phase 5c): Today by email, at a local hour. Off unless the person turns it on. */ | |
| 22 | + digest: DigestPrefs; | |
| 20 | 23 | } |
| 21 | 24 | |
| 25 | +export interface DigestPrefs { | |
| 26 | + email: boolean; | |
| 27 | + /** Local hour, 0 to 23. */ | |
| 28 | + hour: number; | |
| 29 | + /** IANA time zone, e.g. Europe/Istanbul. */ | |
| 30 | + timeZone: string; | |
| 31 | + /** Also on Saturdays and Sundays. */ | |
| 32 | + weekends: boolean; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** Whether a string is a time zone this runtime knows. */ | |
| 36 | +export function isTimeZone(tz: unknown): tz is string { | |
| 37 | + if (typeof tz !== "string" || !tz || tz.length > 64) return false; | |
| 38 | + try { | |
| 39 | + new Intl.DateTimeFormat("en-US", { timeZone: tz }); | |
| 40 | + return true; | |
| 41 | + } catch { | |
| 42 | + return false; | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +export const isDigestHour = (h: unknown): h is number => typeof h === "number" && Number.isInteger(h) && h >= 0 && h <= 23; | |
| 47 | + | |
| 22 | 48 | export const DEFAULT_PREFS: NotificationPrefs = { |
| 23 | 49 | participating: { web: true, email: true }, |
| 24 | 50 | watching: { web: true, email: true }, |
| @@ -27,6 +53,7 @@ | ||
| 27 | 53 | orgs: { email: true }, |
| 28 | 54 | ownActivity: false, |
| 29 | 55 | autoWatch: true, |
| 56 | + digest: { email: false, hour: 8, timeZone: "UTC", weekends: false }, | |
| 30 | 57 | }; |
| 31 | 58 | |
| 32 | 59 | export function mergePrefs(stored: unknown): NotificationPrefs { |
| @@ -45,9 +72,21 @@ | ||
| 45 | 72 | orgs: pick(DEFAULT_PREFS.orgs, s.orgs), |
| 46 | 73 | ownActivity: typeof s.ownActivity === "boolean" ? s.ownActivity : DEFAULT_PREFS.ownActivity, |
| 47 | 74 | autoWatch: typeof s.autoWatch === "boolean" ? s.autoWatch : DEFAULT_PREFS.autoWatch, |
| 75 | + digest: mergeDigest(s.digest), | |
| 48 | 76 | }; |
| 49 | 77 | } |
| 50 | 78 | |
| 79 | +function mergeDigest(v: unknown): DigestPrefs { | |
| 80 | + const d = (v && typeof v === "object" ? v : {}) as Record<string, unknown>; | |
| 81 | + const def = DEFAULT_PREFS.digest; | |
| 82 | + return { | |
| 83 | + email: typeof d.email === "boolean" ? d.email : def.email, | |
| 84 | + hour: isDigestHour(d.hour) ? d.hour : def.hour, | |
| 85 | + timeZone: isTimeZone(d.timeZone) ? d.timeZone : def.timeZone, | |
| 86 | + weekends: typeof d.weekends === "boolean" ? d.weekends : def.weekends, | |
| 87 | + }; | |
| 88 | +} | |
| 89 | + | |
| 51 | 90 | /** Whether a settings group wants email (security and account emails always send). */ |
| 52 | 91 | export function wantsEmail(p: NotificationPrefs, c: Category): boolean { |
| 53 | 92 | switch (c) { |
| @@ -64,6 +103,8 @@ | ||
| 64 | 103 | return p.repos.email; |
| 65 | 104 | case "orgs": |
| 66 | 105 | return p.orgs.email; |
| 106 | + case "digest": | |
| 107 | + return p.digest.email; | |
| 67 | 108 | } |
| 68 | 109 | } |
| 69 | 110 | |
| @@ -114,13 +155,26 @@ | ||
| 114 | 155 | return out; |
| 115 | 156 | } |
| 116 | 157 | |
| 158 | + /** Save someone's settings, and move their morning digest's schedule to match (digest.ts). */ | |
| 117 | 159 | async setPrefs(accountId: string, prefs: NotificationPrefs): Promise<void> { |
| 160 | + const p = mergePrefs(prefs); | |
| 118 | 161 | await this.db |
| 119 | 162 | .prepare("INSERT INTO notification_prefs (account_id, prefs, updated_at) VALUES (?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET prefs = excluded.prefs, updated_at = excluded.updated_at") |
| 120 | - .bind(accountId, JSON.stringify(mergePrefs(prefs)), now()) | |
| 163 | + .bind(accountId, JSON.stringify(p), now()) | |
| 121 | 164 | .run(); |
| 165 | + await syncDigest(this.db, accountId, p); | |
| 122 | 166 | } |
| 123 | 167 | |
| 168 | + /** When the morning digest goes out next, if it is on (digest.ts). */ | |
| 169 | + async digestNext(accountId: string): Promise<string | null> { | |
| 170 | + return (await this.db.prepare("SELECT next_at FROM mail_digests WHERE account_id = ?").bind(accountId).first<{ next_at: string }>())?.next_at ?? null; | |
| 171 | + } | |
| 172 | + | |
| 173 | + /** When the last scheduled digest was queued. */ | |
| 174 | + async digestLastSent(accountId: string): Promise<string | null> { | |
| 175 | + return (await this.db.prepare("SELECT last_sent_at FROM mail_digests WHERE account_id = ?").bind(accountId).first<{ last_sent_at: string | null }>())?.last_sent_at ?? null; | |
| 176 | + } | |
| 177 | + | |
| 124 | 178 | async watchLevel(accountId: string, repoId: string): Promise<WatchLevel> { |
| 125 | 179 | const r = await this.db.prepare("SELECT level FROM repo_watches WHERE account_id = ? AND repo_id = ?").bind(accountId, repoId).first<{ level: WatchLevel }>(); |
| 126 | 180 | return r?.level ?? "participating"; |
modifiedapps/diffinery/src/mail/samples.ts+36−0View file
| @@ -95,6 +95,42 @@ | ||
| 95 | 95 | item("repo-created", "Repository created", { kind: "repo_created", reason: "your_activity", data: { repo, creator: me, visibility: "private", defaultBranch: "main", description: "Marketing site" } }), |
| 96 | 96 | item("repo-deleted", "Repository deleted", { kind: "repo_deleted", reason: "admin", data: { repo, actor: "kim" } }), |
| 97 | 97 | item("repo-transferred", "Repository transferred", { kind: "repo_transferred", reason: "admin", data: { from: `${me}/${repo.split("/")[1]}`, to: repo, actor: "kim" } }), |
| 98 | + item("digest", "Morning digest", { | |
| 99 | + kind: "digest", | |
| 100 | + reason: "digest", | |
| 101 | + data: { | |
| 102 | + since: new Date(Date.now() - 86400_000).toISOString(), | |
| 103 | + summary: { | |
| 104 | + date: new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" }), | |
| 105 | + counts: { needsYou: 2, agents: 1, merged: 3, failed: 1 }, | |
| 106 | + repositories: 4, | |
| 107 | + incomplete: false, | |
| 108 | + sections: [ | |
| 109 | + { | |
| 110 | + id: "needs_you", | |
| 111 | + title: "Needs you", | |
| 112 | + total: 2, | |
| 113 | + items: [ | |
| 114 | + { title: "Cache build artifacts between workflow runs", url: `${pub}/${repo}/pull/42`, where: `${repo}#42`, note: "Review requested · by diffy-dev", mark: "dot" }, | |
| 115 | + { title: "Retry flaky upload step", url: `${pub}/${repo}/pull/44`, where: `${repo}#44`, note: "Agent PR waiting for a person · by fixer-bot", mark: "dot" }, | |
| 116 | + ], | |
| 117 | + }, | |
| 118 | + { id: "agents", title: "Agents at work", total: 1, items: [{ title: "Retry flaky upload step", url: `${pub}/${repo}/pull/44`, where: `${repo}#44`, note: "Waiting for approval · fixer-bot", mark: "dot" }] }, | |
| 119 | + { id: "failures", title: "Failed runs", total: 1, items: [{ title: "Nightly build", url: `${pub}/${repo}/actions/runs/1`, where: repo, note: "nightly on main · " + me, mark: "fail" }] }, | |
| 120 | + { | |
| 121 | + id: "landed", | |
| 122 | + title: "Merged", | |
| 123 | + total: 3, | |
| 124 | + items: [ | |
| 125 | + { title: "Bump the runner image", url: `${pub}/${repo}/pull/40`, where: `${repo}#40`, note: "by fixer-bot (agent), merged by " + me, mark: "ok" }, | |
| 126 | + { title: "Docs: caching", url: `${pub}/${repo}/pull/41`, where: `${repo}#41`, note: "by rivera", mark: "ok" }, | |
| 127 | + { title: "Fix the release notes link", url: `${pub}/${repo}/pull/39`, where: `${repo}#39`, note: "by " + me, mark: "ok" }, | |
| 128 | + ], | |
| 129 | + }, | |
| 130 | + ], | |
| 131 | + }, | |
| 132 | + }, | |
| 133 | + }), | |
| 98 | 134 | item("test", "Test email", { kind: "test", reason: "account", data: { by: me } }), |
| 99 | 135 | ]; |
| 100 | 136 | } |
modifiedapps/diffinery/src/mail/send.ts+16−1View file
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { devAuth } from "../auth/dev.js"; |
| 2 | 2 | import type { Env } from "../env.js"; |
| 3 | -import { Directory } from "../lib/directory.js"; | |
| 3 | +import { Directory, type Account } from "../lib/directory.js"; | |
| 4 | 4 | import { MailStore, wantsEmail } from "./prefs.js"; |
| 5 | 5 | import { compose, toHtml, toText, type RenderCtx } from "./templates.js"; |
| 6 | 6 | import { categoryOf, isMandatory, type MailJob } from "./types.js"; |
| @@ -115,6 +115,7 @@ | ||
| 115 | 115 | const store = new MailStore(env.DB); |
| 116 | 116 | const category = categoryOf(job); |
| 117 | 117 | let to: { email: string; name?: string; login?: string; accountId?: string }; |
| 118 | + let account: Account | null = null; | |
| 118 | 119 | if ("accountId" in job.to) { |
| 119 | 120 | const accountId = job.to.accountId; |
| 120 | 121 | const a = await dir.account(accountId); |
| @@ -128,6 +129,7 @@ | ||
| 128 | 129 | if (a.status === "disabled" && job.kind !== "account_status") return skip("account disabled"); |
| 129 | 130 | if (!job.force && !isMandatory(category) && !wantsEmail(await store.prefs(a.id), category)) return skip(`turned off ${category} emails`); |
| 130 | 131 | to = { email: a.email, name: a.name || undefined, login: a.login, accountId: a.id }; |
| 132 | + account = a; | |
| 131 | 133 | } else { |
| 132 | 134 | to = { email: job.to.email, name: job.to.name }; |
| 133 | 135 | } |
| @@ -140,6 +142,19 @@ | ||
| 140 | 142 | } |
| 141 | 143 | } |
| 142 | 144 | |
| 145 | + // The morning digest is gathered now, as it is sent (agentic/digest.ts), and a scheduled one with nothing in it isn't sent. | |
| 146 | + if (job.kind === "digest" && !job.data.summary) { | |
| 147 | + if (!account) return { status: "skipped", detail: "a digest needs an account" }; | |
| 148 | + const { gatherDigest } = await import("../agentic/digest.js"); | |
| 149 | + const summary = await gatherDigest(env, dir, account, job.data.since, (await store.prefs(account.id)).digest.timeZone); | |
| 150 | + if (!summary.sections.length && !job.data.now) { | |
| 151 | + const detail = "nothing to report since the last digest"; | |
| 152 | + await store.log({ account_id: account.id, to_addr: to.email, kind: job.kind, subject: "", status: "skipped", detail, message_id: null, dev_copy: null }); | |
| 153 | + return { status: "skipped", detail }; | |
| 154 | + } | |
| 155 | + job = { ...job, data: { ...job.data, summary } }; | |
| 156 | + } | |
| 157 | + | |
| 143 | 158 | const msg = await buildMessage(env, job, to); |
| 144 | 159 | const devCopy = devAuth(env) ? JSON.stringify(msg) : null; |
| 145 | 160 | const base = { account_id: to.accountId ?? null, to_addr: to.email, kind: job.kind, subject: msg.subject, dev_copy: devCopy }; |
modifiedapps/diffinery/src/mail/templates.ts+35−1View file
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * neutral palette, blue for actions and the orange mark. |
| 6 | 6 | */ |
| 7 | 7 | import { escapeHtml, renderMarkdown } from "../web/markdown.js"; |
| 8 | -import type { MailJob, Reason, RunData, ThreadData } from "./types.js"; | |
| 8 | +import type { DigestData, MailJob, Reason, RunData, ThreadData } from "./types.js"; | |
| 9 | 9 | |
| 10 | 10 | export type Inline = string | { b: string } | { a: string; href: string } | { code: string }; |
| 11 | 11 | |
| @@ -166,6 +166,7 @@ | ||
| 166 | 166 | admin: "You are receiving this because you are an administrator.", |
| 167 | 167 | account: "You are receiving this because you have a Diffinery account.", |
| 168 | 168 | signup: "You are receiving this because someone asked to create a Diffinery account with this email address. If it wasn't you, you can ignore this email.", |
| 169 | + digest: "You are receiving this because you turned on the morning digest in your notification settings.", | |
| 169 | 170 | }; |
| 170 | 171 | |
| 171 | 172 | const LOGO = (url: string) => `${url}/icon-512.png`; |
| @@ -860,6 +861,8 @@ | ||
| 860 | 861 | url, |
| 861 | 862 | }; |
| 862 | 863 | } |
| 864 | + case "digest": | |
| 865 | + return digestEmail(job.data, pub); | |
| 863 | 866 | case "test": |
| 864 | 867 | return { |
| 865 | 868 | subject: "[Diffinery] Test email", |
| @@ -871,3 +874,34 @@ | ||
| 871 | 874 | }; |
| 872 | 875 | } |
| 873 | 876 | } |
| 877 | + | |
| 878 | +// ---------------------------------------------------------------- the morning digest (phase 5c) | |
| 879 | + | |
| 880 | +/** Today by email: what needs you, your agents, and what happened since the last digest (agentic/digest.ts). */ | |
| 881 | +function digestEmail(d: DigestData, pub: string): Email { | |
| 882 | + const today = `${pub}/`; | |
| 883 | + const s = d.summary; | |
| 884 | + if (!s) { | |
| 885 | + // deliver() always fills the summary in; this only shows if something sends the job without it. | |
| 886 | + return { subject: "[Diffinery] Your morning digest", preheader: "Open Today on Diffinery.", eyebrow: { text: "Today", tone: "blue" }, title: ["Your morning digest"], blocks: [{ button: { label: "Open Today", url: today } }], url: today }; | |
| 887 | + } | |
| 888 | + const c = s.counts; | |
| 889 | + const parts = [c.needsYou ? `${c.needsYou} need${c.needsYou === 1 ? "s" : ""} you` : "", c.agents ? plural(c.agents, "agent pull request") : "", c.merged ? `${c.merged} merged` : "", c.failed ? plural(c.failed, "failed run") : ""].filter(Boolean); | |
| 890 | + const headline = parts.length ? parts.join(", ") : "nothing needs you"; | |
| 891 | + const blocks: Block[] = []; | |
| 892 | + if (!s.sections.length) blocks.push({ p: ["Nothing needs you, your agents have no open pull requests, and nothing merged, released or failed since the last digest."] }); | |
| 893 | + for (const sec of s.sections) { | |
| 894 | + blocks.push({ p: [{ b: sec.title }, sec.total > sec.items.length ? ` · ${sec.items.length} of ${sec.total}` : ` · ${sec.total}`] }); | |
| 895 | + blocks.push({ items: sec.items.map((it) => ({ mark: it.mark, text: [{ b: it.title }, ` ${it.where} · ${it.note}`], url: it.url })) }); | |
| 896 | + } | |
| 897 | + blocks.push({ button: { label: "Open Today", url: today } }); | |
| 898 | + blocks.push({ p: [`Since ${at(d.since)}, across ${plural(s.repositories, "active repository", "active repositories")}${s.incomplete ? " (some were left out; Today shows the most recently active)" : ""}.`], muted: true }); | |
| 899 | + return { | |
| 900 | + subject: `[Diffinery] Today: ${headline}`, | |
| 901 | + preheader: s.sections.length ? s.sections.map((x) => `${x.title}: ${x.total}`).join(" · ") : "A quiet morning.", | |
| 902 | + eyebrow: { text: s.date, tone: c.needsYou ? "purple" : "blue" }, | |
| 903 | + title: [c.needsYou ? `${c.needsYou} ${c.needsYou === 1 ? "thing needs" : "things need"} you today` : "Your morning digest"], | |
| 904 | + blocks, | |
| 905 | + url: today, | |
| 906 | + }; | |
| 907 | +} |
modifiedapps/diffinery/src/mail/types.ts+43−3View file
| @@ -20,13 +20,14 @@ | ||
| 20 | 20 | | "invitation" |
| 21 | 21 | | "admin" |
| 22 | 22 | | "account" |
| 23 | - | "signup"; | |
| 23 | + | "signup" | |
| 24 | + | "digest"; | |
| 24 | 25 | |
| 25 | 26 | /** |
| 26 | 27 | * Settings groups. `security` and `account` always send: they protect the |
| 27 | 28 | * account or are the only way to reach someone (invites, sign-in links). |
| 28 | 29 | */ |
| 29 | -export type Category = "security" | "account" | "participating" | "watching" | "actions" | "repos" | "orgs"; | |
| 30 | +export type Category = "security" | "account" | "participating" | "watching" | "actions" | "repos" | "orgs" | "digest"; | |
| 30 | 31 | |
| 31 | 32 | export type Recipient = { accountId: string } | { email: string; name?: string }; |
| 32 | 33 | |
| @@ -70,6 +71,42 @@ | ||
| 70 | 71 | jobs: Array<{ name: string; conclusion: string | null; url: string; failure?: string | null }>; |
| 71 | 72 | } |
| 72 | 73 | |
| 74 | +/** One line of the morning digest: what it is, where, and a short note (state, who, when). */ | |
| 75 | +export interface DigestItem { | |
| 76 | + title: string; | |
| 77 | + url: string; | |
| 78 | + /** e.g. "acme/website#42". */ | |
| 79 | + where: string; | |
| 80 | + note: string; | |
| 81 | + mark: "ok" | "fail" | "dot"; | |
| 82 | +} | |
| 83 | + | |
| 84 | +export interface DigestSection { | |
| 85 | + id: "needs_you" | "deployments" | "agents" | "tasks" | "failures" | "landed" | "releases"; | |
| 86 | + title: string; | |
| 87 | + /** How many there are; `items` holds at most DIGEST_MAX_ITEMS of them. */ | |
| 88 | + total: number; | |
| 89 | + items: DigestItem[]; | |
| 90 | +} | |
| 91 | + | |
| 92 | +export interface DigestSummary { | |
| 93 | + /** The person's local date, e.g. "Wednesday, October 14". */ | |
| 94 | + date: string; | |
| 95 | + counts: { needsYou: number; agents: number; merged: number; failed: number }; | |
| 96 | + sections: DigestSection[]; | |
| 97 | + repositories: number; | |
| 98 | + incomplete: boolean; | |
| 99 | +} | |
| 100 | + | |
| 101 | +export interface DigestData { | |
| 102 | + /** "Since the last digest" starts here. */ | |
| 103 | + since: string; | |
| 104 | + /** Today, gathered when the email is sent (deliver in send.ts); a scheduled job carries only `since`. */ | |
| 105 | + summary?: DigestSummary; | |
| 106 | + /** Asked for on Settings → Notifications: sent even when there is nothing to report. */ | |
| 107 | + now?: boolean; | |
| 108 | +} | |
| 109 | + | |
| 73 | 110 | export type MailBody = |
| 74 | 111 | | { kind: "signin_new"; data: { method: "passkey" | "access" | "link"; device: string; ip?: string; location?: string; passkey?: string } } |
| 75 | 112 | | { kind: "passkey_added"; data: { name: string; via?: "settings" | "signin-link" | "invite" } } |
| @@ -102,7 +139,8 @@ | ||
| 102 | 139 | | { kind: "repo_transferred"; data: { from: string; to: string; actor: string } } |
| 103 | 140 | | { kind: "thread"; data: ThreadData } |
| 104 | 141 | | { kind: "run"; data: RunData } |
| 105 | - | { kind: "test"; data: { by: string } }; | |
| 142 | + | { kind: "test"; data: { by: string } } | |
| 143 | + | { kind: "digest"; data: DigestData }; | |
| 106 | 144 | |
| 107 | 145 | export type MailKind = MailBody["kind"]; |
| 108 | 146 | |
| @@ -158,6 +196,8 @@ | ||
| 158 | 196 | return "actions"; |
| 159 | 197 | case "thread": |
| 160 | 198 | return job.reason === "subscribed" ? "watching" : "participating"; |
| 199 | + case "digest": | |
| 200 | + return "digest"; | |
| 161 | 201 | } |
| 162 | 202 | } |
| 163 | 203 |
modifiedapps/diffinery/src/mail/web.tsx+82−7View file
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | import { ADMIN_TABS, SettingsNav, type SettingsTab } from "../web/account.js"; |
| 8 | 8 | import { ComputePausedBanner } from "../web/kill-switches.js"; |
| 9 | 9 | import { csrfToken, formData } from "../web/csrf.js"; |
| 10 | -import { KCheckbox, KSelect } from "../web/kumo.js"; | |
| 10 | +import { KCheckbox, KSelect, type KOption } from "../web/kumo.js"; | |
| 11 | +import { digestSince } from "./digest.js"; | |
| 11 | 12 | import { Form, Time, WATCH_OPTIONS, act, flash, post, prm, render, repoUrl, type WebApp } from "../web/ui.js"; |
| 12 | 13 | import { MailStore, WATCH_LEVELS, type EmailLogRow, type NotificationPrefs, type WatchLevel } from "./prefs.js"; |
| 13 | 14 | import { sampleJobs } from "./samples.js"; |
| @@ -31,6 +32,7 @@ | ||
| 31 | 32 | actions: "workflow runs", |
| 32 | 33 | repos: "repository changes", |
| 33 | 34 | orgs: "organization updates", |
| 35 | + digest: "the morning digest", | |
| 34 | 36 | }; |
| 35 | 37 | |
| 36 | 38 | const settingsPage = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child) => |
| @@ -125,7 +127,13 @@ | ||
| 125 | 127 | const store = new MailStore(c.env.DB); |
| 126 | 128 | const dir = c.get("dir"); |
| 127 | 129 | const t = csrfToken(c); |
| 128 | - const [p, watches, recent, suppressed] = await Promise.all([store.prefs(me.id), store.watchedRepos(me.id), store.recent({ accountId: me.id, limit: 20 }), me.email ? new Suppressions(c.env.DB).get(me.email) : null]); | |
| 130 | + const [p, watches, recent, suppressed, digestNext] = await Promise.all([ | |
| 131 | + store.prefs(me.id), | |
| 132 | + store.watchedRepos(me.id), | |
| 133 | + store.recent({ accountId: me.id, limit: 20 }), | |
| 134 | + me.email ? new Suppressions(c.env.DB).get(me.email) : null, | |
| 135 | + store.digestNext(me.id), | |
| 136 | + ]); | |
| 129 | 137 | const repos = (await Promise.all(watches.map(async (w) => ({ level: w.level, repo: await dir.repoById(w.repo_id) })))).filter((w) => w.repo); |
| 130 | 138 | return settingsPage( |
| 131 | 139 | c, |
| @@ -175,7 +183,8 @@ | ||
| 175 | 183 | <PrefRow title="Security and account" hint="New sign-ins, passkeys, tokens, agents, push protection, invitations and sign-in links." email={{ name: "security", on: true, locked: true }} /> |
| 176 | 184 | </tbody> |
| 177 | 185 | </table> |
| 178 | - <div class="body"> | |
| 186 | + <DigestSettings p={p} next={digestNext} /> | |
| 187 | + <div class="body" style="border-top:1px solid var(--border)"> | |
| 179 | 188 | <div class="kstack"> |
| 180 | 189 | <KCheckbox name="ownActivity" checked={p.ownActivity} label="Email me about my own comments and changes" /> |
| 181 | 190 | <KCheckbox name="autoWatch" checked={p.autoWatch} label="Watch repositories I create (All activity)" /> |
| @@ -212,15 +221,56 @@ | ||
| 212 | 221 | )} |
| 213 | 222 | |
| 214 | 223 | <h2 class="mt">Recent emails to you</h2> |
| 215 | - <Form action="/settings/notifications/test" t={t} class="mb"> | |
| 216 | - <button class="btn sm">Send me a test email</button> | |
| 217 | - </Form> | |
| 224 | + <div class="row wrap mb" style="gap:8px"> | |
| 225 | + <Form action="/settings/notifications/test" t={t} class="inline"> | |
| 226 | + <button class="btn sm">Send me a test email</button> | |
| 227 | + </Form> | |
| 228 | + <Form action="/settings/notifications/digest" t={t} class="inline"> | |
| 229 | + <button class="btn sm">Send me my digest now</button> | |
| 230 | + </Form> | |
| 231 | + </div> | |
| 218 | 232 | <EmailLog rows={recent} /> |
| 219 | 233 | </>, |
| 220 | 234 | ); |
| 221 | 235 | } |
| 222 | 236 | |
| 223 | -function prefsFromForm(f: { b(name: string): boolean }): NotificationPrefs { | |
| 237 | +/** The time zones the digest offers: every zone the runtime knows, UTC first. */ | |
| 238 | +function timeZones(current: string): KOption[] { | |
| 239 | + const all = ((Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.("timeZone") ?? []).filter((z) => z !== "UTC"); | |
| 240 | + if (current !== "UTC" && !all.includes(current)) all.push(current); | |
| 241 | + return [{ value: BROWSER_TZ, label: "This browser's time zone" }, { value: "UTC", label: "UTC" }, ...all.map((z) => ({ value: z, label: z.replaceAll("_", " ") }))]; | |
| 242 | +} | |
| 243 | +const BROWSER_TZ = "browser"; | |
| 244 | +const HOURS: KOption[] = Array.from({ length: 24 }, (_, h) => ({ value: String(h), label: `${String(h).padStart(2, "0")}:00` })); | |
| 245 | + | |
| 246 | +/** The morning digest's part of the settings form (phase 5c, digest.ts). */ | |
| 247 | +const DigestSettings: FC<{ p: NotificationPrefs; next: string | null }> = ({ p, next }) => { | |
| 248 | + // Someone who never chose a zone gets their browser's; after that, the zone they saved. | |
| 249 | + const fresh = !p.digest.email && p.digest.timeZone === "UTC"; | |
| 250 | + return ( | |
| 251 | + <div class="body" id="digest" style="border-top:1px solid var(--border)"> | |
| 252 | + <h3 class="m0">Morning digest</h3> | |
| 253 | + <p class="muted small">Today by email, once a day at the hour you pick: what needs you, what your agents are doing, and what merged, released or failed since the last one. A day with nothing in it sends nothing.</p> | |
| 254 | + <div class="kstack"> | |
| 255 | + <KCheckbox name="digest.email" checked={p.digest.email} label="Email me a morning digest" /> | |
| 256 | + <div class="row wrap" style="gap:8px"> | |
| 257 | + <KSelect name="digest.hour" options={HOURS} value={String(p.digest.hour)} size="sm" label="Hour" /> | |
| 258 | + <KSelect name="digest.timeZone" options={timeZones(p.digest.timeZone)} value={fresh ? BROWSER_TZ : p.digest.timeZone} size="sm" label="Time zone" /> | |
| 259 | + </div> | |
| 260 | + <KCheckbox name="digest.weekends" checked={p.digest.weekends} label="On Saturdays and Sundays too" /> | |
| 261 | + <input type="hidden" name="digest.browserTimeZone" value="" data-browser-tz /> | |
| 262 | + </div> | |
| 263 | + {p.digest.email && next ? ( | |
| 264 | + <p class="muted small m0" style="margin-top:8px"> | |
| 265 | + The next one goes out <Time at={next} /> ({String(p.digest.hour).padStart(2, "0")}:00 {p.digest.timeZone.replaceAll("_", " ")}). | |
| 266 | + </p> | |
| 267 | + ) : null} | |
| 268 | + </div> | |
| 269 | + ); | |
| 270 | +}; | |
| 271 | + | |
| 272 | +function prefsFromForm(f: { b(name: string): boolean; s(name: string): string }): NotificationPrefs { | |
| 273 | + const tz = f.s("digest.timeZone"); | |
| 224 | 274 | return { |
| 225 | 275 | participating: { web: f.b("participating.web"), email: f.b("participating.email") }, |
| 226 | 276 | watching: { web: f.b("watching.web"), email: f.b("watching.email") }, |
| @@ -229,6 +279,8 @@ | ||
| 229 | 279 | orgs: { email: f.b("orgs.email") }, |
| 230 | 280 | ownActivity: f.b("ownActivity"), |
| 231 | 281 | autoWatch: f.b("autoWatch"), |
| 282 | + // mergePrefs (prefs.ts) drops an hour or zone it doesn't know. | |
| 283 | + digest: { email: f.b("digest.email"), hour: Number(f.s("digest.hour")), timeZone: tz === BROWSER_TZ ? f.s("digest.browserTimeZone") || "UTC" : tz, weekends: f.b("digest.weekends") }, | |
| 232 | 284 | }; |
| 233 | 285 | } |
| 234 | 286 | |
| @@ -242,6 +294,18 @@ | ||
| 242 | 294 | return deliver(c.env, { kind: "test", data: { by: me.login }, to: { accountId: me.id }, reason: "account", at: now() }); |
| 243 | 295 | } |
| 244 | 296 | |
| 297 | +/** Digests one account may ask for in an hour, on top of the scheduled one. */ | |
| 298 | +export const MAX_DIGESTS_NOW_PER_HOUR = 3; | |
| 299 | + | |
| 300 | +/** Send the morning digest now (not through the queue), even when it has nothing in it and whether or not it is turned on. */ | |
| 301 | +export async function sendDigestNow(c: AppContext, me: Account) { | |
| 302 | + const store = new MailStore(c.env.DB); | |
| 303 | + const sent = (await store.recent({ accountId: me.id, kind: "digest", since: new Date(Date.now() - 3600_000).toISOString(), limit: MAX_DIGESTS_NOW_PER_HOUR + 1 })).length; | |
| 304 | + if (sent >= MAX_DIGESTS_NOW_PER_HOUR) throw new HttpError(429, `you can ask for your digest at most ${MAX_DIGESTS_NOW_PER_HOUR} times an hour; try again later`); | |
| 305 | + const at = new Date(); | |
| 306 | + return deliver(c.env, { kind: "digest", data: { since: digestSince(await store.digestLastSent(me.id), at), now: true }, to: { accountId: me.id }, reason: "digest", at: at.toISOString(), force: true }); | |
| 307 | +} | |
| 308 | + | |
| 245 | 309 | // ---------------------------------------------------------------- the suppression list (suppression.ts) |
| 246 | 310 | |
| 247 | 311 | export const suppressionJson = (s: Suppression) => ({ email: s.email, reason: s.reason, detail: s.detail, source: s.source, created_by: s.created_by, created_at: s.created_at, owner_can_lift: ownerCanLift(s) }); |
| @@ -403,6 +467,17 @@ | ||
| 403 | 467 | return c.redirect("/settings/notifications", 303); |
| 404 | 468 | }); |
| 405 | 469 | |
| 470 | + post(web, "/settings/notifications/digest", async (c) => { | |
| 471 | + const me = requireUser(c); | |
| 472 | + const r = await sendDigestNow(c, me).catch((e) => { | |
| 473 | + if (e instanceof HttpError && e.status === 429) return { status: "refused", detail: e.detail }; | |
| 474 | + throw e; | |
| 475 | + }); | |
| 476 | + if (r.status === "sent") flash(c, "notice", `Your digest was sent to ${me.email}.`); | |
| 477 | + else flash(c, "error", `The digest was ${r.status}: ${r.detail ?? "unknown reason"}`); | |
| 478 | + return c.redirect("/settings/notifications", 303); | |
| 479 | + }); | |
| 480 | + | |
| 406 | 481 | // ------------------------------------------------------------ Settings → Email: suppressed or not, and verifying the address again |
| 407 | 482 | |
| 408 | 483 | web.get("/settings/email", async (c) => emailPage(c, requireUser(c))); |
modifiedapps/diffinery/src/web/assets.ts+2−0View file
| @@ -356,6 +356,8 @@ | ||
| 356 | 356 | const d=document,b=d.body,$=(s,r=d)=>r.querySelector(s),$$=(s,r=d)=>[...r.querySelectorAll(s)]; |
| 357 | 357 | const repo=b.dataset.repo||'',ref=b.dataset.ref||''; |
| 358 | 358 | const enc=p=>p.split('/').map(encodeURIComponent).join('/'); |
| 359 | +// Fields that want the browser's time zone (the morning digest's "this browser" choice). | |
| 360 | +$$('input[data-browser-tz]').forEach(i=>{try{i.value=Intl.DateTimeFormat().resolvedOptions().timeZone||''}catch(_){}}); | |
| 359 | 361 | d.addEventListener('click',e=>{ |
| 360 | 362 | const c=e.target.closest('[data-copy]'); |
| 361 | 363 | if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.innerHTML;if(c.dataset.copyIcon)c.innerHTML=${JSON.stringify(UI_ICONS.check)};else c.textContent='Copied';setTimeout(()=>{c.innerHTML=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return} |
modifiedapps/diffinery/test/e2e/email.test.mjs+82−1View file
| @@ -9,7 +9,8 @@ | ||
| 9 | 9 | // own activity staying quiet, one-click unsubscribe (RFC 8058) turning that |
| 10 | 10 | // kind of email off, the footer link muting a thread while mentions still get |
| 11 | 11 | // through, failed workflow run emails, and the suppression list: signed bounce |
| 12 | -// and complaint intake, no email to a suppressed address, lifting it again. | |
| 12 | +// and complaint intake, no email to a suppressed address, lifting it again; the | |
| 13 | +// morning digest on its schedule (phase 5c) and Today at phone width. | |
| 13 | 14 | import { spawn, execFileSync } from "node:child_process"; |
| 14 | 15 | import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs"; |
| 15 | 16 | import { tmpdir } from "node:os"; |
| @@ -19,6 +20,7 @@ | ||
| 19 | 20 | import { createServer } from "node:net"; |
| 20 | 21 | import { test, before, after } from "node:test"; |
| 21 | 22 | import assert from "node:assert/strict"; |
| 23 | +import { chromium } from "playwright"; | |
| 22 | 24 | |
| 23 | 25 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 24 | 26 | const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs"); |
| @@ -406,3 +408,82 @@ | ||
| 406 | 408 | const audit = await (await fetch(`${base}/settings/audit`)).text(); |
| 407 | 409 | for (const action of ["email.suppression.add", "email.suppression.lift", "email.suppression.remove"]) assert.match(audit, new RegExp(action.replaceAll(".", "\\."))); |
| 408 | 410 | }); |
| 411 | + | |
| 412 | +test("email: the morning digest on its schedule, one-click unsubscribe, and Today on a phone (phase 5c)", async () => { | |
| 413 | + const S = "/diffinery/notification-settings"; | |
| 414 | + // The template gallery in the first test already sent a sample digest; count from here. | |
| 415 | + const sample = (await emails()).filter((e) => e.kind === "digest").map((e) => e.id ?? `${e.created_at} ${e.subject}`); | |
| 416 | + const digests = async () => (await emails()).filter((e) => e.kind === "digest" && !sample.includes(e.id ?? `${e.created_at} ${e.subject}`)); | |
| 417 | + assert.deepEqual((await ok("GET", S)).digest, { email: false, hour: 8, timeZone: "UTC", weekends: false }); | |
| 418 | + assert.equal((await api("PUT", S, { digest: { hour: 24 } })).status, 422); | |
| 419 | + assert.equal((await api("PUT", S, { digest: { timeZone: "Mars/Olympus" } })).status, 422); | |
| 420 | + | |
| 421 | + // On, for the next hour in Tokyo, every day. | |
| 422 | + const hour = (Number(new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Tokyo", hour: "numeric", hourCycle: "h23" }).format(new Date())) + 1) % 24; | |
| 423 | + const saved = await ok("PUT", S, { digest: { email: true, hour, timeZone: "Asia/Tokyo", weekends: true } }); | |
| 424 | + assert.deepEqual(saved.digest, { email: true, hour, timeZone: "Asia/Tokyo", weekends: true }); | |
| 425 | + const next = /The next one goes out <time datetime="([^"]+)"/.exec(await (await fetch(`${base}/settings/notifications`)).text())?.[1]; | |
| 426 | + assert.ok(next && Date.parse(next) > Date.now() && Date.parse(next) <= Date.now() + 3600_000, `next digest at ${next}`); | |
| 427 | + assert.equal(new Date(next).getUTCMinutes(), 0); | |
| 428 | + | |
| 429 | + // The five-minute cron sends nothing before the hour, then queues it once; it goes out like any email. | |
| 430 | + const cron = (t) => fetch(`${base}/cdn-cgi/local/scheduled?cron=${encodeURIComponent("*/5 * * * *")}&time=${t}`); | |
| 431 | + assert.equal((await cron(Date.parse(next) - 10 * 60_000)).status, 200); | |
| 432 | + await sleep(2000); | |
| 433 | + assert.equal((await digests()).length, 0); | |
| 434 | + assert.equal((await cron(Date.parse(next) + 2 * 60_000)).status, 200); | |
| 435 | + const d = await until("the morning digest", async () => (await digests())[0]); | |
| 436 | + assert.equal(d.status, "sent", d.detail); | |
| 437 | + assert.match(d.subject, /^\[Diffinery\] Today: /); | |
| 438 | + assert.equal(d.dev_copy.headers["X-Diffinery-Kind"], "digest"); | |
| 439 | + assert.equal(d.dev_copy.headers["X-Diffinery-Reason"], "digest"); | |
| 440 | + assert.match(d.dev_copy.text, /Failed runs · \d+/); | |
| 441 | + assert.ok(d.dev_copy.text.includes(`Open Today: ${base}/`)); | |
| 442 | + assert.match(d.dev_copy.text, /you turned on the morning digest/); | |
| 443 | + assert.equal((await cron(Date.parse(next) + 7 * 60_000)).status, 200); | |
| 444 | + await sleep(3000); | |
| 445 | + assert.equal((await digests()).length, 1, "the next run doesn't send it again"); | |
| 446 | + assert.match(await (await fetch(`${base}/settings/notifications`)).text(), /The next one goes out/); | |
| 447 | + | |
| 448 | + // The mail app's one-click unsubscribe turns off only the digest. | |
| 449 | + const link = /^<(.+)>$/.exec(d.dev_copy.headers["List-Unsubscribe"])[1]; | |
| 450 | + const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" }); | |
| 451 | + assert.equal(unsub.status, 200); | |
| 452 | + const off = await ok("GET", S); | |
| 453 | + assert.equal(off.digest.email, false); | |
| 454 | + assert.equal(off.participating.email, true); | |
| 455 | + assert.doesNotMatch(await (await fetch(`${base}/settings/notifications`)).text(), /The next one goes out/); | |
| 456 | + | |
| 457 | + // Asked for, it is sent now even while off. | |
| 458 | + const now = await ok("POST", "/diffinery/emails/digest"); | |
| 459 | + assert.equal(now.status, "sent", JSON.stringify(now)); | |
| 460 | + assert.equal((await digests()).length, 2); | |
| 461 | + | |
| 462 | + // The settings form: the browser's time zone, filled in by the page. | |
| 463 | + const formPage = await fetch(`${base}/settings/notifications`); | |
| 464 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(await formPage.text())?.[1]; | |
| 465 | + const cookie = /dfy_csrf=([^;]+)/.exec(formPage.headers.get("set-cookie") ?? "")?.[1]; | |
| 466 | + const fields = [["_csrf", csrf], ...["participating.web", "participating.email", "watching.web", "watching.email", "actions.web", "actions.email", "actions.failuresOnly", "repos.email", "orgs.email", "autoWatch"].map((k) => [k, "1"]), ["digest.email", "1"], ["digest.hour", "7"], ["digest.timeZone", "browser"], ["digest.browserTimeZone", "Europe/Istanbul"]]; | |
| 467 | + const posted = await fetch(`${base}/settings/notifications`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams(fields) }); | |
| 468 | + assert.equal(posted.status, 303); | |
| 469 | + assert.deepEqual((await ok("GET", S)).digest, { email: true, hour: 7, timeZone: "Europe/Istanbul", weekends: false }); | |
| 470 | + | |
| 471 | + // On a phone: the page fills in the browser's zone, nothing scrolls sideways, and Today comes before the repository list. | |
| 472 | + const browser = await chromium.launch(); | |
| 473 | + try { | |
| 474 | + const page = await (await browser.newContext({ viewport: { width: 390, height: 844 }, timezoneId: "America/Sao_Paulo", isMobile: true, hasTouch: true })).newPage(); | |
| 475 | + const overflow = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); | |
| 476 | + await page.goto(`${base}/settings/notifications`); | |
| 477 | + assert.equal(await page.locator("input[data-browser-tz]").inputValue(), "America/Sao_Paulo"); | |
| 478 | + assert.equal(await overflow(), 0, "the settings page scrolls sideways"); | |
| 479 | + if (process.env.E2E_SHOTS) await page.screenshot({ path: join(process.env.E2E_SHOTS, "notifications-390.png"), fullPage: true }); | |
| 480 | + await page.goto(`${base}/`); | |
| 481 | + assert.equal(await overflow(), 0, "Today scrolls sideways"); | |
| 482 | + const today = await page.locator("h1", { hasText: "Today" }).boundingBox(); | |
| 483 | + const side = await page.locator("h3", { hasText: /repositories/i }).first().boundingBox(); | |
| 484 | + assert.ok(today && side && today.y < side.y, "Today comes first"); | |
| 485 | + if (process.env.E2E_SHOTS) await page.screenshot({ path: join(process.env.E2E_SHOTS, "today-390.png"), fullPage: true }); | |
| 486 | + } finally { | |
| 487 | + await browser.close(); | |
| 488 | + } | |
| 489 | +}); |
addedapps/diffinery/test/unit/digest.test.ts+221−0View file
| @@ -1,0 +1,221 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { DIGEST_MAX_ITEMS, digestSummary } from "../../src/agentic/digest.js"; | |
| 3 | +import type { Today, TodayRepo } from "../../src/agentic/today.js"; | |
| 4 | +import type { TodayPull } from "../../src/agentic/today-repo.js"; | |
| 5 | +import { DEFAULT_SINCE_HOURS, digestCron, digestSince, localDate, nextDigestAt } from "../../src/mail/digest.js"; | |
| 6 | +import { DEFAULT_PREFS, MailStore, mergePrefs, wantsEmail, type NotificationPrefs } from "../../src/mail/prefs.js"; | |
| 7 | +import { compose, toText } from "../../src/mail/templates.js"; | |
| 8 | +import { categoryOf, type MailJob } from "../../src/mail/types.js"; | |
| 9 | +import { testD1 } from "./stubs/d1.js"; | |
| 10 | + | |
| 11 | +const digest = (d: Partial<NotificationPrefs["digest"]>) => ({ ...DEFAULT_PREFS.digest, email: true, ...d }); | |
| 12 | +const iso = (d: Date) => d.toISOString(); | |
| 13 | + | |
| 14 | +describe("nextDigestAt", () => { | |
| 15 | + it("is the chosen hour in UTC, today if it is still ahead, else tomorrow", () => { | |
| 16 | + // 2026-10-14 is a Wednesday. | |
| 17 | + expect(iso(nextDigestAt(digest({ hour: 8 }), new Date("2026-10-14T07:59:00Z")))).toBe("2026-10-14T08:00:00.000Z"); | |
| 18 | + expect(iso(nextDigestAt(digest({ hour: 8 }), new Date("2026-10-14T08:00:00Z")))).toBe("2026-10-15T08:00:00.000Z"); | |
| 19 | + expect(iso(nextDigestAt(digest({ hour: 0 }), new Date("2026-10-14T23:30:00Z")))).toBe("2026-10-15T00:00:00.000Z"); | |
| 20 | + }); | |
| 21 | + | |
| 22 | + it("is the local hour in the person's time zone", () => { | |
| 23 | + // Istanbul is UTC+3 all year. | |
| 24 | + expect(iso(nextDigestAt(digest({ hour: 8, timeZone: "Europe/Istanbul" }), new Date("2026-10-14T04:00:00Z")))).toBe("2026-10-14T05:00:00.000Z"); | |
| 25 | + expect(iso(nextDigestAt(digest({ hour: 8, timeZone: "Europe/Istanbul" }), new Date("2026-10-14T06:00:00Z")))).toBe("2026-10-15T05:00:00.000Z"); | |
| 26 | + // Late in the UTC day it is already tomorrow in Tokyo (UTC+9). | |
| 27 | + expect(iso(nextDigestAt(digest({ hour: 7, timeZone: "Asia/Tokyo" }), new Date("2026-10-14T20:00:00Z")))).toBe("2026-10-14T22:00:00.000Z"); | |
| 28 | + // And still yesterday in Los Angeles (UTC-7 in October). | |
| 29 | + expect(iso(nextDigestAt(digest({ hour: 9, timeZone: "America/Los_Angeles" }), new Date("2026-10-15T03:00:00Z")))).toBe("2026-10-15T16:00:00.000Z"); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("skips Saturdays and Sundays unless weekends are on", () => { | |
| 33 | + // Friday 2026-10-16, after the hour: the next is Monday. | |
| 34 | + const fri = new Date("2026-10-16T10:00:00Z"); | |
| 35 | + expect(iso(nextDigestAt(digest({ hour: 8 }), fri))).toBe("2026-10-19T08:00:00.000Z"); | |
| 36 | + expect(iso(nextDigestAt(digest({ hour: 8, weekends: true }), fri))).toBe("2026-10-17T08:00:00.000Z"); | |
| 37 | + // A weekday in the person's zone, not in UTC: Monday 07:00 in Tokyo is Sunday 22:00 UTC. | |
| 38 | + expect(iso(nextDigestAt(digest({ hour: 7, timeZone: "Asia/Tokyo" }), new Date("2026-10-17T00:00:00Z")))).toBe("2026-10-18T22:00:00.000Z"); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("follows clock changes", () => { | |
| 42 | + // New York leaves daylight saving time on 2026-11-01: 08:00 is 12:00 UTC before, 13:00 UTC after. | |
| 43 | + const ny = digest({ hour: 8, timeZone: "America/New_York", weekends: true }); | |
| 44 | + expect(iso(nextDigestAt(ny, new Date("2026-10-31T09:00:00Z")))).toBe("2026-10-31T12:00:00.000Z"); | |
| 45 | + expect(iso(nextDigestAt(ny, new Date("2026-10-31T12:00:00Z")))).toBe("2026-11-01T13:00:00.000Z"); | |
| 46 | + // Berlin skips 02:00 to 03:00 on 2026-03-29: a 02:00 digest goes out at 03:00 local that day. | |
| 47 | + const berlin = digest({ hour: 2, timeZone: "Europe/Berlin", weekends: true }); | |
| 48 | + expect(iso(nextDigestAt(berlin, new Date("2026-03-28T12:00:00Z")))).toBe("2026-03-29T01:00:00.000Z"); | |
| 49 | + }); | |
| 50 | +}); | |
| 51 | + | |
| 52 | +describe("digestSince", () => { | |
| 53 | + const at = new Date("2026-10-19T08:00:00Z"); | |
| 54 | + it("starts at the last digest, so Monday's covers the weekend", () => { | |
| 55 | + expect(digestSince("2026-10-16T08:00:00.000Z", at)).toBe("2026-10-16T08:00:00.000Z"); | |
| 56 | + }); | |
| 57 | + it("covers a day without one, or after a long gap", () => { | |
| 58 | + const day = new Date(at.getTime() - DEFAULT_SINCE_HOURS * 3600_000).toISOString(); | |
| 59 | + expect(digestSince(null, at)).toBe(day); | |
| 60 | + expect(digestSince("2026-09-01T08:00:00.000Z", at)).toBe(day); | |
| 61 | + expect(digestSince("2026-10-20T08:00:00.000Z", at)).toBe(day); | |
| 62 | + }); | |
| 63 | +}); | |
| 64 | + | |
| 65 | +describe("digest settings", () => { | |
| 66 | + it("are off by default, and keep only an hour and a time zone that exist", () => { | |
| 67 | + expect(mergePrefs(null).digest).toEqual({ email: false, hour: 8, timeZone: "UTC", weekends: false }); | |
| 68 | + expect(mergePrefs({ digest: { email: true, hour: 6, timeZone: "Europe/Istanbul", weekends: true } }).digest).toEqual({ email: true, hour: 6, timeZone: "Europe/Istanbul", weekends: true }); | |
| 69 | + expect(mergePrefs({ digest: { email: true, hour: 24, timeZone: "Mars/Olympus" } }).digest).toEqual({ email: true, hour: 8, timeZone: "UTC", weekends: false }); | |
| 70 | + expect(mergePrefs({ digest: { hour: 7.5 } }).digest.hour).toBe(8); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it("are their own settings group, so the unsubscribe link turns only the digest off", () => { | |
| 74 | + const job = { kind: "digest", reason: "digest" } as const; | |
| 75 | + expect(categoryOf(job)).toBe("digest"); | |
| 76 | + expect(wantsEmail(DEFAULT_PREFS, "digest")).toBe(false); | |
| 77 | + expect(wantsEmail(mergePrefs({ digest: { email: true } }), "digest")).toBe(true); | |
| 78 | + }); | |
| 79 | +}); | |
| 80 | + | |
| 81 | +describe("the digest's schedule", () => { | |
| 82 | + const ID = "u-mia"; | |
| 83 | + function setup() { | |
| 84 | + const db = testD1(); | |
| 85 | + db.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, created_at) VALUES (?, 'mia', 'user', 'active', ?)").run(ID, new Date().toISOString()); | |
| 86 | + const store = new MailStore(db); | |
| 87 | + const sent: MailJob[] = []; | |
| 88 | + const cron = (at: string) => digestCron(db, new Date(at), (ids) => store.prefsFor(ids), async (jobs) => void sent.push(...jobs)); | |
| 89 | + const row = () => db.sqlite.prepare("SELECT next_at, last_sent_at FROM mail_digests WHERE account_id = ?").get(ID) as { next_at: string; last_sent_at: string | null } | undefined; | |
| 90 | + return { db, store, sent, cron, row }; | |
| 91 | + } | |
| 92 | + | |
| 93 | + it("is written when the digest is turned on and removed when it is turned off", async () => { | |
| 94 | + const { store, row } = setup(); | |
| 95 | + await store.setPrefs(ID, mergePrefs({ digest: { email: true, hour: 9, timeZone: "Europe/Istanbul" } })); | |
| 96 | + expect(row()?.next_at).toBe(iso(nextDigestAt(digest({ hour: 9, timeZone: "Europe/Istanbul" }), new Date()))); | |
| 97 | + expect(await store.digestNext(ID)).toBe(row()!.next_at); | |
| 98 | + await store.setPrefs(ID, mergePrefs({ digest: { email: false, hour: 9 } })); | |
| 99 | + expect(row()).toBeUndefined(); | |
| 100 | + expect(await store.digestNext(ID)).toBeNull(); | |
| 101 | + }); | |
| 102 | + | |
| 103 | + it("queues each due digest once, then moves it to the next day", async () => { | |
| 104 | + const { db, store, sent, cron, row } = setup(); | |
| 105 | + await store.setPrefs(ID, mergePrefs({ digest: { email: true, hour: 8 } })); | |
| 106 | + db.sqlite.prepare("UPDATE mail_digests SET next_at = ? WHERE account_id = ?").run("2026-10-14T08:00:00.000Z", ID); | |
| 107 | + expect(await cron("2026-10-14T07:58:00Z")).toBe(0); | |
| 108 | + expect(await cron("2026-10-14T08:02:00Z")).toBe(1); | |
| 109 | + expect(sent).toEqual([{ kind: "digest", data: { since: "2026-10-13T08:02:00.000Z" }, to: { accountId: ID }, reason: "digest", at: "2026-10-14T08:02:00.000Z" }]); | |
| 110 | + expect(row()).toEqual({ next_at: "2026-10-15T08:00:00.000Z", last_sent_at: "2026-10-14T08:02:00.000Z" }); | |
| 111 | + // The next run, five minutes later, finds nothing due. | |
| 112 | + expect(await cron("2026-10-14T08:07:00Z")).toBe(0); | |
| 113 | + // Tomorrow's covers what happened since today's. | |
| 114 | + expect(await cron("2026-10-15T08:01:00Z")).toBe(1); | |
| 115 | + expect(sent[1]!.data).toEqual({ since: "2026-10-14T08:02:00.000Z" }); | |
| 116 | + }); | |
| 117 | + | |
| 118 | + it("drops a row whose digest was turned off some other way", async () => { | |
| 119 | + const { db, sent, cron, row } = setup(); | |
| 120 | + db.sqlite.prepare("INSERT INTO mail_digests (account_id, next_at) VALUES (?, ?)").run(ID, "2026-10-14T08:00:00.000Z"); | |
| 121 | + expect(await cron("2026-10-14T08:01:00Z")).toBe(0); | |
| 122 | + expect(sent).toEqual([]); | |
| 123 | + expect(row()).toBeUndefined(); | |
| 124 | + }); | |
| 125 | +}); | |
| 126 | + | |
| 127 | +// ---------------------------------------------------------------- content and the email | |
| 128 | + | |
| 129 | +const repo: TodayRepo = { id: "r1", fullName: "acme/web", url: "/acme/web", defaultBranch: "main", visibility: "private" }; | |
| 130 | +const pull = (n: number, over: Partial<TodayPull> = {}): TodayPull => ({ | |
| 131 | + number: n, | |
| 132 | + title: `PR ${n}`, | |
| 133 | + author: { id: "u-bob", login: "bob", kind: "user" }, | |
| 134 | + head: "h", | |
| 135 | + base: "main", | |
| 136 | + draft: false, | |
| 137 | + requestedReviewers: [], | |
| 138 | + intent: null, | |
| 139 | + createdAt: "2026-10-13T08:00:00Z", | |
| 140 | + updatedAt: "2026-10-13T08:00:00Z", | |
| 141 | + approvedBy: [], | |
| 142 | + approvedHead: false, | |
| 143 | + changesRequestedBy: [], | |
| 144 | + reviewerIds: [], | |
| 145 | + checks: "success", | |
| 146 | + failingChecks: [], | |
| 147 | + autoMerge: false, | |
| 148 | + queued: false, | |
| 149 | + ...over, | |
| 150 | +}); | |
| 151 | +const today = (over: Partial<Today> = {}): Today => ({ | |
| 152 | + generatedAt: "2026-10-14T08:00:00Z", | |
| 153 | + since: "2026-10-13T08:00:00Z", | |
| 154 | + repositories: 3, | |
| 155 | + incomplete: false, | |
| 156 | + needsYou: [], | |
| 157 | + agentsAtWork: [], | |
| 158 | + landed: [], | |
| 159 | + releases: [], | |
| 160 | + failures: [], | |
| 161 | + deployments: [], | |
| 162 | + tasks: [], | |
| 163 | + ...over, | |
| 164 | +}); | |
| 165 | +const BASE = "https://diffinery.example"; | |
| 166 | + | |
| 167 | +describe("digestSummary", () => { | |
| 168 | + it("keeps the sections that have something, in Today's order, with links", () => { | |
| 169 | + const bot = { id: "a-bot", login: "fix-bot", kind: "agent" }; | |
| 170 | + const s = digestSummary( | |
| 171 | + BASE, | |
| 172 | + today({ | |
| 173 | + needsYou: [{ kind: "review_requested", repo, pull: pull(4) }], | |
| 174 | + agentsAtWork: [ | |
| 175 | + { state: "checks_failing", repo, pull: pull(5, { author: bot }) }, | |
| 176 | + { state: "draft", repo, pull: pull(6, { author: bot, draft: true }) }, | |
| 177 | + ], | |
| 178 | + landed: [{ repo, pull: { number: 3, title: "Merged one", author: bot, mergedBy: "mia", mergedAt: "2026-10-13T20:00:00Z" } }], | |
| 179 | + }), | |
| 180 | + "Wednesday, October 14", | |
| 181 | + ); | |
| 182 | + expect(s.counts).toEqual({ needsYou: 1, agents: 1, merged: 1, failed: 0 }); | |
| 183 | + expect(s.sections.map((x) => x.id)).toEqual(["needs_you", "agents", "landed"]); | |
| 184 | + expect(s.sections[0]!.items[0]).toEqual({ title: "PR 4", url: `${BASE}/acme/web/pull/4`, where: "acme/web#4", note: "Review requested · by bob", mark: "dot" }); | |
| 185 | + expect(s.sections[1]!.items[0]!.mark).toBe("fail"); | |
| 186 | + expect(s.sections[2]!.items[0]!.note).toBe("by fix-bot (agent), merged by mia"); | |
| 187 | + }); | |
| 188 | + | |
| 189 | + it("caps each section and keeps the total", () => { | |
| 190 | + const s = digestSummary(BASE, today({ needsYou: Array.from({ length: 12 }, (_, i) => ({ kind: "review_requested" as const, repo, pull: pull(i + 1) })) }), "x"); | |
| 191 | + expect(s.sections[0]!.total).toBe(12); | |
| 192 | + expect(s.sections[0]!.items.length).toBe(DIGEST_MAX_ITEMS); | |
| 193 | + }); | |
| 194 | + | |
| 195 | + it("has no sections on a quiet day", () => { | |
| 196 | + expect(digestSummary(BASE, today(), "x").sections).toEqual([]); | |
| 197 | + }); | |
| 198 | +}); | |
| 199 | + | |
| 200 | +describe("the digest email", () => { | |
| 201 | + const job = (summary: ReturnType<typeof digestSummary>): MailJob => ({ kind: "digest", reason: "digest", to: { accountId: "u" }, at: "2026-10-14T08:00:00Z", data: { since: "2026-10-13T08:00:00Z", summary } }); | |
| 202 | + | |
| 203 | + it("leads with what needs you, and lists each section", () => { | |
| 204 | + const s = digestSummary(BASE, today({ needsYou: [{ kind: "review_requested", repo, pull: pull(4) }], failures: [{ repo, run: { id: "run1", runNumber: 7, workflow: "CI", title: "Fix it", ref: "refs/heads/main", event: "push", conclusion: "failure", actor: { id: "u", login: "mia", kind: "user" }, prNumber: null, at: "2026-10-13T22:00:00Z" } }] }), localDate("Europe/Istanbul", new Date("2026-10-14T05:00:00Z"))); | |
| 205 | + const e = compose(job(s), BASE); | |
| 206 | + expect(e.subject).toBe("[Diffinery] Today: 1 needs you, 1 failed run"); | |
| 207 | + expect(e.eyebrow?.text).toBe("Wednesday, October 14"); | |
| 208 | + const text = toText(e, { publicUrl: BASE, reason: "digest" }); | |
| 209 | + expect(text).toContain("Needs you · 1"); | |
| 210 | + expect(text).toContain(`PR 4 acme/web#4 · Review requested · by bob <${BASE}/acme/web/pull/4>`); | |
| 211 | + expect(text).toContain("[failed] Fix it acme/web · CI on main · mia"); | |
| 212 | + expect(text).toContain(`Open Today: ${BASE}/`); | |
| 213 | + expect(text).toContain("you turned on the morning digest"); | |
| 214 | + }); | |
| 215 | + | |
| 216 | + it("says so on a quiet day (sent only when asked for)", () => { | |
| 217 | + const e = compose(job(digestSummary(BASE, today(), "x")), BASE); | |
| 218 | + expect(e.subject).toBe("[Diffinery] Today: nothing needs you"); | |
| 219 | + expect(toText(e, { publicUrl: BASE, reason: "digest" })).toContain("Nothing needs you"); | |
| 220 | + }); | |
| 221 | +}); |