Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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