Skip to content

g1t/packages/contracts/src/inbox.ts

244 lines8,991 bytesCodeBlame
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
90/** One thing that happened on a thread, as the person was told of it. */
91export type InboxActivity = {
92 reason: InboxReason;
93 severity: InboxSeverity;
94 title: string;
95 body: string;
96 event: string | null;
97 actor: string | null;
98 createdAt: string;
99};
100
101/** A person's subscription to an issue or pull request. */
102export type ThreadSubscription = {
103 /** Whether they hear of what happens on it. */
104 subscribed: boolean;
105 /** Whether they hear of nothing on it at all, not even a mention. */
106 ignored: boolean;
107 /** Why they are subscribed; null when they are not. */
108 reason: InboxReason | null;
109 repo: string | null;
110 number: number | null;
111 /** When they last chose, or null if they never did. */
112 updatedAt: string | null;
113};
114
115/** A thread with its history, newest first, and the person's subscription. */
116export type InboxThread = InboxItem & {
117 activity: InboxActivity[];
118 subscription: ThreadSubscription | null;
119};
120
121/** How closely a person follows a repository. */
122export type WatchLevel = "participating" | "all" | "ignore" | "custom";
123
124export const WATCH_LEVELS: WatchLevel[] = ["participating", "all", "ignore", "custom"];
125
126/** The kinds of activity a custom watch can follow. */
127export type WatchEvent = "issues" | "pulls" | "deployments" | "security";
128
129export const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"];
130
131export type Watching = {
132 repoId: string;
133 repo: string | null;
134 level: WatchLevel;
135 /** With `custom`: what it follows. */
136 events: WatchEvent[];
137 updatedAt: string | null;
138};
139
140/** A person's choices about being told. */
141export type InboxSettings = {
142 /** The reasons they are also emailed for. */
143 email: InboxReason[];
144 /** How they watch a repository they create. */
145 defaultWatch: WatchLevel;
146};
147
148/** The inbox itself (not done, not snoozed), what was saved, or what is done. */
149export type InboxView = "inbox" | "saved" | "done";
150
151export type InboxQuery = {
152 view?: InboxView;
153 severity?: InboxSeverity | null;
154 reason?: InboxReason | null;
155 /** Only threads the person takes part in: not those followed by watching or by hand. */
156 participating?: boolean;
157 repoId?: string | null;
158 unread?: boolean;
159 /** RFC 3339: only threads with activity at or after it. */
160 since?: string | null;
161 /** RFC 3339: only threads whose latest activity was before it. */
162 updatedBefore?: string | null;
163 /** The `next` of the page before. */
164 before?: string | null;
165 /** At most 100. */
166 limit?: number;
167};
168
169export type InboxPage = { items: InboxItem[]; next: string | null };
170
171/** Unread items in the inbox, by severity. */
172export type InboxCounts = { unread: number } & Record<InboxSeverity, number>;
173
174export type InboxMark = "read" | "unread" | "done" | "undone" | "save" | "unsave" | "snooze" | "unsnooze";
175
176export type InboxMarkArgs = {
177 mark: InboxMark;
178 /** At most 100. */
179 ids?: string[];
180 /** Every item in the inbox, when `ids` is empty: Mark all read. */
181 all?: boolean;
182 severity?: InboxSeverity | null;
183 repoId?: string | null;
184 /** With `all`: only threads whose latest activity was at or before this. */
185 lastReadAt?: string | null;
186 /** For `snooze`: RFC 3339, later than now. */
187 until?: string | null;
188};
189
190/** Which issue or pull request: a thread's id, or a repository and number. */
191export type SubscriptionTarget = { id: string } | { repoId: string; number: number };
192
193export interface InboxApi {
194 /**
195 * Latest activity first; in the inbox unfiltered, unread warnings first.
196 * Items about a repository the viewer can no longer read are dropped.
197 */
198 list(viewer: User, query?: InboxQuery): Promise<InboxPage>;
199 counts(username: string): Promise<InboxCounts>;
200 /** Changes the person's own items. Returns how many changed. */
201 mark(username: string, args: InboxMarkArgs): Promise<number>;
202 /** One of the viewer's threads with its history, or null. */
203 thread(viewer: User, id: string): Promise<InboxThread | null>;
204 /** The viewer's subscription to an issue or pull request; null when there is none to have. */
205 subscription(viewer: User, target: SubscriptionTarget): Promise<ThreadSubscription | null>;
206 /**
207 * Subscribes (`true`), unsubscribes (`false`), ignores, or goes back to
208 * the default (`null`, subscribed only while taking part).
209 */
210 subscribe(viewer: User, target: SubscriptionTarget, subscribed: boolean | null, ignored?: boolean): Promise<ThreadSubscription | null>;
211 watching(username: string, repoId: string): Promise<Watching>;
212 /** `null` goes back to the default, participating. */
213 watch(username: string, repoId: string, repo: string, level: WatchLevel | null, events?: WatchEvent[]): Promise<Watching>;
214 watched(username: string): Promise<Watching[]>;
215 settings(username: string): Promise<InboxSettings>;
216 updateSettings(username: string, changes: Partial<InboxSettings>): Promise<InboxSettings>;
217}
218
219/** The inbox, which the events service keeps. */
220export function inboxClient(events: ServiceBinding): InboxApi {
221 const call = async <T>(method: string, args: object): Promise<T> => {
222 const response = await events.fetch(`https://service/rpc/${method}`, {
223 method: "POST",
224 headers: { "content-type": "application/json" },
225 body: JSON.stringify(args),
226 });
227 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
228 return (await response.json()) as T;
229 };
230 return {
231 list: (viewer, query = {}) => call("inbox_list", { viewer, ...query }),
232 counts: (username) => call("inbox_counts", { username }),
233 mark: (username, args) => call("inbox_mark", { username, ...args }),
234 thread: (viewer, id) => call("inbox_thread", { viewer, id }),
235 subscription: (viewer, target) => call("inbox_subscription", { viewer, ...target }),
236 subscribe: (viewer, target, subscribed, ignored = false) =>
237 call("inbox_subscribe", { viewer, ...target, subscribed, ignored }),
238 watching: (username, repoId) => call("inbox_watching", { username, repoId }),
239 watch: (username, repoId, repo, level, events = []) => call("inbox_watch", { username, repoId, repo, level, events }),
240 watched: (username) => call("inbox_watched", { username }),
241 settings: (username) => call("inbox_settings", { username }),
242 updateSettings: (username, changes) => call("inbox_update_settings", { username, ...changes }),
243 };
244}