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
273 linesCodeBlameRaw
1/**
2 * The inbox: what needs a person, or what they follow, as it happens. The
3 * events service keeps it and brings each person's thread about a thing
4 * (an issue, a pull request, a workflow on a branch, a deployment) back to
5 * the top as things happen to it. Mirrors `crates/contracts/src/inbox.rs`,
6 * which says who is told of what, and why.
7 */
8import type { ServiceBinding } from "./clients";
9import type { User } from "./identity";
10
11/**
12 * How much an item matters: a failure, something a person must answer (an
13 * agent waiting on them, a review asked of them), something that went well,
14 * or something to know.
15 */
16export type InboxSeverity = "error" | "warning" | "success" | "info";
17
18export const INBOX_SEVERITIES: InboxSeverity[] = ["error", "warning", "success", "info"];
19
20/**
21 * Why a person was told: what the thread asked of them, or what ties them
22 * to it. Most specific first.
23 */
24export type 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
38export const INBOX_REASONS: InboxReason[] = [
39 "agent",
40 "review_requested",
41 "assign",
42 "mention",
43 "team_mention",
44 "ci_activity",
45 "security_alert",
46 "state_change",
47 "author",
48 "comment",
49 "manual",
50 "subscribed",
51];
52
53export type InboxSubjectKind = "issue" | "pull" | "run" | "deploy";
54
55/** One thread in a person's inbox: what it is about, and its latest activity. */
56export type InboxItem = {
57 /** The thread's id: the same for as long as the person has it. */
58 id: string;
59 /** Why they were told of the latest activity. */
60 reason: InboxReason;
61 /** While unread, the most urgent of what happened since it was last read. */
62 severity: InboxSeverity;
63 /** One line: what happened last, and where. */
64 title: string;
65 /** One line: what it happened to, such as the pull request's title. */
66 body: string;
67 /** The event behind the latest activity, such as `pull.merged`. */
68 event: string | null;
69 /** `owner/name`. */
70 repo: string | null;
71 workspace: string | null;
72 subject: InboxSubjectKind | null;
73 number: number | null;
74 /** A path on g1t.sh, such as `/acme/rocket/pull/12`. */
75 url: string;
76 /** A username, or `g1t`. */
77 actor: string | null;
78 /** How many things have happened on the thread. */
79 count: number;
80 /** RFC 3339: when the person was first told of the thread. */
81 createdAt: string;
82 /** RFC 3339: its latest activity. */
83 updatedAt: string;
84 readAt: string | null;
85 doneAt: string | null;
86 saved: boolean;
87 snoozedUntil: string | null;
88 /**
89 * Why it settled by itself, in a few words (`CI passed in run 330`,
90 * `Merged`): set when the event that left nothing to do marked it done.
91 * Absent when it is not done, or was marked done by hand.
92 */
93 settledReason?: string | null;
94 /** Who settled it, when a person or g1t did: a username, or `g1t`. */
95 settledBy?: string | null;
96};
97
98/** One thing that happened on a thread, as the person was told of it. */
99export type InboxActivity = {
100 reason: InboxReason;
101 severity: InboxSeverity;
102 title: string;
103 body: string;
104 event: string | null;
105 actor: string | null;
106 createdAt: string;
107};
108
109/** A person's subscription to an issue or pull request. */
110export type ThreadSubscription = {
111 /** Whether they hear of what happens on it. */
112 subscribed: boolean;
113 /** Whether they hear of nothing on it at all, not even a mention. */
114 ignored: boolean;
115 /** Why they are subscribed; null when they are not. */
116 reason: InboxReason | null;
117 repo: string | null;
118 number: number | null;
119 /** When they last chose, or null if they never did. */
120 updatedAt: string | null;
121};
122
123/** A thread with its history, newest first, and the person's subscription. */
124export type InboxThread = InboxItem & {
125 activity: InboxActivity[];
126 subscription: ThreadSubscription | null;
127};
128
129/** How closely a person follows a repository. */
130export type WatchLevel = "participating" | "all" | "ignore" | "custom";
131
132export const WATCH_LEVELS: WatchLevel[] = ["participating", "all", "ignore", "custom"];
133
134/** The kinds of activity a custom watch can follow. */
135export type WatchEvent = "issues" | "pulls" | "deployments" | "security";
136
137export const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"];
138
139export type Watching = {
140 repoId: string;
141 repo: string | null;
142 level: WatchLevel;
143 /** With `custom`: what it follows. */
144 events: WatchEvent[];
145 updatedAt: string | null;
146};
147
148/**
149 * When security alerts that are not urgent (medium, low, and what is only to
150 * know) reach a person: in a digest once a day or once a week, at once like
151 * the urgent ones, or not at all. Critical and high alerts, a refused push
152 * and a vulnerability report never wait for the digest.
153 */
154export type DigestCadence = "daily" | "weekly" | "at_once" | "off";
155
156export const DIGEST_CADENCES: DigestCadence[] = ["daily", "weekly", "at_once", "off"];
157
158/** A person's choices about being told. */
159export type InboxSettings = {
160 /** The reasons they are also emailed for. */
161 email: InboxReason[];
162 /** How they watch a repository they create. */
163 defaultWatch: WatchLevel;
164 /**
165 * Whether critical and high security alerts on repositories they watch for
166 * security reach their inbox at once. A workspace's owners and security
167 * managers are told whatever this says.
168 */
169 securityAlerts: boolean;
170 /** When the rest of the security alerts reach them. */
171 securityDigest: DigestCadence;
172};
173
174/** The inbox itself (not done, not snoozed), what was saved, or what is done. */
175export type InboxView = "inbox" | "saved" | "done";
176
177export type InboxQuery = {
178 view?: InboxView;
179 severity?: InboxSeverity | null;
180 reason?: InboxReason | null;
181 /** Only threads the person takes part in: not those followed by watching or by hand. */
182 participating?: boolean;
183 repoId?: string | null;
184 unread?: boolean;
185 /** RFC 3339: only threads with activity at or after it. */
186 since?: string | null;
187 /** RFC 3339: only threads whose latest activity was before it. */
188 updatedBefore?: string | null;
189 /** The `next` of the page before. */
190 before?: string | null;
191 /** At most 100. */
192 limit?: number;
193};
194
195export type InboxPage = { items: InboxItem[]; next: string | null };
196
197/** Unread items in the inbox, by severity. */
198export type InboxCounts = { unread: number } & Record<InboxSeverity, number>;
199
200export type InboxMark = "read" | "unread" | "done" | "undone" | "save" | "unsave" | "snooze" | "unsnooze";
201
202export type InboxMarkArgs = {
203 mark: InboxMark;
204 /** At most 100. */
205 ids?: string[];
206 /** Every item in the inbox, when `ids` is empty: Mark all read. */
207 all?: boolean;
208 severity?: InboxSeverity | null;
209 repoId?: string | null;
210 /** With `all`: only threads whose latest activity was at or before this. */
211 lastReadAt?: string | null;
212 /** For `snooze`: RFC 3339, later than now. */
213 until?: string | null;
214};
215
216/** Which issue or pull request: a thread's id, or a repository and number. */
217export type SubscriptionTarget = { id: string } | { repoId: string; number: number };
218
219export interface InboxApi {
220 /**
221 * Latest activity first; in the inbox unfiltered, unread warnings first.
222 * Items about a repository the viewer can no longer read are dropped.
223 */
224 list(viewer: User, query?: InboxQuery): Promise<InboxPage>;
225 counts(username: string): Promise<InboxCounts>;
226 /** Changes the person's own items. Returns how many changed. */
227 mark(username: string, args: InboxMarkArgs): Promise<number>;
228 /** One of the viewer's threads with its history, or null. */
229 thread(viewer: User, id: string): Promise<InboxThread | null>;
230 /** The viewer's subscription to an issue or pull request; null when there is none to have. */
231 subscription(viewer: User, target: SubscriptionTarget): Promise<ThreadSubscription | null>;
232 /**
233 * Subscribes (`true`), unsubscribes (`false`), ignores, or goes back to
234 * the default (`null`, subscribed only while taking part).
235 */
236 subscribe(viewer: User, target: SubscriptionTarget, subscribed: boolean | null, ignored?: boolean): Promise<ThreadSubscription | null>;
237 watching(username: string, repoId: string): Promise<Watching>;
238 /** `null` goes back to the default, participating. */
239 watch(username: string, repoId: string, repo: string, level: WatchLevel | null, events?: WatchEvent[]): Promise<Watching>;
240 watched(username: string): Promise<Watching[]>;
241 /** How many watch a repository: all of it, or some of it (custom). */
242 watchers(repoId: string): Promise<number>;
243 settings(username: string): Promise<InboxSettings>;
244 updateSettings(username: string, changes: Partial<InboxSettings>): Promise<InboxSettings>;
245}
246
247/** The inbox, which the events service keeps. */
248export function inboxClient(events: ServiceBinding): InboxApi {
249 const call = async <T>(method: string, args: object): Promise<T> => {
250 const response = await events.fetch(`https://service/rpc/${method}`, {
251 method: "POST",
252 headers: { "content-type": "application/json" },
253 body: JSON.stringify(args),
254 });
255 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
256 return (await response.json()) as T;
257 };
258 return {
259 list: (viewer, query = {}) => call("inbox_list", { viewer, ...query }),
260 counts: (username) => call("inbox_counts", { username }),
261 mark: (username, args) => call("inbox_mark", { username, ...args }),
262 thread: (viewer, id) => call("inbox_thread", { viewer, id }),
263 subscription: (viewer, target) => call("inbox_subscription", { viewer, ...target }),
264 subscribe: (viewer, target, subscribed, ignored = false) =>
265 call("inbox_subscribe", { viewer, ...target, subscribed, ignored }),
266 watching: (username, repoId) => call("inbox_watching", { username, repoId }),
267 watch: (username, repoId, repo, level, events = []) => call("inbox_watch", { username, repoId, repo, level, events }),
268 watched: (username) => call("inbox_watched", { username }),
269 watchers: (repoId) => call("inbox_watchers", { repoId }),
270 settings: (username) => call("inbox_settings", { username }),
271 updateSettings: (username, changes) => call("inbox_update_settings", { username, ...changes }),
272 };
273}