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.

g1t.sh
337 linesCodeBlameRaw

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 all1/**
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings2 * 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 all7 */
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings8import 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 settings10 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.
23const 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 bar28 "team_mention",
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings29 "ci_activity",
30 "security_alert",
31 "state_change",
32 "author",
33 "comment",
34 "manual",
35 "subscribed",
36];
37const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"];
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all38
39export type InboxTab = "all" | "needs" | "error" | "success" | "info";
40
41/** The tabs, in order, and the severity each shows. */
42export 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 settings44 // 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 all45 { 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. */
52export const SEVERITY_LABEL: Record<InboxSeverity, string> = {
53 error: "Error",
54 warning: "Needs you",
55 success: "Success",
56 info: "Info",
57};
58
59export function inboxTab(value: string | null | undefined): InboxTab {
60 return INBOX_TABS.find((entry) => entry.tab === value)?.tab ?? "all";
61}
62
63export function severityOf(tab: InboxTab): InboxSeverity | null {
64 return INBOX_TABS.find((entry) => entry.tab === tab)?.severity ?? null;
65}
66
67export function inboxView(value: string | null | undefined): InboxView {
68 return value === "saved" || value === "done" ? value : "inbox";
69}
70
71/** What is unread under a tab. */
72export 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. */
79export 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. */
85export 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 settings90 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 all91 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
102const MINUTE = 60_000;
103const HOUR = 60 * MINUTE;
104const DAY = 24 * HOUR;
105
106/** When, in a few characters: "just now", "15m ago", "3h ago", "Yesterday", "4d ago", "Sep 30". */
107export 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. */
119export 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
125export 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`. */
131const 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 */
146export 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 */
166export 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 settings170 .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 all171 return { items: needs.slice(0, max), total: needs.length };
172}
173
174/** Whether an item is still unread. */
175export function isUnread(item: Pick<InboxItem, "readAt">): boolean {
176 return item.readAt == null;
177}
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings178
179/** Why someone was told, in a few quiet words on the card. */
180export 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 bar185 team_mention: "team mentioned",
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings186 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. */
196export 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. */
201export 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". */
207export 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. */
212export 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. */
220export 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. */
228export 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 */
246export 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. */
256export 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 bar272 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 settings274 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. */
280export 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. */
296export 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 bar300 { 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 settings301 { 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 settings308 { 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. */
316export 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 */
326export 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. */
334export 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}