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 | /** |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 2 | * The inbox's tabs, reasons, times and actions, shared by the panel in the |
| 3 | * top bar (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx), | |
| 4 | * and how people subscribe to threads and watch repositories | |
| 5 | * (components/notifications.tsx). The events service keeps the items and | |
| 6 | * decides who is told of what. | |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 7 | */ |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 8 | import type { |
| 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 | DigestCadence, |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 10 | InboxCounts, |
| 11 | InboxItem, | |
| 12 | InboxMarkArgs, | |
| 13 | InboxReason, | |
| 14 | InboxSeverity, | |
| 15 | InboxView, | |
| 16 | ThreadSubscription, | |
| 17 | WatchEvent, | |
| 18 | WatchLevel, | |
| 19 | } from "@g1t/contracts"; | |
| 20 | ||
| 21 | // The contracts' lists, as types only, so this file runs under `node --test`. | |
| 22 | // Typed by the contract: a reason or kind added there must be added here. | |
| 23 | const INBOX_REASONS: InboxReason[] = [ | |
| 24 | "agent", | |
| 25 | "review_requested", | |
| 26 | "assign", | |
| 27 | "mention", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 28 | "team_mention", |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 29 | "ci_activity", |
| 30 | "security_alert", | |
| 31 | "state_change", | |
| 32 | "author", | |
| 33 | "comment", | |
| 34 | "manual", | |
| 35 | "subscribed", | |
| 36 | ]; | |
| 37 | const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"]; | |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 38 | |
| 39 | export type InboxTab = "all" | "needs" | "error" | "success" | "info"; | |
| 40 | ||
| 41 | /** The tabs, in order, and the severity each shows. */ | |
| 42 | export const INBOX_TABS: { tab: InboxTab; label: string; severity: InboxSeverity | null }[] = [ | |
| 43 | { tab: "all", label: "All", severity: null }, | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 44 | // Warnings are what is waiting on a person: an agent, or a review asked of them. |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 45 | { tab: "needs", label: "Needs you", severity: "warning" }, |
| 46 | { tab: "error", label: "Errors", severity: "error" }, | |
| 47 | { tab: "success", label: "Success", severity: "success" }, | |
| 48 | { tab: "info", label: "Info", severity: "info" }, | |
| 49 | ]; | |
| 50 | ||
| 51 | /** The words on an item's badge. */ | |
| 52 | export const SEVERITY_LABEL: Record<InboxSeverity, string> = { | |
| 53 | error: "Error", | |
| 54 | warning: "Needs you", | |
| 55 | success: "Success", | |
| 56 | info: "Info", | |
| 57 | }; | |
| 58 | ||
| 59 | export function inboxTab(value: string | null | undefined): InboxTab { | |
| 60 | return INBOX_TABS.find((entry) => entry.tab === value)?.tab ?? "all"; | |
| 61 | } | |
| 62 | ||
| 63 | export function severityOf(tab: InboxTab): InboxSeverity | null { | |
| 64 | return INBOX_TABS.find((entry) => entry.tab === tab)?.severity ?? null; | |
| 65 | } | |
| 66 | ||
| 67 | export function inboxView(value: string | null | undefined): InboxView { | |
| 68 | return value === "saved" || value === "done" ? value : "inbox"; | |
| 69 | } | |
| 70 | ||
| 71 | /** What is unread under a tab. */ | |
| 72 | export function tabCount(counts: InboxCounts | null | undefined, tab: InboxTab): number { | |
| 73 | if (!counts) return 0; | |
| 74 | const severity = severityOf(tab); | |
| 75 | return severity ? counts[severity] : counts.unread; | |
| 76 | } | |
| 77 | ||
| 78 | /** The bell's number: up to 99, then "99+". Empty when nothing is unread. */ | |
| 79 | export function bellCount(unread: number | null | undefined): string { | |
| 80 | if (!unread || unread < 1) return ""; | |
| 81 | return unread > 99 ? "99+" : String(unread); | |
| 82 | } | |
| 83 | ||
| 84 | /** What an empty tab says. */ | |
| 85 | export function emptyFor(tab: InboxTab, view: InboxView = "inbox"): { title: string; detail: string } { | |
| 86 | if (view === "saved") return { title: "Nothing saved", detail: "Save an item to keep it here after it is done." }; | |
| 87 | if (view === "done") return { title: "Nothing done yet", detail: "Items you mark done move here." }; | |
| 88 | switch (tab) { | |
| 89 | case "needs": | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 90 | return { title: "Nothing needs you", detail: "When an agent is waiting on you, or someone asks for your review, it shows up here first." }; |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 91 | case "error": |
| 92 | return { title: "No failures", detail: "Failed checks and workflows on your work show up here." }; | |
| 93 | case "success": | |
| 94 | return { title: "Nothing new landed", detail: "Merges, approvals and finished agent work show up here." }; | |
| 95 | case "info": | |
| 96 | return { title: "No mentions or comments", detail: "Mentions of you and comments on your work show up here." }; | |
| 97 | default: | |
| 98 | return { title: "You're all caught up", detail: "What needs you, or what you follow, shows up here as it happens." }; | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | const MINUTE = 60_000; | |
| 103 | const HOUR = 60 * MINUTE; | |
| 104 | const DAY = 24 * HOUR; | |
| 105 | ||
| 106 | /** When, in a few characters: "just now", "15m ago", "3h ago", "Yesterday", "4d ago", "Sep 30". */ | |
| 107 | export function whenShort(at: string, now: number): string { | |
| 108 | const time = Date.parse(at); | |
| 109 | const elapsed = Math.max(0, now - time); | |
| 110 | if (elapsed < MINUTE) return "just now"; | |
| 111 | if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`; | |
| 112 | if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`; | |
| 113 | if (elapsed < 2 * DAY) return "Yesterday"; | |
| 114 | if (elapsed < 7 * DAY) return `${Math.floor(elapsed / DAY)}d ago`; | |
| 115 | return new Date(time).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); | |
| 116 | } | |
| 117 | ||
| 118 | /** How long an item can be snoozed for. */ | |
| 119 | export const SNOOZES = [ | |
| 120 | { key: "3h", label: "3 hours", ms: 3 * HOUR }, | |
| 121 | { key: "tomorrow", label: "Tomorrow", ms: DAY }, | |
| 122 | { key: "week", label: "Next week", ms: 7 * DAY }, | |
| 123 | ] as const; | |
| 124 | ||
| 125 | export function snoozeUntil(key: string | null | undefined, now: number): string | null { | |
| 126 | const snooze = SNOOZES.find((entry) => entry.key === key); | |
| 127 | return snooze ? new Date(now + snooze.ms).toISOString() : null; | |
| 128 | } | |
| 129 | ||
| 130 | /** What the inbox forms post, by `intent`. */ | |
| 131 | const MARKS: Record<string, InboxMarkArgs["mark"]> = { | |
| 132 | read: "read", | |
| 133 | unread: "unread", | |
| 134 | done: "done", | |
| 135 | undone: "undone", | |
| 136 | save: "save", | |
| 137 | unsave: "unsave", | |
| 138 | snooze: "snooze", | |
| 139 | }; | |
| 140 | ||
| 141 | /** | |
| 142 | * A posted inbox form as the mark it asks for: one item (`id`), or every | |
| 143 | * item (`intent` `read_all`, optionally for one tab). Null when it asks for | |
| 144 | * nothing the inbox does. | |
| 145 | */ | |
| 146 | export function markFromForm(form: FormData, now: number): InboxMarkArgs | null { | |
| 147 | const intent = String(form.get("intent") ?? ""); | |
| 148 | if (intent === "read_all") { | |
| 149 | return { mark: "read", all: true, severity: severityOf(inboxTab(String(form.get("tab") ?? ""))) }; | |
| 150 | } | |
| 151 | const mark = MARKS[intent]; | |
| 152 | const id = String(form.get("id") ?? "").trim(); | |
| 153 | if (!mark || !id) return null; | |
| 154 | if (mark === "snooze") { | |
| 155 | const until = snoozeUntil(String(form.get("for") ?? ""), now); | |
| 156 | return until ? { mark, ids: [id], until } : null; | |
| 157 | } | |
| 158 | return { mark, ids: [id] }; | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Mission control's Needs you card, from unread items: what an agent is | |
| 163 | * waiting on first, then failures, newest first within each; `max` of them, | |
| 164 | * and how many there are in all. | |
| 165 | */ | |
| 166 | export function needsYou(items: InboxItem[], max: number): { items: InboxItem[]; total: number } { | |
| 167 | const rank = (item: InboxItem) => (item.severity === "warning" ? 0 : 1); | |
| 168 | const needs = items | |
| 169 | .filter((item) => isUnread(item) && (item.severity === "warning" || item.severity === "error")) | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 170 | .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt)); |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 171 | return { items: needs.slice(0, max), total: needs.length }; |
| 172 | } | |
| 173 | ||
| 174 | /** Whether an item is still unread. */ | |
| 175 | export function isUnread(item: Pick<InboxItem, "readAt">): boolean { | |
| 176 | return item.readAt == null; | |
| 177 | } | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 178 | |
| 179 | /** Why someone was told, in a few quiet words on the card. */ | |
| 180 | export const REASON_LABEL: Record<InboxReason, string> = { | |
| 181 | agent: "agent waiting", | |
| 182 | review_requested: "review requested", | |
| 183 | assign: "assigned", | |
| 184 | mention: "mentioned", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 185 | team_mention: "team mentioned", |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 186 | ci_activity: "CI activity", |
| 187 | security_alert: "security alert", | |
| 188 | state_change: "state changed", | |
| 189 | author: "your work", | |
| 190 | comment: "commented", | |
| 191 | manual: "subscribed", | |
| 192 | subscribed: "watching", | |
| 193 | }; | |
| 194 | ||
| 195 | /** The reason filter on /inbox, from the address: null for any reason. */ | |
| 196 | export function inboxReason(value: string | null | undefined): InboxReason | null { | |
| 197 | return INBOX_REASONS.find((reason) => reason === value) ?? null; | |
| 198 | } | |
| 199 | ||
| 200 | /** The reason filter's choices, in the order reasons rank. */ | |
| 201 | export const REASON_FILTERS: { reason: InboxReason | null; label: string }[] = [ | |
| 202 | { reason: null, label: "Any reason" }, | |
| 203 | ...INBOX_REASONS.map((reason) => ({ reason, label: REASON_LABEL[reason].replace(/^./, (c) => c.toUpperCase()) })), | |
| 204 | ]; | |
| 205 | ||
| 206 | /** How much has happened on a thread, when more than one thing has: "3 updates". */ | |
| 207 | export function updatesLabel(count: number | null | undefined): string { | |
| 208 | return count && count > 1 ? `${count} updates` : ""; | |
| 209 | } | |
| 210 | ||
| 211 | /** The ways to watch a repository, in the order the menu shows them. */ | |
| 212 | export const WATCH_CHOICES: { level: WatchLevel; label: string; detail: string }[] = [ | |
| 213 | { level: "participating", label: "Participating and @mentions", detail: "Only what you take part in, or are mentioned in." }, | |
| 214 | { level: "all", label: "All activity", detail: "Every issue and pull request, and every deployment." }, | |
| 215 | { level: "ignore", label: "Ignore", detail: "Nothing at all, not even a mention." }, | |
| 216 | { level: "custom", label: "Custom", detail: "What you take part in, and the kinds you choose." }, | |
| 217 | ]; | |
| 218 | ||
| 219 | /** The kinds a custom watch can follow, with their names for people. */ | |
| 220 | export const WATCH_EVENT_LABEL: Record<WatchEvent, string> = { | |
| 221 | issues: "Issues", | |
| 222 | pulls: "Pull requests", | |
| 223 | deployments: "Deployments", | |
| 224 | security: "Security alerts", | |
| 225 | }; | |
| 226 | ||
| 227 | /** The Watch button's words for how someone watches. */ | |
| 228 | export function watchLabel(level: WatchLevel | null | undefined): string { | |
| 229 | switch (level) { | |
| 230 | case "all": | |
| 231 | return "Watching"; | |
| 232 | case "custom": | |
| 233 | return "Watching some"; | |
| 234 | case "ignore": | |
| 235 | return "Ignoring"; | |
| 236 | default: | |
| 237 | return "Watch"; | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * A posted watch form as the level and kinds it asks for: `level`, and for | |
| 243 | * `custom` the `event` fields checked. A custom watch with nothing checked | |
| 244 | * is participating. Null when the level is not one. | |
| 245 | */ | |
| 246 | export function watchFromForm(form: FormData): { level: WatchLevel; events: WatchEvent[] } | null { | |
| 247 | const level = String(form.get("level") ?? ""); | |
| 248 | if (!["participating", "all", "ignore", "custom"].includes(level)) return null; | |
| 249 | if (level !== "custom") return { level: level as WatchLevel, events: [] }; | |
| 250 | const checked = form.getAll("event").map(String); | |
| 251 | const events = WATCH_EVENTS.filter((event) => checked.includes(event)); | |
| 252 | return events.length > 0 ? { level: "custom", events } : { level: "participating", events: [] }; | |
| 253 | } | |
| 254 | ||
| 255 | /** The line under the subscribe button: whether, and why. */ | |
| 256 | export function subscriptionLine(subscription: ThreadSubscription | null | undefined, kind: "issue" | "pull"): string { | |
| 257 | const thing = kind === "pull" ? "pull request" : "issue"; | |
| 258 | if (!subscription) return `Subscribe to hear of what happens on this ${thing}.`; | |
| 259 | if (subscription.ignored) return `You ignore this ${thing}: you hear of nothing on it, not even a mention.`; | |
| 260 | if (!subscription.subscribed) return "You're not subscribed. You'll still hear if you're mentioned or asked to review."; | |
| 261 | switch (subscription.reason) { | |
| 262 | case "author": | |
| 263 | return `You're subscribed because you opened this ${thing}, or asked g1t for it.`; | |
| 264 | case "assign": | |
| 265 | return "You're subscribed because you were assigned."; | |
| 266 | case "review_requested": | |
| 267 | return "You're subscribed because you were asked to review."; | |
| 268 | case "comment": | |
| 269 | return "You're subscribed because you commented."; | |
| 270 | case "mention": | |
| 271 | return "You're subscribed because you were mentioned."; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 272 | case "team_mention": |
| 273 | return "You're subscribed because a team you're in was mentioned."; | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 274 | default: |
| 275 | return `You're subscribed to this ${thing}.`; | |
| 276 | } | |
| 277 | } | |
| 278 | ||
| 279 | /** What a posted subscription form asks for: subscribe, unsubscribe, ignore, or the default. */ | |
| 280 | export function subscriptionFromForm(form: FormData): { subscribed: boolean | null; ignored: boolean } | null { | |
| 281 | switch (String(form.get("intent") ?? "")) { | |
| 282 | case "subscribe": | |
| 283 | return { subscribed: true, ignored: false }; | |
| 284 | case "unsubscribe": | |
| 285 | return { subscribed: false, ignored: false }; | |
| 286 | case "ignore": | |
| 287 | return { subscribed: false, ignored: true }; | |
| 288 | case "default": | |
| 289 | return { subscribed: null, ignored: false }; | |
| 290 | default: | |
| 291 | return null; | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | /** The reasons someone can be emailed for, in the settings' order, with what each is. */ | |
| 296 | export const EMAIL_REASONS: { reason: InboxReason; label: string; detail: string }[] = [ | |
| 297 | { reason: "agent", label: "An agent is waiting on you", detail: "It asked you something, or stopped until you step in." }, | |
| 298 | { reason: "review_requested", label: "You're asked to review", detail: "Someone asked for your review of a pull request." }, | |
| 299 | { reason: "mention", label: "You're mentioned", detail: "Someone wrote your @username in a comment." }, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 300 | { reason: "team_mention", label: "Your team is mentioned", detail: "Someone wrote @workspace/team for a team you're in." }, |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 301 | { reason: "assign", label: "You're assigned", detail: "Someone assigned you an issue or a pull request." }, |
| 302 | { reason: "ci_activity", label: "Your work's checks and deployments", detail: "Checks, a workflow or a deployment failed on your work." }, | |
| 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. | 303 | { |
| 304 | reason: "security_alert", | |
| 305 | label: "Security alerts", | |
| 306 | detail: "Critical and high alerts, a push of yours that push protection refused, a vulnerability report, and your security digest.", | |
| 307 | }, | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 308 | { reason: "state_change", label: "What you follow closes or merges", detail: "An issue or pull request you're subscribed to was closed, reopened or merged." }, |
| 309 | { reason: "author", label: "News on your work", detail: "An approval, changes asked for, or g1t finishing what you asked for." }, | |
| 310 | { reason: "comment", label: "Conversations you're in", detail: "Comments on issues and pull requests you commented on." }, | |
| 311 | { reason: "manual", label: "Threads you subscribed to", detail: "Activity on issues and pull requests you subscribed to by hand." }, | |
| 312 | { reason: "subscribed", label: "Repositories you watch", detail: "Activity in repositories you watch." }, | |
| 313 | ]; | |
| 314 | ||
| 315 | /** The reasons checked on the settings form, each once, in rank order. */ | |
| 316 | export function emailReasonsFromForm(form: FormData): InboxReason[] { | |
| 317 | const checked = form.getAll("email").map(String); | |
| 318 | return INBOX_REASONS.filter((reason) => checked.includes(reason)); | |
| 319 | } | |
| 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. | 320 | |
| 321 | /** | |
| 322 | * When security alerts that are not urgent (medium and low) reach a person, | |
| 323 | * in the settings' order. Critical and high alerts, a refused push and a | |
| 324 | * vulnerability report come at once whatever is chosen here. | |
| 325 | */ | |
| 326 | export const SECURITY_DIGEST_CHOICES: { cadence: DigestCadence; label: string; detail: string }[] = [ | |
| 327 | { cadence: "daily", label: "In a daily digest", detail: "One item and email a day per workspace, in the morning (UTC), with what was found." }, | |
| 328 | { cadence: "weekly", label: "In a weekly digest", detail: "The same, on Monday mornings." }, | |
| 329 | { cadence: "at_once", label: "At once", detail: "Each scan that finds something is an item of its own, as the urgent ones are." }, | |
| 330 | { cadence: "off", label: "Not at all", detail: "Only critical and high alerts reach you. The findings are still on each repository's Security page." }, | |
| 331 | ]; | |
| 332 | ||
| 333 | /** The cadence a settings form posts, or null for anything else. */ | |
| 334 | export function securityDigestFromForm(form: FormData): DigestCadence | null { | |
| 335 | const given = String(form.get("security_digest") ?? ""); | |
| 336 | return SECURITY_DIGEST_CHOICES.find((choice) => choice.cadence === given)?.cadence ?? null; | |
| 337 | } |