Skip to content

g1t/crates/contracts/src/inbox.rs

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