| 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 | */ |
| 8 | import type { ServiceBinding } from "./clients"; |
| 9 | import 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 | */ |
| 16 | export type InboxSeverity = "error" | "warning" | "success" | "info"; |
| 17 | |
| 18 | export 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 | */ |
| 24 | export type InboxReason = |
| 25 | | "agent" |
| 26 | | "review_requested" |
| 27 | | "assign" |
| 28 | | "mention" |
| 29 | | "ci_activity" |
| 30 | | "security_alert" |
| 31 | | "state_change" |
| 32 | | "author" |
| 33 | | "comment" |
| 34 | | "manual" |
| 35 | | "subscribed"; |
| 36 | |
| 37 | export const INBOX_REASONS: InboxReason[] = [ |
| 38 | "agent", |
| 39 | "review_requested", |
| 40 | "assign", |
| 41 | "mention", |
| 42 | "ci_activity", |
| 43 | "security_alert", |
| 44 | "state_change", |
| 45 | "author", |
| 46 | "comment", |
| 47 | "manual", |
| 48 | "subscribed", |
| 49 | ]; |
| 50 | |
| 51 | export type InboxSubjectKind = "issue" | "pull" | "run" | "deploy"; |
| 52 | |
| 53 | /** One thread in a person's inbox: what it is about, and its latest activity. */ |
| 54 | export type InboxItem = { |
| 55 | /** The thread's id: the same for as long as the person has it. */ |
| 56 | id: string; |
| 57 | /** Why they were told of the latest activity. */ |
| 58 | reason: InboxReason; |
| 59 | /** While unread, the most urgent of what happened since it was last read. */ |
| 60 | severity: InboxSeverity; |
| 61 | /** One line: what happened last, and where. */ |
| 62 | title: string; |
| 63 | /** One line: what it happened to, such as the pull request's title. */ |
| 64 | body: string; |
| 65 | /** The event behind the latest activity, such as `pull.merged`. */ |
| 66 | event: string | null; |
| 67 | /** `owner/name`. */ |
| 68 | repo: string | null; |
| 69 | workspace: string | null; |
| 70 | subject: InboxSubjectKind | null; |
| 71 | number: number | null; |
| 72 | /** A path on g1t.sh, such as `/acme/rocket/pull/12`. */ |
| 73 | url: string; |
| 74 | /** A username, or `g1t`. */ |
| 75 | actor: string | null; |
| 76 | /** How many things have happened on the thread. */ |
| 77 | count: number; |
| 78 | /** RFC 3339: when the person was first told of the thread. */ |
| 79 | createdAt: string; |
| 80 | /** RFC 3339: its latest activity. */ |
| 81 | updatedAt: string; |
| 82 | readAt: string | null; |
| 83 | doneAt: string | null; |
| 84 | saved: boolean; |
| 85 | snoozedUntil: string | null; |
| 86 | }; |
| 87 | |
| 88 | /** One thing that happened on a thread, as the person was told of it. */ |
| 89 | export type InboxActivity = { |
| 90 | reason: InboxReason; |
| 91 | severity: InboxSeverity; |
| 92 | title: string; |
| 93 | body: string; |
| 94 | event: string | null; |
| 95 | actor: string | null; |
| 96 | createdAt: string; |
| 97 | }; |
| 98 | |
| 99 | /** A person's subscription to an issue or pull request. */ |
| 100 | export type ThreadSubscription = { |
| 101 | /** Whether they hear of what happens on it. */ |
| 102 | subscribed: boolean; |
| 103 | /** Whether they hear of nothing on it at all, not even a mention. */ |
| 104 | ignored: boolean; |
| 105 | /** Why they are subscribed; null when they are not. */ |
| 106 | reason: InboxReason | null; |
| 107 | repo: string | null; |
| 108 | number: number | null; |
| 109 | /** When they last chose, or null if they never did. */ |
| 110 | updatedAt: string | null; |
| 111 | }; |
| 112 | |
| 113 | /** A thread with its history, newest first, and the person's subscription. */ |
| 114 | export type InboxThread = InboxItem & { |
| 115 | activity: InboxActivity[]; |
| 116 | subscription: ThreadSubscription | null; |
| 117 | }; |
| 118 | |
| 119 | /** How closely a person follows a repository. */ |
| 120 | export type WatchLevel = "participating" | "all" | "ignore" | "custom"; |
| 121 | |
| 122 | export const WATCH_LEVELS: WatchLevel[] = ["participating", "all", "ignore", "custom"]; |
| 123 | |
| 124 | /** The kinds of activity a custom watch can follow. */ |
| 125 | export type WatchEvent = "issues" | "pulls" | "deployments" | "security"; |
| 126 | |
| 127 | export const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"]; |
| 128 | |
| 129 | export type Watching = { |
| 130 | repoId: string; |
| 131 | repo: string | null; |
| 132 | level: WatchLevel; |
| 133 | /** With `custom`: what it follows. */ |
| 134 | events: WatchEvent[]; |
| 135 | updatedAt: string | null; |
| 136 | }; |
| 137 | |
| 138 | /** A person's choices about being told. */ |
| 139 | export type InboxSettings = { |
| 140 | /** The reasons they are also emailed for. */ |
| 141 | email: InboxReason[]; |
| 142 | /** How they watch a repository they create. */ |
| 143 | defaultWatch: WatchLevel; |
| 144 | }; |
| 145 | |
| 146 | /** The inbox itself (not done, not snoozed), what was saved, or what is done. */ |
| 147 | export type InboxView = "inbox" | "saved" | "done"; |
| 148 | |
| 149 | export type InboxQuery = { |
| 150 | view?: InboxView; |
| 151 | severity?: InboxSeverity | null; |
| 152 | reason?: InboxReason | null; |
| 153 | /** Only threads the person takes part in: not those followed by watching or by hand. */ |
| 154 | participating?: boolean; |
| 155 | repoId?: string | null; |
| 156 | unread?: boolean; |
| 157 | /** RFC 3339: only threads with activity at or after it. */ |
| 158 | since?: string | null; |
| 159 | /** RFC 3339: only threads whose latest activity was before it. */ |
| 160 | updatedBefore?: string | null; |
| 161 | /** The `next` of the page before. */ |
| 162 | before?: string | null; |
| 163 | /** At most 100. */ |
| 164 | limit?: number; |
| 165 | }; |
| 166 | |
| 167 | export type InboxPage = { items: InboxItem[]; next: string | null }; |
| 168 | |
| 169 | /** Unread items in the inbox, by severity. */ |
| 170 | export type InboxCounts = { unread: number } & Record<InboxSeverity, number>; |
| 171 | |
| 172 | export type InboxMark = "read" | "unread" | "done" | "undone" | "save" | "unsave" | "snooze" | "unsnooze"; |
| 173 | |
| 174 | export type InboxMarkArgs = { |
| 175 | mark: InboxMark; |
| 176 | /** At most 100. */ |
| 177 | ids?: string[]; |
| 178 | /** Every item in the inbox, when `ids` is empty: Mark all read. */ |
| 179 | all?: boolean; |
| 180 | severity?: InboxSeverity | null; |
| 181 | repoId?: string | null; |
| 182 | /** With `all`: only threads whose latest activity was at or before this. */ |
| 183 | lastReadAt?: string | null; |
| 184 | /** For `snooze`: RFC 3339, later than now. */ |
| 185 | until?: string | null; |
| 186 | }; |
| 187 | |
| 188 | /** Which issue or pull request: a thread's id, or a repository and number. */ |
| 189 | export type SubscriptionTarget = { id: string } | { repoId: string; number: number }; |
| 190 | |
| 191 | export interface InboxApi { |
| 192 | /** |
| 193 | * Latest activity first; in the inbox unfiltered, unread warnings first. |
| 194 | * Items about a repository the viewer can no longer read are dropped. |
| 195 | */ |
| 196 | list(viewer: User, query?: InboxQuery): Promise<InboxPage>; |
| 197 | counts(username: string): Promise<InboxCounts>; |
| 198 | /** Changes the person's own items. Returns how many changed. */ |
| 199 | mark(username: string, args: InboxMarkArgs): Promise<number>; |
| 200 | /** One of the viewer's threads with its history, or null. */ |
| 201 | thread(viewer: User, id: string): Promise<InboxThread | null>; |
| 202 | /** The viewer's subscription to an issue or pull request; null when there is none to have. */ |
| 203 | subscription(viewer: User, target: SubscriptionTarget): Promise<ThreadSubscription | null>; |
| 204 | /** |
| 205 | * Subscribes (`true`), unsubscribes (`false`), ignores, or goes back to |
| 206 | * the default (`null`, subscribed only while taking part). |
| 207 | */ |
| 208 | subscribe(viewer: User, target: SubscriptionTarget, subscribed: boolean | null, ignored?: boolean): Promise<ThreadSubscription | null>; |
| 209 | watching(username: string, repoId: string): Promise<Watching>; |
| 210 | /** `null` goes back to the default, participating. */ |
| 211 | watch(username: string, repoId: string, repo: string, level: WatchLevel | null, events?: WatchEvent[]): Promise<Watching>; |
| 212 | watched(username: string): Promise<Watching[]>; |
| 213 | settings(username: string): Promise<InboxSettings>; |
| 214 | updateSettings(username: string, changes: Partial<InboxSettings>): Promise<InboxSettings>; |
| 215 | } |
| 216 | |
| 217 | /** The inbox, which the events service keeps. */ |
| 218 | export function inboxClient(events: ServiceBinding): InboxApi { |
| 219 | const call = async <T>(method: string, args: object): Promise<T> => { |
| 220 | const response = await events.fetch(`https://service/rpc/${method}`, { |
| 221 | method: "POST", |
| 222 | headers: { "content-type": "application/json" }, |
| 223 | body: JSON.stringify(args), |
| 224 | }); |
| 225 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 226 | return (await response.json()) as T; |
| 227 | }; |
| 228 | return { |
| 229 | list: (viewer, query = {}) => call("inbox_list", { viewer, ...query }), |
| 230 | counts: (username) => call("inbox_counts", { username }), |
| 231 | mark: (username, args) => call("inbox_mark", { username, ...args }), |
| 232 | thread: (viewer, id) => call("inbox_thread", { viewer, id }), |
| 233 | subscription: (viewer, target) => call("inbox_subscription", { viewer, ...target }), |
| 234 | subscribe: (viewer, target, subscribed, ignored = false) => |
| 235 | call("inbox_subscribe", { viewer, ...target, subscribed, ignored }), |
| 236 | watching: (username, repoId) => call("inbox_watching", { username, repoId }), |
| 237 | watch: (username, repoId, repo, level, events = []) => call("inbox_watch", { username, repoId, repo, level, events }), |
| 238 | watched: (username) => call("inbox_watched", { username }), |
| 239 | settings: (username) => call("inbox_settings", { username }), |
| 240 | updateSettings: (username, changes) => call("inbox_update_settings", { username, ...changes }), |
| 241 | }; |
| 242 | } |