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.

276 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 * (a work item, 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 = "item" | "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 a work item 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 = "work_items" | "pulls" | "deployments" | "security";
136
137export const WATCH_EVENTS: WatchEvent[] = ["work_items", "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/**
217 * Which work item or pull request: a thread's id, a work item's id (with its
218 * number, to say back), or a pull request's repository and number.
219 */
220export type SubscriptionTarget = { id: string } | { itemId: string; number: number } | { repoId: string; number: number };
221
222export interface InboxApi {
223 /**
224 * Latest activity first; in the inbox unfiltered, unread warnings first.
225 * Items about a repository the viewer can no longer read are dropped.
226 */
227 list(viewer: User, query?: InboxQuery): Promise<InboxPage>;
228 counts(username: string): Promise<InboxCounts>;
229 /** Changes the person's own items. Returns how many changed. */
230 mark(username: string, args: InboxMarkArgs): Promise<number>;
231 /** One of the viewer's threads with its history, or null. */
232 thread(viewer: User, id: string): Promise<InboxThread | null>;
233 /** The viewer's subscription to a work item or pull request; null when there is none to have. */
234 subscription(viewer: User, target: SubscriptionTarget): Promise<ThreadSubscription | null>;
235 /**
236 * Subscribes (`true`), unsubscribes (`false`), ignores, or goes back to
237 * the default (`null`, subscribed only while taking part).
238 */
239 subscribe(viewer: User, target: SubscriptionTarget, subscribed: boolean | null, ignored?: boolean): Promise<ThreadSubscription | null>;
240 watching(username: string, repoId: string): Promise<Watching>;
241 /** `null` goes back to the default, participating. */
242 watch(username: string, repoId: string, repo: string, level: WatchLevel | null, events?: WatchEvent[]): Promise<Watching>;
243 watched(username: string): Promise<Watching[]>;
244 /** How many watch a repository: all of it, or some of it (custom). */
245 watchers(repoId: string): Promise<number>;
246 settings(username: string): Promise<InboxSettings>;
247 updateSettings(username: string, changes: Partial<InboxSettings>): Promise<InboxSettings>;
248}
249
250/** The inbox, which the events service keeps. */
251export function inboxClient(events: ServiceBinding): InboxApi {
252 const call = async <T>(method: string, args: object): Promise<T> => {
253 const response = await events.fetch(`https://service/rpc/${method}`, {
254 method: "POST",
255 headers: { "content-type": "application/json" },
256 body: JSON.stringify(args),
257 });
258 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
259 return (await response.json()) as T;
260 };
261 return {
262 list: (viewer, query = {}) => call("inbox_list", { viewer, ...query }),
263 counts: (username) => call("inbox_counts", { username }),
264 mark: (username, args) => call("inbox_mark", { username, ...args }),
265 thread: (viewer, id) => call("inbox_thread", { viewer, id }),
266 subscription: (viewer, target) => call("inbox_subscription", { viewer, ...target }),
267 subscribe: (viewer, target, subscribed, ignored = false) =>
268 call("inbox_subscribe", { viewer, ...target, subscribed, ignored }),
269 watching: (username, repoId) => call("inbox_watching", { username, repoId }),
270 watch: (username, repoId, repo, level, events = []) => call("inbox_watch", { username, repoId, repo, level, events }),
271 watched: (username) => call("inbox_watched", { username }),
272 watchers: (repoId) => call("inbox_watchers", { repoId }),
273 settings: (username) => call("inbox_settings", { username }),
274 updateSettings: (username, changes) => call("inbox_update_settings", { username, ...changes }),
275 };
276}