Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; | |
| 3 | ||
| 4 | import type { InboxItem } from "@g1t/contracts"; | |
| 5 | ||
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 6 | import { |
| 7 | EMAIL_REASONS, | |
| 8 | REASON_FILTERS, | |
| A repository's Security overview is one row per feature in three groups, each with a status word (Enabled, Disabled, Needs setup, Coming), a line on what it does and the one thing to do about it: the security policy (View policy, or Set up a security policy, which commits a starting SECURITY.md on a branch and opens it as a pull request), dependency alerts, security updates (the switch, kept on Vulnerabilities and now in Settings too), code scanning (Enabled once an analysis was uploaded), secret scanning (push protection always on) and code quality as Coming; and under Reporting, advisories and the private vulnerability reporting switch. Anyone who can see a public repository but has no role on it sees the public face at the same address: the policy, the published advisories and, when it is on, Report a vulnerability. Advisories get their pages (the list with its state filter, one advisory with Publish, Withdraw and Edit, a form for a new draft or changes) and reports theirs (new ones first, each accepted into a draft advisory or declined), with a Reports page beside Advisories and an Advisories tab; the public report form takes an email when signed out and counts against the front door's heavy limit. Security settings gain Alerts for secrets in the history and Private vulnerability reporting. Settings → Notifications gains a Security section: critical and high alerts at once (always, for the workspaces one owns or manages security for) and the rest in a daily or weekly digest, at once, or not at all, with security alerts now among the email reasons. The notifications guide says how security alerts arrive and what the digest is, the security guide the overview's rows and the public face, secret protection the history alerts switch, webhooks the summary and advisory events, and a new guide covers advisories and private reporting; the report_settings method lets the public page know whether a repository takes reports. | 9 | SECURITY_DIGEST_CHOICES, |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 10 | REASON_LABEL, |
| 11 | bellCount, | |
| 12 | emailReasonsFromForm, | |
| 13 | emptyFor, | |
| A repository's Security overview is one row per feature in three groups, each with a status word (Enabled, Disabled, Needs setup, Coming), a line on what it does and the one thing to do about it: the security policy (View policy, or Set up a security policy, which commits a starting SECURITY.md on a branch and opens it as a pull request), dependency alerts, security updates (the switch, kept on Vulnerabilities and now in Settings too), code scanning (Enabled once an analysis was uploaded), secret scanning (push protection always on) and code quality as Coming; and under Reporting, advisories and the private vulnerability reporting switch. Anyone who can see a public repository but has no role on it sees the public face at the same address: the policy, the published advisories and, when it is on, Report a vulnerability. Advisories get their pages (the list with its state filter, one advisory with Publish, Withdraw and Edit, a form for a new draft or changes) and reports theirs (new ones first, each accepted into a draft advisory or declined), with a Reports page beside Advisories and an Advisories tab; the public report form takes an email when signed out and counts against the front door's heavy limit. Security settings gain Alerts for secrets in the history and Private vulnerability reporting. Settings → Notifications gains a Security section: critical and high alerts at once (always, for the workspaces one owns or manages security for) and the rest in a daily or weekly digest, at once, or not at all, with security alerts now among the email reasons. The notifications guide says how security alerts arrive and what the digest is, the security guide the overview's rows and the public face, secret protection the history alerts switch, webhooks the summary and advisory events, and a new guide covers advisories and private reporting; the report_settings method lets the public page know whether a repository takes reports. | 14 | securityDigestFromForm, |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 15 | inboxReason, |
| 16 | inboxTab, | |
| 17 | inboxView, | |
| 18 | markFromForm, | |
| 19 | needsYou, | |
| 20 | severityOf, | |
| 21 | snoozeUntil, | |
| 22 | subscriptionFromForm, | |
| 23 | subscriptionLine, | |
| 24 | tabCount, | |
| 25 | updatesLabel, | |
| 26 | watchFromForm, | |
| 27 | watchLabel, | |
| 28 | whenShort, | |
| 29 | } from "./inbox.ts"; | |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 30 | |
| 31 | const NOW = Date.parse("2026-10-07T12:00:00.000Z"); | |
| 32 | ||
| 33 | test("tabs read from the address, and each shows one severity", () => { | |
| 34 | assert.equal(inboxTab("needs"), "needs"); | |
| 35 | assert.equal(inboxTab("nonsense"), "all"); | |
| 36 | assert.equal(inboxTab(null), "all"); | |
| 37 | assert.equal(severityOf("needs"), "warning"); | |
| 38 | assert.equal(severityOf("all"), null); | |
| 39 | assert.equal(inboxView("done"), "done"); | |
| 40 | assert.equal(inboxView("archive"), "inbox"); | |
| 41 | }); | |
| 42 | ||
| 43 | test("counts are what is unread under each tab", () => { | |
| 44 | const counts = { unread: 15, error: 3, warning: 0, success: 8, info: 4 }; | |
| 45 | assert.equal(tabCount(counts, "all"), 15); | |
| 46 | assert.equal(tabCount(counts, "error"), 3); | |
| 47 | assert.equal(tabCount(counts, "needs"), 0); | |
| 48 | assert.equal(tabCount(null, "all"), 0); | |
| 49 | }); | |
| 50 | ||
| 51 | test("the bell shows a number up to 99", () => { | |
| 52 | assert.equal(bellCount(0), ""); | |
| 53 | assert.equal(bellCount(null), ""); | |
| 54 | assert.equal(bellCount(7), "7"); | |
| 55 | assert.equal(bellCount(140), "99+"); | |
| 56 | }); | |
| 57 | ||
| 58 | test("times are said in a few characters", () => { | |
| 59 | assert.equal(whenShort("2026-10-07T11:59:30.000Z", NOW), "just now"); | |
| 60 | assert.equal(whenShort("2026-10-07T11:45:00.000Z", NOW), "15m ago"); | |
| 61 | assert.equal(whenShort("2026-10-07T09:00:00.000Z", NOW), "3h ago"); | |
| 62 | assert.equal(whenShort("2026-10-06T09:00:00.000Z", NOW), "Yesterday"); | |
| 63 | assert.equal(whenShort("2026-10-03T12:00:00.000Z", NOW), "4d ago"); | |
| 64 | assert.equal(whenShort("2026-09-20T12:00:00.000Z", NOW), "Sep 20"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a snooze is for later, by one of a few choices", () => { | |
| 68 | assert.equal(snoozeUntil("3h", NOW), "2026-10-07T15:00:00.000Z"); | |
| 69 | assert.equal(snoozeUntil("week", NOW), "2026-10-14T12:00:00.000Z"); | |
| 70 | assert.equal(snoozeUntil("forever", NOW), null); | |
| 71 | }); | |
| 72 | ||
| 73 | test("forms ask for one mark on one item, or read for every item", () => { | |
| 74 | const form = (fields: Record<string, string>) => { | |
| 75 | const data = new FormData(); | |
| 76 | for (const [name, value] of Object.entries(fields)) data.set(name, value); | |
| 77 | return data; | |
| 78 | }; | |
| 79 | assert.deepEqual(markFromForm(form({ intent: "done", id: "ntf_1" }), NOW), { mark: "done", ids: ["ntf_1"] }); | |
| 80 | assert.deepEqual(markFromForm(form({ intent: "snooze", id: "ntf_1", for: "tomorrow" }), NOW), { | |
| 81 | mark: "snooze", | |
| 82 | ids: ["ntf_1"], | |
| 83 | until: "2026-10-08T12:00:00.000Z", | |
| 84 | }); | |
| 85 | assert.deepEqual(markFromForm(form({ intent: "read_all", tab: "error" }), NOW), { mark: "read", all: true, severity: "error" }); | |
| 86 | assert.deepEqual(markFromForm(form({ intent: "read_all" }), NOW), { mark: "read", all: true, severity: null }); | |
| 87 | assert.equal(markFromForm(form({ intent: "snooze", id: "ntf_1", for: "never" }), NOW), null); | |
| 88 | assert.equal(markFromForm(form({ intent: "delete", id: "ntf_1" }), NOW), null); | |
| 89 | assert.equal(markFromForm(form({ intent: "done" }), NOW), null); | |
| 90 | }); | |
| 91 | ||
| 92 | test("what needs you puts a waiting agent before a failure, and leaves out the rest", () => { | |
| 93 | const item = (id: string, severity: InboxItem["severity"], createdAt: string, readAt: string | null = null): InboxItem => ({ | |
| 94 | id, | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 95 | reason: "agent", |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 96 | severity, |
| 97 | title: id, | |
| 98 | body: "", | |
| 99 | repo: "acme/rocket", | |
| 100 | workspace: "acme", | |
| 101 | subject: "pull", | |
| 102 | number: 1, | |
| 103 | url: "/acme/rocket/pull/1", | |
| 104 | actor: null, | |
| 105 | createdAt, | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 106 | updatedAt: createdAt, |
| 107 | event: null, | |
| 108 | count: 1, | |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 109 | readAt, |
| 110 | doneAt: null, | |
| 111 | saved: false, | |
| 112 | snoozedUntil: null, | |
| 113 | }); | |
| 114 | const needs = needsYou( | |
| 115 | [ | |
| 116 | item("failed-new", "error", "2026-10-07T11:00:00.000Z"), | |
| 117 | item("waiting-old", "warning", "2026-10-06T11:00:00.000Z"), | |
| 118 | item("waiting-new", "warning", "2026-10-07T10:00:00.000Z"), | |
| 119 | item("merged", "success", "2026-10-07T11:30:00.000Z"), | |
| 120 | item("failed-read", "error", "2026-10-07T11:40:00.000Z", "2026-10-07T11:50:00.000Z"), | |
| 121 | ], | |
| 122 | 2, | |
| 123 | ); | |
| 124 | assert.deepEqual( | |
| 125 | needs.items.map((entry) => entry.id), | |
| 126 | ["waiting-new", "waiting-old"], | |
| 127 | ); | |
| 128 | assert.equal(needs.total, 3); | |
| 129 | }); | |
| 130 | ||
| 131 | test("every tab says something when it is empty", () => { | |
| 132 | assert.equal(emptyFor("all").title, "You're all caught up"); | |
| 133 | assert.equal(emptyFor("needs").title, "Nothing needs you"); | |
| 134 | assert.equal(emptyFor("all", "saved").title, "Nothing saved"); | |
| 135 | }); | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 136 | |
| 137 | test("every reason has words, a filter and an email choice", () => { | |
| 138 | const reasons = Object.keys(REASON_LABEL); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 139 | assert.equal(reasons.length, 12); |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 140 | assert.deepEqual(REASON_FILTERS.slice(1).map((entry) => entry.reason), reasons); |
| 141 | assert.equal(REASON_FILTERS[0].label, "Any reason"); | |
| 142 | assert.equal(REASON_FILTERS.find((entry) => entry.reason === "review_requested")?.label, "Review requested"); | |
| 143 | // Every reason someone can be told for can be emailed, but a security | |
| 144 | // alert, which nothing sends yet. | |
| 145 | assert.deepEqual( | |
| 146 | EMAIL_REASONS.map((entry) => entry.reason).sort(), | |
| A repository's Security overview is one row per feature in three groups, each with a status word (Enabled, Disabled, Needs setup, Coming), a line on what it does and the one thing to do about it: the security policy (View policy, or Set up a security policy, which commits a starting SECURITY.md on a branch and opens it as a pull request), dependency alerts, security updates (the switch, kept on Vulnerabilities and now in Settings too), code scanning (Enabled once an analysis was uploaded), secret scanning (push protection always on) and code quality as Coming; and under Reporting, advisories and the private vulnerability reporting switch. Anyone who can see a public repository but has no role on it sees the public face at the same address: the policy, the published advisories and, when it is on, Report a vulnerability. Advisories get their pages (the list with its state filter, one advisory with Publish, Withdraw and Edit, a form for a new draft or changes) and reports theirs (new ones first, each accepted into a draft advisory or declined), with a Reports page beside Advisories and an Advisories tab; the public report form takes an email when signed out and counts against the front door's heavy limit. Security settings gain Alerts for secrets in the history and Private vulnerability reporting. Settings → Notifications gains a Security section: critical and high alerts at once (always, for the workspaces one owns or manages security for) and the rest in a daily or weekly digest, at once, or not at all, with security alerts now among the email reasons. The notifications guide says how security alerts arrive and what the digest is, the security guide the overview's rows and the public face, secret protection the history alerts switch, webhooks the summary and advisory events, and a new guide covers advisories and private reporting; the report_settings method lets the public page know whether a repository takes reports. | 147 | reasons.sort(), |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 148 | ); |
| 149 | assert.equal(inboxReason("mention"), "mention"); | |
| 150 | assert.equal(inboxReason("gossip"), null); | |
| 151 | assert.equal(inboxReason(null), null); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a thread says how much happened on it once more than one thing has", () => { | |
| 155 | assert.equal(updatesLabel(1), ""); | |
| 156 | assert.equal(updatesLabel(null), ""); | |
| 157 | assert.equal(updatesLabel(4), "4 updates"); | |
| 158 | }); | |
| 159 | ||
| 160 | test("watching is read from the menu, and a custom watch of nothing is the default", () => { | |
| 161 | const form = (fields: [string, string][]) => { | |
| 162 | const data = new FormData(); | |
| 163 | for (const [name, value] of fields) data.append(name, value); | |
| 164 | return data; | |
| 165 | }; | |
| 166 | assert.deepEqual(watchFromForm(form([["level", "all"]])), { level: "all", events: [] }); | |
| 167 | assert.deepEqual(watchFromForm(form([["level", "custom"], ["event", "deployments"], ["event", "pulls"], ["event", "releases"]])), { | |
| 168 | level: "custom", | |
| 169 | events: ["pulls", "deployments"], | |
| 170 | }); | |
| 171 | assert.deepEqual(watchFromForm(form([["level", "custom"]])), { level: "participating", events: [] }); | |
| 172 | assert.equal(watchFromForm(form([["level", "loud"]])), null); | |
| 173 | assert.equal(watchLabel("all"), "Watching"); | |
| 174 | assert.equal(watchLabel("ignore"), "Ignoring"); | |
| 175 | assert.equal(watchLabel("participating"), "Watch"); | |
| 176 | }); | |
| 177 | ||
| 178 | test("the subscribe button says whether, and why", () => { | |
| 179 | const sub = (changes: object) => ({ subscribed: true, ignored: false, reason: null, repo: null, number: 7, updatedAt: null, ...changes }); | |
| 180 | assert.equal(subscriptionLine(sub({ reason: "assign" }), "issue"), "You're subscribed because you were assigned."); | |
| 181 | assert.equal(subscriptionLine(sub({ reason: "author" }), "pull"), "You're subscribed because you opened this pull request, or asked g1t for it."); | |
| 182 | assert.match(subscriptionLine(sub({ subscribed: false }), "issue"), /still hear if you're mentioned/); | |
| 183 | assert.match(subscriptionLine(sub({ subscribed: false, ignored: true }), "issue"), /ignore this issue/); | |
| 184 | assert.match(subscriptionLine(null, "issue"), /^Subscribe/); | |
| 185 | const intent = (value: string) => { | |
| 186 | const data = new FormData(); | |
| 187 | data.set("intent", value); | |
| 188 | return subscriptionFromForm(data); | |
| 189 | }; | |
| 190 | assert.deepEqual(intent("subscribe"), { subscribed: true, ignored: false }); | |
| 191 | assert.deepEqual(intent("unsubscribe"), { subscribed: false, ignored: false }); | |
| 192 | assert.deepEqual(intent("ignore"), { subscribed: false, ignored: true }); | |
| 193 | assert.deepEqual(intent("default"), { subscribed: null, ignored: false }); | |
| 194 | assert.equal(intent("explode"), null); | |
| 195 | }); | |
| 196 | ||
| 197 | test("email reasons are read from the settings form in rank order", () => { | |
| 198 | const data = new FormData(); | |
| 199 | for (const value of ["mention", "nope", "agent", "mention"]) data.append("email", value); | |
| 200 | assert.deepEqual(emailReasonsFromForm(data), ["agent", "mention"]); | |
| 201 | assert.deepEqual(emailReasonsFromForm(new FormData()), []); | |
| 202 | }); | |
| A repository's Security overview is one row per feature in three groups, each with a status word (Enabled, Disabled, Needs setup, Coming), a line on what it does and the one thing to do about it: the security policy (View policy, or Set up a security policy, which commits a starting SECURITY.md on a branch and opens it as a pull request), dependency alerts, security updates (the switch, kept on Vulnerabilities and now in Settings too), code scanning (Enabled once an analysis was uploaded), secret scanning (push protection always on) and code quality as Coming; and under Reporting, advisories and the private vulnerability reporting switch. Anyone who can see a public repository but has no role on it sees the public face at the same address: the policy, the published advisories and, when it is on, Report a vulnerability. Advisories get their pages (the list with its state filter, one advisory with Publish, Withdraw and Edit, a form for a new draft or changes) and reports theirs (new ones first, each accepted into a draft advisory or declined), with a Reports page beside Advisories and an Advisories tab; the public report form takes an email when signed out and counts against the front door's heavy limit. Security settings gain Alerts for secrets in the history and Private vulnerability reporting. Settings → Notifications gains a Security section: critical and high alerts at once (always, for the workspaces one owns or manages security for) and the rest in a daily or weekly digest, at once, or not at all, with security alerts now among the email reasons. The notifications guide says how security alerts arrive and what the digest is, the security guide the overview's rows and the public face, secret protection the history alerts switch, webhooks the summary and advisory events, and a new guide covers advisories and private reporting; the report_settings method lets the public page know whether a repository takes reports. | 203 | |
| 204 | test("security alerts that are not urgent reach people as they choose, daily until they do", () => { | |
| 205 | assert.deepEqual( | |
| 206 | SECURITY_DIGEST_CHOICES.map((choice) => choice.cadence), | |
| 207 | ["daily", "weekly", "at_once", "off"], | |
| 208 | ); | |
| 209 | const data = new FormData(); | |
| 210 | data.set("security_digest", "weekly"); | |
| 211 | assert.equal(securityDigestFromForm(data), "weekly"); | |
| 212 | data.set("security_digest", "hourly"); | |
| 213 | assert.equal(securityDigestFromForm(data), null); | |
| 214 | assert.equal(securityDigestFromForm(new FormData()), null); | |
| 215 | // Security alerts are among the reasons one can be emailed for. | |
| 216 | assert.ok(EMAIL_REASONS.some((entry) => entry.reason === "security_alert")); | |
| 217 | }); |