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: the events service tells people what needs them as events arrive | 1 | //! The inbox: what needs a person, or what they follow, as it happens. |
| 2 | //! | |
| 3 | //! The events service keeps it, beside the event log: as events arrive it | |
| 4 | //! works out who should hear of each (see `services/events/src/inbox.rs`) | |
| Inbox: threads, reasons, subscriptions and watching | 5 | //! and why. Each person has one item per **thread**, the thing it is about |
| 6 | //! (an issue, a pull request, a workflow on a branch, a deployment): new | |
| 7 | //! activity on a thread brings its item back to the top, unread, and adds | |
| 8 | //! a line to its short history, rather than adding another item. Items are | |
| 9 | //! kept by username, which never changes. | |
| 10 | //! | |
| 11 | //! Who hears of a thread follows its **subscriptions**: whoever opened it, | |
| 12 | //! is assigned to it, was asked to review it, commented on it or was | |
| 13 | //! mentioned in it is subscribed without asking, and anyone can subscribe | |
| 14 | //! or unsubscribe by hand. A person can also **watch** a repository: only | |
| 15 | //! what they take part in (the default), all of its activity, only some | |
| 16 | //! kinds of it, or nothing at all. | |
| 17 | //! | |
| 18 | //! Methods, served at `POST /rpc/<method>` on the events service: | |
| Inbox: the events service tells people what needs them as events arrive | 19 | //! |
| 20 | //! - `inbox_list` takes `ListInboxArgs` and returns `InboxPage`. Items about | |
| 21 | //! a repository the viewer can no longer read are dropped as they are | |
| 22 | //! found. | |
| 23 | //! - `inbox_counts` takes `InboxCountsArgs` and returns `InboxCounts`: the | |
| 24 | //! unread items, by severity. One query, for every page's top bar. | |
| 25 | //! - `inbox_mark` takes `MarkInboxArgs` and returns how many items changed. | |
| Inbox: threads, reasons, subscriptions and watching | 26 | //! - `inbox_thread` takes `ThreadArgs` and returns `Option<InboxThread>`: |
| 27 | //! one item with its history and the person's subscription. | |
| 28 | //! - `inbox_subscription` takes `SubscriptionArgs` and returns | |
| 29 | //! `Option<ThreadSubscription>`; `inbox_subscribe` takes `SubscribeArgs` | |
| 30 | //! and returns the same. | |
| 31 | //! - `inbox_watching` takes `WatchingArgs` and returns `Watching`; | |
| 32 | //! `inbox_watch` takes `WatchArgs` and returns `Watching`; | |
| 33 | //! `inbox_watched` takes `InboxCountsArgs` and returns `Vec<Watching>`. | |
| 34 | //! - `inbox_settings` takes `InboxCountsArgs` and returns `InboxSettings`; | |
| 35 | //! `inbox_update_settings` takes `UpdateInboxSettingsArgs` and returns | |
| 36 | //! `InboxSettings`. | |
| Inbox: the events service tells people what needs them as events arrive | 37 | //! |
| 38 | //! What an event is about (the issue or pull request, its people, the | |
| 39 | //! comment) comes from the work service's `inbox_subject`, which takes | |
| 40 | //! `InboxSubjectArgs` and returns `Option<InboxSubject>`. | |
| 41 | ||
| 42 | use serde::{Deserialize, Serialize}; | |
| 43 | ||
| 44 | use crate::Viewer; | |
| 45 | use crate::credentials::Principal; | |
| 46 | ||
| 47 | /// How much an item matters, and how it is shown: a failure, something a | |
| Inbox: threads, reasons, subscriptions and watching | 48 | /// person must answer (an agent waiting on them, a review asked of them), |
| 49 | /// something that went well, or something to know. | |
| Inbox: the events service tells people what needs them as events arrive | 50 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| 51 | #[serde(rename_all = "lowercase")] | |
| 52 | pub enum Severity { | |
| 53 | Error, | |
| 54 | Warning, | |
| 55 | Success, | |
| 56 | Info, | |
| 57 | } | |
| 58 | ||
| 59 | impl Severity { | |
| 60 | pub const ALL: [Severity; 4] = [Severity::Error, Severity::Warning, Severity::Success, Severity::Info]; | |
| 61 | ||
| 62 | pub fn as_str(self) -> &'static str { | |
| 63 | match self { | |
| 64 | Severity::Error => "error", | |
| 65 | Severity::Warning => "warning", | |
| 66 | Severity::Success => "success", | |
| 67 | Severity::Info => "info", | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | pub fn parse(value: &str) -> Option<Severity> { | |
| 72 | Severity::ALL.into_iter().find(|severity| severity.as_str() == value) | |
| 73 | } | |
| Inbox: threads, reasons, subscriptions and watching | 74 | |
| 75 | /// Which of two is kept on an unread thread: what needs the person, | |
| 76 | /// then a failure, then good news, then the rest. Lower comes first. | |
| 77 | pub fn urgency(self) -> u8 { | |
| 78 | match self { | |
| 79 | Severity::Warning => 0, | |
| 80 | Severity::Error => 1, | |
| 81 | Severity::Success => 2, | |
| 82 | Severity::Info => 3, | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | /// Why a person was told: what ties them to the thread, or what it asked | |
| 88 | /// of them. | |
| 89 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 90 | #[serde(rename_all = "snake_case")] | |
| 91 | pub enum Reason { | |
| 92 | /// An agent is waiting on them: it asked a question, or it stopped | |
| 93 | /// until a person steps in. | |
| 94 | Agent, | |
| 95 | /// Someone asked them to review a pull request. | |
| 96 | ReviewRequested, | |
| 97 | /// They were assigned to it. | |
| 98 | Assign, | |
| 99 | /// Someone mentioned them by name. | |
| 100 | Mention, | |
| 101 | /// A check, workflow or deployment on their work finished. | |
| 102 | CiActivity, | |
| 103 | /// A security alert on a repository they look after. | |
| 104 | SecurityAlert, | |
| 105 | /// It was closed, reopened or merged. | |
| 106 | StateChange, | |
| 107 | /// They opened it, or asked g1t for it. | |
| 108 | Author, | |
| 109 | /// They commented on it. | |
| 110 | Comment, | |
| 111 | /// They subscribed to it by hand. | |
| 112 | Manual, | |
| 113 | /// They watch its repository. | |
| 114 | Subscribed, | |
| Inbox: the events service tells people what needs them as events arrive | 115 | } |
| 116 | ||
| Inbox: threads, reasons, subscriptions and watching | 117 | impl Reason { |
| 118 | /// Most specific first: when one person is told of something for more | |
| 119 | /// than one reason, the first of these is the one shown. | |
| 120 | pub const ALL: [Reason; 11] = [ | |
| 121 | Reason::Agent, | |
| 122 | Reason::ReviewRequested, | |
| 123 | Reason::Assign, | |
| 124 | Reason::Mention, | |
| 125 | Reason::CiActivity, | |
| 126 | Reason::SecurityAlert, | |
| 127 | Reason::StateChange, | |
| 128 | Reason::Author, | |
| 129 | Reason::Comment, | |
| 130 | Reason::Manual, | |
| 131 | Reason::Subscribed, | |
| 132 | ]; | |
| 133 | ||
| 134 | pub fn as_str(self) -> &'static str { | |
| 135 | match self { | |
| 136 | Reason::Agent => "agent", | |
| 137 | Reason::ReviewRequested => "review_requested", | |
| 138 | Reason::Assign => "assign", | |
| 139 | Reason::Mention => "mention", | |
| 140 | Reason::CiActivity => "ci_activity", | |
| 141 | Reason::SecurityAlert => "security_alert", | |
| 142 | Reason::StateChange => "state_change", | |
| 143 | Reason::Author => "author", | |
| 144 | Reason::Comment => "comment", | |
| 145 | Reason::Manual => "manual", | |
| 146 | Reason::Subscribed => "subscribed", | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | pub fn parse(value: &str) -> Option<Reason> { | |
| 151 | Reason::ALL.into_iter().find(|reason| reason.as_str() == value) | |
| 152 | } | |
| 153 | ||
| 154 | /// Lower is more specific. | |
| 155 | pub fn rank(self) -> usize { | |
| 156 | Reason::ALL.iter().position(|reason| *reason == self).unwrap_or(Reason::ALL.len()) | |
| 157 | } | |
| 158 | ||
| 159 | /// Whether the person takes part in the thread themselves, rather than | |
| 160 | /// following it: everything but a hand subscription and watching. | |
| 161 | pub fn participating(self) -> bool { | |
| 162 | !matches!(self, Reason::Manual | Reason::Subscribed) | |
| 163 | } | |
| 164 | ||
| 165 | /// What is asked of the person directly: told even when they | |
| 166 | /// unsubscribed from the thread, though never when they ignore it or | |
| 167 | /// its repository. | |
| 168 | pub fn direct(self) -> bool { | |
| 169 | matches!( | |
| 170 | self, | |
| 171 | Reason::Agent | Reason::ReviewRequested | Reason::Assign | Reason::Mention | Reason::CiActivity | Reason::SecurityAlert | |
| 172 | ) | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| Inbox: the events service tells people what needs them as events arrive | 176 | /// What an item is about. |
| 177 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 178 | #[serde(rename_all = "lowercase")] | |
| 179 | pub enum SubjectKind { | |
| 180 | Issue, | |
| 181 | Pull, | |
| Inbox: threads, reasons, subscriptions and watching | 182 | /// A workflow's runs on one branch. |
| Inbox: the events service tells people what needs them as events arrive | 183 | Run, |
| Inbox: threads, reasons, subscriptions and watching | 184 | /// A project's deployments: production, or one pull request's preview. |
| 185 | Deploy, | |
| Inbox: the events service tells people what needs them as events arrive | 186 | } |
| 187 | ||
| 188 | impl SubjectKind { | |
| Inbox: threads, reasons, subscriptions and watching | 189 | pub const ALL: [SubjectKind; 4] = [SubjectKind::Issue, SubjectKind::Pull, SubjectKind::Run, SubjectKind::Deploy]; |
| 190 | ||
| Inbox: the events service tells people what needs them as events arrive | 191 | pub fn as_str(self) -> &'static str { |
| 192 | match self { | |
| 193 | SubjectKind::Issue => "issue", | |
| 194 | SubjectKind::Pull => "pull", | |
| 195 | SubjectKind::Run => "run", | |
| Inbox: threads, reasons, subscriptions and watching | 196 | SubjectKind::Deploy => "deploy", |
| Inbox: the events service tells people what needs them as events arrive | 197 | } |
| 198 | } | |
| 199 | ||
| 200 | pub fn parse(value: &str) -> Option<SubjectKind> { | |
| Inbox: threads, reasons, subscriptions and watching | 201 | SubjectKind::ALL.into_iter().find(|kind| kind.as_str() == value) |
| Inbox: the events service tells people what needs them as events arrive | 202 | } |
| 203 | } | |
| 204 | ||
| Inbox: threads, reasons, subscriptions and watching | 205 | /// One thread in a person's inbox: what it is about, and its latest |
| 206 | /// activity. | |
| Inbox: the events service tells people what needs them as events arrive | 207 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 208 | #[serde(rename_all = "camelCase")] | |
| 209 | pub struct InboxItem { | |
| Inbox: threads, reasons, subscriptions and watching | 210 | /// The thread's id: the same for as long as the person has it. |
| Inbox: the events service tells people what needs them as events arrive | 211 | pub id: String, |
| Inbox: threads, reasons, subscriptions and watching | 212 | /// Why they were told of the latest activity. |
| 213 | pub reason: Reason, | |
| 214 | /// While unread, the most urgent of what happened since it was last | |
| 215 | /// read; once read, the latest's. | |
| Inbox: the events service tells people what needs them as events arrive | 216 | pub severity: Severity, |
| Inbox: threads, reasons, subscriptions and watching | 217 | /// One line: what happened last, and where. |
| Inbox: the events service tells people what needs them as events arrive | 218 | pub title: String, |
| 219 | /// One line: what it happened to, such as the pull request's title. | |
| 220 | pub body: String, | |
| Inbox: threads, reasons, subscriptions and watching | 221 | /// The event behind the latest activity, such as `pull.merged`. |
| 222 | pub event: Option<String>, | |
| Inbox: the events service tells people what needs them as events arrive | 223 | /// `owner/name`. |
| 224 | pub repo: Option<String>, | |
| 225 | /// The workspace it happened in. | |
| 226 | pub workspace: Option<String>, | |
| 227 | pub subject: Option<SubjectKind>, | |
| 228 | /// The issue or pull request's number. | |
| 229 | pub number: Option<u32>, | |
| 230 | /// Where it is on g1t.sh: a path such as `/acme/rocket/pull/12`. | |
| 231 | pub url: String, | |
| Inbox: threads, reasons, subscriptions and watching | 232 | /// Who did the latest: a username, or `g1t`. Absent when nobody did. |
| Inbox: the events service tells people what needs them as events arrive | 233 | pub actor: Option<String>, |
| Inbox: threads, reasons, subscriptions and watching | 234 | /// How many things have happened on the thread. |
| 235 | pub count: u32, | |
| 236 | /// RFC 3339: when the person was first told of the thread. | |
| Inbox: the events service tells people what needs them as events arrive | 237 | pub created_at: String, |
| Inbox: threads, reasons, subscriptions and watching | 238 | /// RFC 3339: its latest activity. |
| 239 | pub updated_at: String, | |
| Inbox: the events service tells people what needs them as events arrive | 240 | pub read_at: Option<String>, |
| 241 | pub done_at: Option<String>, | |
| 242 | pub saved: bool, | |
| 243 | /// While this is in the future the item is out of the list. | |
| 244 | pub snoozed_until: Option<String>, | |
| 245 | } | |
| 246 | ||
| Inbox: threads, reasons, subscriptions and watching | 247 | /// One thing that happened on a thread, as the person was told of it. |
| 248 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 249 | #[serde(rename_all = "camelCase")] | |
| 250 | pub struct InboxActivity { | |
| 251 | pub reason: Reason, | |
| 252 | pub severity: Severity, | |
| 253 | pub title: String, | |
| 254 | pub body: String, | |
| 255 | pub event: Option<String>, | |
| 256 | pub actor: Option<String>, | |
| 257 | pub created_at: String, | |
| 258 | } | |
| 259 | ||
| 260 | /// The most activity kept per thread, newest first. | |
| 261 | pub const MAX_ACTIVITY: u32 = 10; | |
| 262 | ||
| Inbox: the events service tells people what needs them as events arrive | 263 | /// Which items a list shows. |
| 264 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 265 | #[serde(rename_all = "lowercase")] | |
| 266 | pub enum InboxView { | |
| 267 | /// Everything not done and not snoozed: the inbox itself. | |
| 268 | #[default] | |
| 269 | Inbox, | |
| 270 | /// Saved items, done or not. | |
| 271 | Saved, | |
| 272 | /// Items marked done. | |
| 273 | Done, | |
| 274 | } | |
| 275 | ||
| Inbox: threads, reasons, subscriptions and watching | 276 | impl InboxView { |
| 277 | pub fn parse(value: &str) -> Option<InboxView> { | |
| 278 | match value { | |
| 279 | "inbox" => Some(InboxView::Inbox), | |
| 280 | "saved" => Some(InboxView::Saved), | |
| 281 | "done" => Some(InboxView::Done), | |
| 282 | _ => None, | |
| 283 | } | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | /// `inbox_list`. Latest activity first, except that in the inbox | |
| 288 | /// unfiltered, unread warnings (what is waiting on the person) come first. | |
| 289 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| Inbox: the events service tells people what needs them as events arrive | 290 | #[serde(rename_all = "camelCase")] |
| 291 | pub struct ListInboxArgs { | |
| 292 | /// Whose inbox: the person signed in. Their memberships decide which | |
| 293 | /// repositories they can still read. | |
| 294 | pub viewer: Viewer, | |
| 295 | #[serde(default)] | |
| 296 | pub view: InboxView, | |
| 297 | /// Only items of this severity. | |
| 298 | #[serde(default)] | |
| 299 | pub severity: Option<Severity>, | |
| Inbox: threads, reasons, subscriptions and watching | 300 | /// Only items told for this reason. |
| 301 | #[serde(default)] | |
| 302 | pub reason: Option<Reason>, | |
| 303 | /// Only items the person takes part in (see [`Reason::participating`]). | |
| Inbox: the events service tells people what needs them as events arrive | 304 | #[serde(default)] |
| Inbox: threads, reasons, subscriptions and watching | 305 | pub participating: bool, |
| 306 | /// Only items about this repository. | |
| 307 | #[serde(default)] | |
| 308 | pub repo_id: Option<String>, | |
| 309 | #[serde(default)] | |
| Inbox: the events service tells people what needs them as events arrive | 310 | pub unread: bool, |
| Inbox: threads, reasons, subscriptions and watching | 311 | /// RFC 3339: only items with activity at or after it. |
| 312 | #[serde(default)] | |
| 313 | pub since: Option<String>, | |
| 314 | /// RFC 3339: only items whose latest activity was before it. | |
| 315 | #[serde(default)] | |
| 316 | pub updated_before: Option<String>, | |
| Inbox: the events service tells people what needs them as events arrive | 317 | /// The `next` of the page before. |
| 318 | #[serde(default)] | |
| 319 | pub before: Option<String>, | |
| 320 | #[serde(default)] | |
| 321 | pub limit: Option<u32>, | |
| 322 | } | |
| 323 | ||
| 324 | pub const DEFAULT_INBOX_PAGE: u32 = 30; | |
| 325 | pub const MAX_INBOX_PAGE: u32 = 100; | |
| 326 | ||
| 327 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 328 | #[serde(rename_all = "camelCase")] | |
| 329 | pub struct InboxPage { | |
| 330 | pub items: Vec<InboxItem>, | |
| 331 | /// Pass as `before` for the next page; absent on the last. | |
| 332 | pub next: Option<String>, | |
| 333 | } | |
| 334 | ||
| Inbox: threads, reasons, subscriptions and watching | 335 | /// `inbox_counts`, and the other methods that need only whose. |
| Inbox: the events service tells people what needs them as events arrive | 336 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 337 | #[serde(rename_all = "camelCase")] | |
| 338 | pub struct InboxCountsArgs { | |
| 339 | pub username: String, | |
| 340 | } | |
| 341 | ||
| 342 | /// Unread items in the inbox view, by severity. | |
| 343 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 344 | #[serde(rename_all = "camelCase")] | |
| 345 | pub struct InboxCounts { | |
| 346 | pub unread: u32, | |
| 347 | pub error: u32, | |
| 348 | pub warning: u32, | |
| 349 | pub success: u32, | |
| 350 | pub info: u32, | |
| 351 | } | |
| 352 | ||
| 353 | /// What `inbox_mark` does to the items it names. | |
| 354 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 355 | #[serde(rename_all = "snake_case")] | |
| 356 | pub enum InboxMark { | |
| 357 | Read, | |
| 358 | Unread, | |
| 359 | /// Out of the inbox, into Done; read with it. | |
| 360 | Done, | |
| 361 | /// Back into the inbox. | |
| 362 | Undone, | |
| 363 | Save, | |
| 364 | Unsave, | |
| 365 | /// Out of the inbox until `until`. | |
| 366 | Snooze, | |
| Inbox: threads, reasons, subscriptions and watching | 367 | /// Back into the inbox now. |
| 368 | Unsnooze, | |
| Inbox: the events service tells people what needs them as events arrive | 369 | } |
| 370 | ||
| 371 | /// `inbox_mark`: changes the person's own items, by id, or every item in | |
| 372 | /// their inbox when `ids` is empty and `all` is set (Mark all read). | |
| 373 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 374 | #[serde(rename_all = "camelCase")] | |
| 375 | pub struct MarkInboxArgs { | |
| 376 | pub username: String, | |
| 377 | pub mark: InboxMark, | |
| 378 | #[serde(default)] | |
| 379 | pub ids: Vec<String>, | |
| 380 | #[serde(default)] | |
| 381 | pub all: bool, | |
| 382 | /// With `all`: only items of this severity. | |
| 383 | #[serde(default)] | |
| 384 | pub severity: Option<Severity>, | |
| Inbox: threads, reasons, subscriptions and watching | 385 | /// With `all`: only items about this repository. |
| 386 | #[serde(default)] | |
| 387 | pub repo_id: Option<String>, | |
| 388 | /// With `all`: only items whose latest activity was at or before this | |
| 389 | /// (RFC 3339), so what arrived after the person looked stays unread. | |
| 390 | #[serde(default)] | |
| 391 | pub last_read_at: Option<String>, | |
| Inbox: the events service tells people what needs them as events arrive | 392 | /// For `snooze`: RFC 3339. |
| 393 | #[serde(default)] | |
| 394 | pub until: Option<String>, | |
| 395 | } | |
| 396 | ||
| 397 | /// The most ids one `inbox_mark` call changes. | |
| 398 | pub const MAX_MARK: usize = 100; | |
| 399 | ||
| Inbox: threads, reasons, subscriptions and watching | 400 | /// `inbox_thread`: one of the viewer's own threads, by id. |
| 401 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 402 | #[serde(rename_all = "camelCase")] | |
| 403 | pub struct ThreadArgs { | |
| 404 | pub viewer: Viewer, | |
| 405 | pub id: String, | |
| 406 | } | |
| 407 | ||
| 408 | /// A thread with its history and the person's subscription to it. | |
| 409 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 410 | #[serde(rename_all = "camelCase")] | |
| 411 | pub struct InboxThread { | |
| 412 | #[serde(flatten)] | |
| 413 | pub item: InboxItem, | |
| 414 | /// Newest first, at most [`MAX_ACTIVITY`]. | |
| 415 | pub activity: Vec<InboxActivity>, | |
| 416 | /// For an issue or pull request; absent for a run or a deployment, | |
| 417 | /// which nobody subscribes to. | |
| 418 | pub subscription: Option<ThreadSubscription>, | |
| 419 | } | |
| 420 | ||
| 421 | /// A person's subscription to an issue or pull request. | |
| 422 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 423 | #[serde(rename_all = "camelCase")] | |
| 424 | pub struct ThreadSubscription { | |
| 425 | /// Whether they hear of what happens on it. | |
| 426 | pub subscribed: bool, | |
| 427 | /// Whether they hear of nothing on it at all, not even a mention. | |
| 428 | pub ignored: bool, | |
| 429 | /// Why they are subscribed: they opened it (`author`), are assigned | |
| 430 | /// (`assign`), were asked to review (`review_requested`), commented | |
| 431 | /// (`comment`), were mentioned (`mention`) or subscribed by hand | |
| 432 | /// (`manual`). Absent when they are not. | |
| 433 | pub reason: Option<Reason>, | |
| 434 | /// `owner/name`, and the issue or pull request's number. | |
| 435 | pub repo: Option<String>, | |
| 436 | pub number: Option<u32>, | |
| 437 | /// RFC 3339: when they last chose, or null if they never did. | |
| 438 | pub updated_at: Option<String>, | |
| 439 | } | |
| 440 | ||
| 441 | /// Which issue or pull request: by a thread's id, or by its repository and | |
| 442 | /// number. | |
| 443 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 444 | #[serde(rename_all = "camelCase")] | |
| 445 | pub struct SubscriptionArgs { | |
| 446 | pub viewer: Viewer, | |
| 447 | #[serde(default)] | |
| 448 | pub id: Option<String>, | |
| 449 | #[serde(default)] | |
| 450 | pub repo_id: Option<String>, | |
| 451 | #[serde(default)] | |
| 452 | pub number: Option<u32>, | |
| 453 | } | |
| 454 | ||
| 455 | /// `inbox_subscribe`. | |
| 456 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 457 | #[serde(rename_all = "camelCase")] | |
| 458 | pub struct SubscribeArgs { | |
| 459 | #[serde(flatten)] | |
| 460 | pub on: SubscriptionArgs, | |
| 461 | /// True to subscribe, false to unsubscribe. Absent with `ignored` | |
| 462 | /// false: back to the default, subscribed only while taking part. | |
| 463 | #[serde(default)] | |
| 464 | pub subscribed: Option<bool>, | |
| 465 | /// True to hear of nothing on it, not even a mention. | |
| 466 | #[serde(default)] | |
| 467 | pub ignored: bool, | |
| 468 | } | |
| 469 | ||
| 470 | /// How closely a person follows a repository. | |
| 471 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 472 | #[serde(rename_all = "snake_case")] | |
| 473 | pub enum WatchLevel { | |
| 474 | /// Only what they take part in or are mentioned in: the default. | |
| 475 | #[default] | |
| 476 | Participating, | |
| 477 | /// Everything: every issue and pull request opened, commented on, | |
| 478 | /// closed or merged, and every deployment. | |
| 479 | All, | |
| 480 | /// Nothing at all, not even a mention. | |
| 481 | Ignore, | |
| 482 | /// What they take part in, and the kinds of activity in `events`. | |
| 483 | Custom, | |
| 484 | } | |
| 485 | ||
| 486 | impl WatchLevel { | |
| 487 | pub const ALL: [WatchLevel; 4] = [WatchLevel::Participating, WatchLevel::All, WatchLevel::Ignore, WatchLevel::Custom]; | |
| 488 | ||
| 489 | pub fn as_str(self) -> &'static str { | |
| 490 | match self { | |
| 491 | WatchLevel::Participating => "participating", | |
| 492 | WatchLevel::All => "all", | |
| 493 | WatchLevel::Ignore => "ignore", | |
| 494 | WatchLevel::Custom => "custom", | |
| 495 | } | |
| 496 | } | |
| 497 | ||
| 498 | pub fn parse(value: &str) -> Option<WatchLevel> { | |
| 499 | WatchLevel::ALL.into_iter().find(|level| level.as_str() == value) | |
| 500 | } | |
| 501 | } | |
| 502 | ||
| 503 | /// The kinds of activity a custom watch can follow. | |
| 504 | pub const WATCH_EVENTS: [&str; 4] = ["issues", "pulls", "deployments", "security"]; | |
| 505 | ||
| 506 | /// How a person watches one repository. | |
| 507 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 508 | #[serde(rename_all = "camelCase")] | |
| 509 | pub struct Watching { | |
| 510 | pub repo_id: String, | |
| 511 | /// `owner/name`, when known. | |
| 512 | #[serde(default)] | |
| 513 | pub repo: Option<String>, | |
| 514 | pub level: WatchLevel, | |
| 515 | /// With `custom`: some of [`WATCH_EVENTS`]. | |
| 516 | #[serde(default)] | |
| 517 | pub events: Vec<String>, | |
| 518 | /// RFC 3339: when they chose, or null if they never did. | |
| 519 | #[serde(default)] | |
| 520 | pub updated_at: Option<String>, | |
| 521 | } | |
| 522 | ||
| 523 | /// `inbox_watching`. | |
| 524 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 525 | #[serde(rename_all = "camelCase")] | |
| 526 | pub struct WatchingArgs { | |
| 527 | pub username: String, | |
| 528 | pub repo_id: String, | |
| 529 | } | |
| 530 | ||
| 531 | /// `inbox_watch`. No `level`: back to the default. | |
| 532 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 533 | #[serde(rename_all = "camelCase")] | |
| 534 | pub struct WatchArgs { | |
| 535 | pub username: String, | |
| 536 | pub repo_id: String, | |
| 537 | /// `owner/name`, kept to list what the person watches. | |
| 538 | #[serde(default)] | |
| 539 | pub repo: Option<String>, | |
| 540 | #[serde(default)] | |
| 541 | pub level: Option<WatchLevel>, | |
| 542 | #[serde(default)] | |
| 543 | pub events: Vec<String>, | |
| 544 | } | |
| 545 | ||
| 546 | /// A person's choices about being told. | |
| 547 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 548 | #[serde(rename_all = "camelCase")] | |
| 549 | pub struct InboxSettings { | |
| 550 | /// The reasons they are also emailed for. | |
| 551 | pub email: Vec<Reason>, | |
| 552 | /// How they watch a repository they create. | |
| 553 | pub default_watch: WatchLevel, | |
| 554 | } | |
| 555 | ||
| 556 | impl Default for InboxSettings { | |
| 557 | fn default() -> Self { | |
| 558 | InboxSettings { | |
| 559 | email: DEFAULT_EMAIL.to_vec(), | |
| 560 | default_watch: WatchLevel::All, | |
| 561 | } | |
| 562 | } | |
| 563 | } | |
| 564 | ||
| 565 | /// What a person is emailed for until they choose: what is waiting on them. | |
| 566 | pub const DEFAULT_EMAIL: [Reason; 3] = [Reason::Agent, Reason::ReviewRequested, Reason::Mention]; | |
| 567 | ||
| 568 | /// `inbox_update_settings`. What is left out is unchanged. | |
| 569 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 570 | #[serde(rename_all = "camelCase")] | |
| 571 | pub struct UpdateInboxSettingsArgs { | |
| 572 | pub username: String, | |
| 573 | #[serde(default)] | |
| 574 | pub email: Option<Vec<Reason>>, | |
| 575 | #[serde(default)] | |
| 576 | pub default_watch: Option<WatchLevel>, | |
| 577 | } | |
| 578 | ||
| Inbox: the events service tells people what needs them as events arrive | 579 | /// `inbox_subject` on the work service: what an event names, for the |
| 580 | /// inbox. A service-to-service read: it checks nobody's access, and what | |
| 581 | /// it returns is only ever shown to the people it names, or to those who | |
| 582 | /// can read the repository. | |
| 583 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 584 | #[serde(rename_all = "camelCase")] | |
| 585 | pub struct InboxSubjectArgs { | |
| 586 | pub repo_id: String, | |
| 587 | pub number: u32, | |
| 588 | /// The comment the event is about, if any. | |
| 589 | #[serde(default)] | |
| 590 | pub comment_id: Option<String>, | |
| 591 | } | |
| 592 | ||
| 593 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 594 | #[serde(rename_all = "camelCase")] | |
| 595 | pub struct InboxSubject { | |
| 596 | /// `issue` or `pull`. | |
| 597 | pub kind: Option<SubjectKind>, | |
| 598 | pub title: String, | |
| 599 | pub author: Principal, | |
| 600 | /// For work g1t did: the person it was done for. | |
| 601 | #[serde(default)] | |
| 602 | pub requested_by: Option<Principal>, | |
| 603 | /// Usernames. | |
| 604 | #[serde(default)] | |
| 605 | pub assignees: Vec<String>, | |
| 606 | /// Usernames, and `g1t`. Pull requests only. | |
| 607 | #[serde(default)] | |
| 608 | pub reviewers: Vec<String>, | |
| 609 | /// For a pull request: the issue it is for, with that issue's people. | |
| 610 | #[serde(default)] | |
| 611 | pub issue: Option<Box<InboxSubject>>, | |
| 612 | #[serde(default)] | |
| 613 | pub comment: Option<InboxComment>, | |
| 614 | } | |
| 615 | ||
| 616 | impl InboxSubject { | |
| 617 | /// Whose it is to answer for: whoever asked g1t for it, or its author. | |
| 618 | pub fn owner(&self) -> &Principal { | |
| 619 | self.requested_by.as_ref().unwrap_or(&self.author) | |
| 620 | } | |
| 621 | } | |
| 622 | ||
| 623 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 624 | #[serde(rename_all = "camelCase")] | |
| 625 | pub struct InboxComment { | |
| 626 | pub author: Principal, | |
| 627 | /// The first line or so, as written. | |
| 628 | pub excerpt: String, | |
| 629 | /// The people it mentions by name, outside code and quotes. Never `g1t`. | |
| 630 | #[serde(default)] | |
| 631 | pub mentions: Vec<String>, | |
| 632 | /// A review's verdict: `approve` or `request_changes`. | |
| 633 | #[serde(default)] | |
| 634 | pub verdict: Option<String>, | |
| 635 | /// Something that happened (an assignment, a close), not something written. | |
| 636 | #[serde(default)] | |
| 637 | pub event: bool, | |
| 638 | } | |
| 639 | ||
| Inbox: threads, reasons, subscriptions and watching | 640 | /// `notify_by_email` on the identity service: one item, emailed to the |
| 641 | /// person it is for, if they can still read its repository and their | |
| 642 | /// address is confirmed. Returns whether it was sent. | |
| 643 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 644 | #[serde(rename_all = "camelCase")] | |
| 645 | pub struct NotifyByEmailArgs { | |
| 646 | pub username: String, | |
| 647 | pub repo_id: String, | |
| 648 | /// The subject line and the first paragraph. | |
| 649 | pub subject: String, | |
| 650 | pub intro: String, | |
| 651 | /// What was said, and who said it, when it was written by someone. | |
| 652 | #[serde(default)] | |
| 653 | pub quote: Option<(String, String)>, | |
| 654 | /// A path on the site, such as `/acme/rocket/pull/12`. | |
| 655 | pub path: String, | |
| 656 | pub reason: Reason, | |
| 657 | } | |
| 658 | ||
| Inbox: the events service tells people what needs them as events arrive | 659 | #[cfg(test)] |
| 660 | mod tests { | |
| 661 | use super::*; | |
| 662 | ||
| 663 | #[test] | |
| Inbox: threads, reasons, subscriptions and watching | 664 | fn severities_reasons_and_subjects_read_back() { |
| Inbox: the events service tells people what needs them as events arrive | 665 | for severity in Severity::ALL { |
| 666 | assert_eq!(Severity::parse(severity.as_str()), Some(severity)); | |
| 667 | } | |
| 668 | assert_eq!(Severity::parse("fatal"), None); | |
| Inbox: threads, reasons, subscriptions and watching | 669 | for reason in Reason::ALL { |
| 670 | assert_eq!(Reason::parse(reason.as_str()), Some(reason)); | |
| 671 | assert_eq!(serde_json::to_value(reason).unwrap(), reason.as_str()); | |
| 672 | } | |
| 673 | for kind in SubjectKind::ALL { | |
| 674 | assert_eq!(SubjectKind::parse(kind.as_str()), Some(kind)); | |
| 675 | } | |
| 676 | for level in WatchLevel::ALL { | |
| 677 | assert_eq!(WatchLevel::parse(level.as_str()), Some(level)); | |
| 678 | } | |
| Inbox: the events service tells people what needs them as events arrive | 679 | assert_eq!(serde_json::to_value(InboxMark::Unsave).unwrap(), "unsave"); |
| 680 | } | |
| Inbox: threads, reasons, subscriptions and watching | 681 | |
| 682 | #[test] | |
| 683 | fn what_is_asked_of_a_person_outranks_what_they_follow() { | |
| 684 | assert!(Reason::Agent.rank() < Reason::Mention.rank()); | |
| 685 | assert!(Reason::Mention.rank() < Reason::Author.rank()); | |
| 686 | assert!(Reason::Author.rank() < Reason::Subscribed.rank()); | |
| 687 | assert!(Reason::ReviewRequested.direct() && !Reason::Comment.direct()); | |
| 688 | assert!(!Reason::Subscribed.participating() && Reason::Author.participating()); | |
| 689 | assert!(Severity::Warning.urgency() < Severity::Error.urgency()); | |
| 690 | } | |
| 691 | ||
| 692 | #[test] | |
| 693 | fn a_thread_carries_its_item_flat() { | |
| 694 | let thread = InboxThread { | |
| 695 | item: InboxItem { | |
| 696 | id: "ntf_1".into(), | |
| 697 | reason: Reason::Mention, | |
| 698 | severity: Severity::Info, | |
| 699 | title: "t".into(), | |
| 700 | body: "b".into(), | |
| 701 | event: None, | |
| 702 | repo: None, | |
| 703 | workspace: None, | |
| 704 | subject: None, | |
| 705 | number: None, | |
| 706 | url: "/inbox".into(), | |
| 707 | actor: None, | |
| 708 | count: 1, | |
| 709 | created_at: "2026-10-07T12:00:00.000Z".into(), | |
| 710 | updated_at: "2026-10-07T12:00:00.000Z".into(), | |
| 711 | read_at: None, | |
| 712 | done_at: None, | |
| 713 | saved: false, | |
| 714 | snoozed_until: None, | |
| 715 | }, | |
| 716 | activity: Vec::new(), | |
| 717 | subscription: None, | |
| 718 | }; | |
| 719 | let value = serde_json::to_value(&thread).unwrap(); | |
| 720 | assert_eq!(value["id"], "ntf_1"); | |
| 721 | assert_eq!(value["reason"], "mention"); | |
| 722 | assert!(value["activity"].is_array()); | |
| 723 | } | |
| Inbox: the events service tells people what needs them as events arrive | 724 | } |