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.

849 linesCodeBlameRaw
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//! (a work item, 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 work item 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 /// A work item.
191 Item,
192 Pull,
193 /// A workflow's runs on one branch.
194 Run,
195 /// A project's deployments: production, or one pull request's preview.
196 Deploy,
197}
198
199impl SubjectKind {
200 pub const ALL: [SubjectKind; 4] = [SubjectKind::Item, SubjectKind::Pull, SubjectKind::Run, SubjectKind::Deploy];
201
202 pub fn as_str(self) -> &'static str {
203 match self {
204 SubjectKind::Item => "item",
205 SubjectKind::Pull => "pull",
206 SubjectKind::Run => "run",
207 SubjectKind::Deploy => "deploy",
208 }
209 }
210
211 pub fn parse(value: &str) -> Option<SubjectKind> {
212 SubjectKind::ALL.into_iter().find(|kind| kind.as_str() == value)
213 }
214}
215
216/// One thread in a person's inbox: what it is about, and its latest
217/// activity.
218#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct InboxItem {
221 /// The thread's id: the same for as long as the person has it.
222 pub id: String,
223 /// Why they were told of the latest activity.
224 pub reason: Reason,
225 /// While unread, the most urgent of what happened since it was last
226 /// read; once read, the latest's.
227 pub severity: Severity,
228 /// One line: what happened last, and where.
229 pub title: String,
230 /// One line: what it happened to, such as the pull request's title.
231 pub body: String,
232 /// The event behind the latest activity, such as `pull.merged`.
233 pub event: Option<String>,
234 /// `owner/name`.
235 pub repo: Option<String>,
236 /// The workspace it happened in.
237 pub workspace: Option<String>,
238 pub subject: Option<SubjectKind>,
239 /// The work item's or pull request's number.
240 pub number: Option<u32>,
241 /// Where it is on g1t.sh: a path such as `/acme/rocket/pull/12`.
242 pub url: String,
243 /// Who did the latest: a username, or `g1t`. Absent when nobody did.
244 pub actor: Option<String>,
245 /// How many things have happened on the thread.
246 pub count: u32,
247 /// RFC 3339: when the person was first told of the thread.
248 pub created_at: String,
249 /// RFC 3339: its latest activity.
250 pub updated_at: String,
251 pub read_at: Option<String>,
252 pub done_at: Option<String>,
253 pub saved: bool,
254 /// While this is in the future the item is out of the list.
255 pub snoozed_until: Option<String>,
256 /// Why it settled by itself, in a few words, such as `CI passed in
257 /// run 330` or `Merged`: set when the event that left nothing to do
258 /// marked it done. Absent while it is not done, and when it was marked
259 /// done by hand.
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub settled_reason: Option<String>,
262 /// Who settled it, when a person or g1t did: a username, or `g1t`.
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub settled_by: Option<String>,
265}
266
267/// One thing that happened on a thread, as the person was told of it.
268#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub struct InboxActivity {
271 pub reason: Reason,
272 pub severity: Severity,
273 pub title: String,
274 pub body: String,
275 pub event: Option<String>,
276 pub actor: Option<String>,
277 pub created_at: String,
278}
279
280/// The most activity kept per thread, newest first.
281pub const MAX_ACTIVITY: u32 = 10;
282
283/// Which items a list shows.
284#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "lowercase")]
286pub enum InboxView {
287 /// Everything not done and not snoozed: the inbox itself.
288 #[default]
289 Inbox,
290 /// Saved items, done or not.
291 Saved,
292 /// Items marked done.
293 Done,
294}
295
296impl InboxView {
297 pub fn parse(value: &str) -> Option<InboxView> {
298 match value {
299 "inbox" => Some(InboxView::Inbox),
300 "saved" => Some(InboxView::Saved),
301 "done" => Some(InboxView::Done),
302 _ => None,
303 }
304 }
305}
306
307/// `inbox_list`. Latest activity first, except that in the inbox
308/// unfiltered, unread warnings (what is waiting on the person) come first.
309#[derive(Clone, Debug, Default, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct ListInboxArgs {
312 /// Whose inbox: the person signed in. Their memberships decide which
313 /// repositories they can still read.
314 pub viewer: Viewer,
315 #[serde(default)]
316 pub view: InboxView,
317 /// Only items of this severity.
318 #[serde(default)]
319 pub severity: Option<Severity>,
320 /// Only items told for this reason.
321 #[serde(default)]
322 pub reason: Option<Reason>,
323 /// Only items the person takes part in (see [`Reason::participating`]).
324 #[serde(default)]
325 pub participating: bool,
326 /// Only items about this repository.
327 #[serde(default)]
328 pub repo_id: Option<String>,
329 #[serde(default)]
330 pub unread: bool,
331 /// RFC 3339: only items with activity at or after it.
332 #[serde(default)]
333 pub since: Option<String>,
334 /// RFC 3339: only items whose latest activity was before it.
335 #[serde(default)]
336 pub updated_before: Option<String>,
337 /// The `next` of the page before.
338 #[serde(default)]
339 pub before: Option<String>,
340 #[serde(default)]
341 pub limit: Option<u32>,
342}
343
344pub const DEFAULT_INBOX_PAGE: u32 = 30;
345pub const MAX_INBOX_PAGE: u32 = 100;
346
347#[derive(Clone, Debug, Default, Serialize, Deserialize)]
348#[serde(rename_all = "camelCase")]
349pub struct InboxPage {
350 pub items: Vec<InboxItem>,
351 /// Pass as `before` for the next page; absent on the last.
352 pub next: Option<String>,
353}
354
355/// `inbox_counts`, and the other methods that need only whose.
356#[derive(Clone, Debug, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase")]
358pub struct InboxCountsArgs {
359 pub username: String,
360}
361
362/// Unread items in the inbox view, by severity.
363#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
364#[serde(rename_all = "camelCase")]
365pub struct InboxCounts {
366 pub unread: u32,
367 pub error: u32,
368 pub warning: u32,
369 pub success: u32,
370 pub info: u32,
371}
372
373/// What `inbox_mark` does to the items it names.
374#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(rename_all = "snake_case")]
376pub enum InboxMark {
377 Read,
378 Unread,
379 /// Out of the inbox, into Done; read with it.
380 Done,
381 /// Back into the inbox.
382 Undone,
383 Save,
384 Unsave,
385 /// Out of the inbox until `until`.
386 Snooze,
387 /// Back into the inbox now.
388 Unsnooze,
389}
390
391/// `inbox_mark`: changes the person's own items, by id, or every item in
392/// their inbox when `ids` is empty and `all` is set (Mark all read).
393#[derive(Clone, Debug, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase")]
395pub struct MarkInboxArgs {
396 pub username: String,
397 pub mark: InboxMark,
398 #[serde(default)]
399 pub ids: Vec<String>,
400 #[serde(default)]
401 pub all: bool,
402 /// With `all`: only items of this severity.
403 #[serde(default)]
404 pub severity: Option<Severity>,
405 /// With `all`: only items about this repository.
406 #[serde(default)]
407 pub repo_id: Option<String>,
408 /// With `all`: only items whose latest activity was at or before this
409 /// (RFC 3339), so what arrived after the person looked stays unread.
410 #[serde(default)]
411 pub last_read_at: Option<String>,
412 /// For `snooze`: RFC 3339.
413 #[serde(default)]
414 pub until: Option<String>,
415}
416
417/// The most ids one `inbox_mark` call changes.
418pub const MAX_MARK: usize = 100;
419
420/// `inbox_thread`: one of the viewer's own threads, by id.
421#[derive(Clone, Debug, Serialize, Deserialize)]
422#[serde(rename_all = "camelCase")]
423pub struct ThreadArgs {
424 pub viewer: Viewer,
425 pub id: String,
426}
427
428/// A thread with its history and the person's subscription to it.
429#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
430#[serde(rename_all = "camelCase")]
431pub struct InboxThread {
432 #[serde(flatten)]
433 pub item: InboxItem,
434 /// Newest first, at most [`MAX_ACTIVITY`].
435 pub activity: Vec<InboxActivity>,
436 /// For a work item or pull request; absent for a run or a deployment,
437 /// which nobody subscribes to.
438 pub subscription: Option<ThreadSubscription>,
439}
440
441/// A person's subscription to a work item or pull request.
442#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "camelCase")]
444pub struct ThreadSubscription {
445 /// Whether they hear of what happens on it.
446 pub subscribed: bool,
447 /// Whether they hear of nothing on it at all, not even a mention.
448 pub ignored: bool,
449 /// Why they are subscribed: they opened it (`author`), are assigned
450 /// (`assign`), were asked to review (`review_requested`), commented
451 /// (`comment`), were mentioned (`mention`) or subscribed by hand
452 /// (`manual`). Absent when they are not.
453 pub reason: Option<Reason>,
454 /// `owner/name` (a work item's workspace, for one), and the work
455 /// item's or pull request's number.
456 pub repo: Option<String>,
457 pub number: Option<u32>,
458 /// RFC 3339: when they last chose, or null if they never did.
459 pub updated_at: Option<String>,
460}
461
462/// Which work item or pull request: by a thread's id, by a work item's id
463/// (with its number, to say back), or by a pull request's repository and
464/// number.
465#[derive(Clone, Debug, Default, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467pub struct SubscriptionArgs {
468 pub viewer: Viewer,
469 #[serde(default)]
470 pub id: Option<String>,
471 #[serde(default)]
472 pub item_id: Option<String>,
473 #[serde(default)]
474 pub repo_id: Option<String>,
475 #[serde(default)]
476 pub number: Option<u32>,
477}
478
479/// `inbox_subscribe`.
480#[derive(Clone, Debug, Default, Serialize, Deserialize)]
481#[serde(rename_all = "camelCase")]
482pub struct SubscribeArgs {
483 #[serde(flatten)]
484 pub on: SubscriptionArgs,
485 /// True to subscribe, false to unsubscribe. Absent with `ignored`
486 /// false: back to the default, subscribed only while taking part.
487 #[serde(default)]
488 pub subscribed: Option<bool>,
489 /// True to hear of nothing on it, not even a mention.
490 #[serde(default)]
491 pub ignored: bool,
492}
493
494/// How closely a person follows a repository.
495#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(rename_all = "snake_case")]
497pub enum WatchLevel {
498 /// Only what they take part in or are mentioned in: the default.
499 #[default]
500 Participating,
501 /// Everything: every work item and pull request opened, commented on,
502 /// closed or merged, and every deployment.
503 All,
504 /// Nothing at all, not even a mention.
505 Ignore,
506 /// What they take part in, and the kinds of activity in `events`.
507 Custom,
508}
509
510impl WatchLevel {
511 pub const ALL: [WatchLevel; 4] = [WatchLevel::Participating, WatchLevel::All, WatchLevel::Ignore, WatchLevel::Custom];
512
513 pub fn as_str(self) -> &'static str {
514 match self {
515 WatchLevel::Participating => "participating",
516 WatchLevel::All => "all",
517 WatchLevel::Ignore => "ignore",
518 WatchLevel::Custom => "custom",
519 }
520 }
521
522 pub fn parse(value: &str) -> Option<WatchLevel> {
523 WatchLevel::ALL.into_iter().find(|level| level.as_str() == value)
524 }
525}
526
527/// The kinds of activity a custom watch can follow.
528pub const WATCH_EVENTS: [&str; 4] = ["work_items", "pulls", "deployments", "security"];
529
530/// How a person watches one repository.
531#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
532#[serde(rename_all = "camelCase")]
533pub struct Watching {
534 pub repo_id: String,
535 /// `owner/name`, when known.
536 #[serde(default)]
537 pub repo: Option<String>,
538 pub level: WatchLevel,
539 /// With `custom`: some of [`WATCH_EVENTS`].
540 #[serde(default)]
541 pub events: Vec<String>,
542 /// RFC 3339: when they chose, or null if they never did.
543 #[serde(default)]
544 pub updated_at: Option<String>,
545}
546
547/// `inbox_watching`.
548#[derive(Clone, Debug, Serialize, Deserialize)]
549#[serde(rename_all = "camelCase")]
550pub struct WatchingArgs {
551 pub username: String,
552 pub repo_id: String,
553}
554
555/// `inbox_watchers`: how many watch a repository: all of its activity,
556/// or some of it (custom). The default (participating) and ignoring are
557/// not counted. Returns `u64`.
558#[derive(Clone, Debug, Serialize, Deserialize)]
559#[serde(rename_all = "camelCase")]
560pub struct WatchersArgs {
561 pub repo_id: String,
562}
563
564/// `inbox_watch`. No `level`: back to the default.
565#[derive(Clone, Debug, Serialize, Deserialize)]
566#[serde(rename_all = "camelCase")]
567pub struct WatchArgs {
568 pub username: String,
569 pub repo_id: String,
570 /// `owner/name`, kept to list what the person watches.
571 #[serde(default)]
572 pub repo: Option<String>,
573 #[serde(default)]
574 pub level: Option<WatchLevel>,
575 #[serde(default)]
576 pub events: Vec<String>,
577}
578
579/// When security alerts that are not urgent (medium, low, and what is only
580/// to know) reach a person: in a digest once a day or once a week, at once
581/// like the urgent ones, or not at all. Critical and high alerts, a refused
582/// push and a vulnerability report never wait for the digest.
583#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
584#[serde(rename_all = "snake_case")]
585pub enum DigestCadence {
586 #[default]
587 Daily,
588 Weekly,
589 AtOnce,
590 Off,
591}
592
593impl DigestCadence {
594 pub const ALL: [DigestCadence; 4] = [DigestCadence::Daily, DigestCadence::Weekly, DigestCadence::AtOnce, DigestCadence::Off];
595
596 pub fn as_str(self) -> &'static str {
597 match self {
598 DigestCadence::Daily => "daily",
599 DigestCadence::Weekly => "weekly",
600 DigestCadence::AtOnce => "at_once",
601 DigestCadence::Off => "off",
602 }
603 }
604
605 pub fn parse(value: &str) -> Option<DigestCadence> {
606 DigestCadence::ALL.into_iter().find(|cadence| cadence.as_str() == value)
607 }
608}
609
610/// A person's choices about being told.
611#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
612#[serde(rename_all = "camelCase")]
613pub struct InboxSettings {
614 /// The reasons they are also emailed for.
615 pub email: Vec<Reason>,
616 /// How they watch a repository they create.
617 pub default_watch: WatchLevel,
618 /// Whether critical and high security alerts on repositories they watch
619 /// for security reach their inbox at once. A workspace's owners and
620 /// security managers are told whatever this says: those alerts are
621 /// theirs to act on.
622 #[serde(default = "yes")]
623 pub security_alerts: bool,
624 /// When the rest of the security alerts reach them.
625 #[serde(default)]
626 pub security_digest: DigestCadence,
627}
628
629fn yes() -> bool {
630 true
631}
632
633impl Default for InboxSettings {
634 fn default() -> Self {
635 InboxSettings {
636 email: DEFAULT_EMAIL.to_vec(),
637 default_watch: WatchLevel::All,
638 security_alerts: true,
639 security_digest: DigestCadence::Daily,
640 }
641 }
642}
643
644/// What a person is emailed for until they choose: what is waiting on
645/// them, and security alerts, which reach their inbox only when urgent or
646/// as their digest.
647pub const DEFAULT_EMAIL: [Reason; 4] = [Reason::Agent, Reason::ReviewRequested, Reason::Mention, Reason::SecurityAlert];
648
649/// `inbox_update_settings`. What is left out is unchanged.
650#[derive(Clone, Debug, Serialize, Deserialize)]
651#[serde(rename_all = "camelCase")]
652pub struct UpdateInboxSettingsArgs {
653 pub username: String,
654 #[serde(default)]
655 pub email: Option<Vec<Reason>>,
656 #[serde(default)]
657 pub default_watch: Option<WatchLevel>,
658 #[serde(default)]
659 pub security_alerts: Option<bool>,
660 #[serde(default)]
661 pub security_digest: Option<DigestCadence>,
662}
663
664/// `inbox_subject` on the work service: what an event names, for the
665/// inbox: a work item by `item_id` (from a `work_item.*` or comment
666/// event's data), else the pull request `number` of `repo_id`. A
667/// service-to-service read: it checks nobody's access, and what it returns
668/// is only ever shown to the people it names, or to those who can read it.
669#[derive(Clone, Debug, Serialize, Deserialize)]
670#[serde(rename_all = "camelCase")]
671pub struct InboxSubjectArgs {
672 #[serde(default)]
673 pub repo_id: Option<String>,
674 #[serde(default)]
675 pub number: u32,
676 #[serde(default)]
677 pub item_id: Option<String>,
678 /// The comment the event is about, if any.
679 #[serde(default)]
680 pub comment_id: Option<String>,
681}
682
683#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
684#[serde(rename_all = "camelCase")]
685pub struct InboxSubject {
686 /// `item` or `pull`.
687 pub kind: Option<SubjectKind>,
688 pub title: String,
689 pub author: Principal,
690 /// For work g1t did: the person it was done for.
691 #[serde(default)]
692 pub requested_by: Option<Principal>,
693 /// Usernames.
694 #[serde(default)]
695 pub assignees: Vec<String>,
696 /// Usernames, and `g1t`. Pull requests only.
697 #[serde(default)]
698 pub reviewers: Vec<String>,
699 /// The people its description mentions by name, outside code and
700 /// quotes, for `work_item.opened` and `pull.opened`. Never `g1t`.
701 #[serde(default)]
702 pub mentions: Vec<String>,
703 /// The teams its description mentions, with the people each tells.
704 #[serde(default)]
705 pub team_mentions: Vec<TeamMentioned>,
706 /// For a pull request: the work item it is for, with its people.
707 #[serde(default)]
708 pub item: Option<Box<InboxSubject>>,
709 #[serde(default)]
710 pub comment: Option<InboxComment>,
711}
712
713impl InboxSubject {
714 /// Whose it is to answer for: whoever asked g1t for it, or its author.
715 pub fn owner(&self) -> &Principal {
716 self.requested_by.as_ref().unwrap_or(&self.author)
717 }
718}
719
720#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
721#[serde(rename_all = "camelCase")]
722pub struct InboxComment {
723 pub author: Principal,
724 /// The first line or so, as written.
725 pub excerpt: String,
726 /// The people it mentions by name, outside code and quotes. Never `g1t`.
727 #[serde(default)]
728 pub mentions: Vec<String>,
729 /// The teams it mentions (`@workspace/team`), with the people each
730 /// tells: everyone in the team and its child teams, unless the team
731 /// turned notifications off. A secret team tells nobody unless the
732 /// writer is in it.
733 #[serde(default)]
734 pub team_mentions: Vec<TeamMentioned>,
735 /// A review's verdict: `approve` or `request_changes`.
736 #[serde(default)]
737 pub verdict: Option<String>,
738 /// Something that happened (an assignment, a close), not something written.
739 #[serde(default)]
740 pub event: bool,
741 /// Set when one of the workspace's agents wrote it, as itself: it is
742 /// shown by its name, marked as an agent, never as a person.
743 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub agent: Option<crate::work::AgentRef>,
745 /// Who the agent acted for, set with `agent`.
746 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub acting_for: Option<Principal>,
748 /// An agent's review: its verdict is advisory.
749 #[serde(default)]
750 pub advisory: bool,
751}
752
753/// A team a comment or description mentions, and who it tells.
754#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
755#[serde(rename_all = "camelCase")]
756pub struct TeamMentioned {
757 /// `workspace/slug`.
758 pub team: String,
759 /// Usernames.
760 pub members: Vec<String>,
761}
762
763/// `notify_by_email` on the identity service: one item, emailed to the
764/// person it is for, if they can still read its repository and their
765/// address is confirmed. Returns whether it was sent.
766#[derive(Clone, Debug, Serialize, Deserialize)]
767#[serde(rename_all = "camelCase")]
768pub struct NotifyByEmailArgs {
769 pub username: String,
770 pub repo_id: String,
771 /// The subject line and the first paragraph.
772 pub subject: String,
773 pub intro: String,
774 /// What was said, and who said it, when it was written by someone.
775 #[serde(default)]
776 pub quote: Option<(String, String)>,
777 /// A path on the site, such as `/acme/rocket/pull/12`.
778 pub path: String,
779 pub reason: Reason,
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 #[test]
787 fn severities_reasons_and_subjects_read_back() {
788 for severity in Severity::ALL {
789 assert_eq!(Severity::parse(severity.as_str()), Some(severity));
790 }
791 assert_eq!(Severity::parse("fatal"), None);
792 for reason in Reason::ALL {
793 assert_eq!(Reason::parse(reason.as_str()), Some(reason));
794 assert_eq!(serde_json::to_value(reason).unwrap(), reason.as_str());
795 }
796 for kind in SubjectKind::ALL {
797 assert_eq!(SubjectKind::parse(kind.as_str()), Some(kind));
798 }
799 for level in WatchLevel::ALL {
800 assert_eq!(WatchLevel::parse(level.as_str()), Some(level));
801 }
802 assert_eq!(serde_json::to_value(InboxMark::Unsave).unwrap(), "unsave");
803 }
804
805 #[test]
806 fn what_is_asked_of_a_person_outranks_what_they_follow() {
807 assert!(Reason::Agent.rank() < Reason::Mention.rank());
808 assert!(Reason::Mention.rank() < Reason::Author.rank());
809 assert!(Reason::Author.rank() < Reason::Subscribed.rank());
810 assert!(Reason::ReviewRequested.direct() && !Reason::Comment.direct());
811 assert!(!Reason::Subscribed.participating() && Reason::Author.participating());
812 assert!(Severity::Warning.urgency() < Severity::Error.urgency());
813 }
814
815 #[test]
816 fn a_thread_carries_its_item_flat() {
817 let thread = InboxThread {
818 item: InboxItem {
819 id: "ntf_1".into(),
820 reason: Reason::Mention,
821 severity: Severity::Info,
822 title: "t".into(),
823 body: "b".into(),
824 event: None,
825 repo: None,
826 workspace: None,
827 subject: None,
828 number: None,
829 url: "/inbox".into(),
830 actor: None,
831 count: 1,
832 created_at: "2026-10-07T12:00:00.000Z".into(),
833 updated_at: "2026-10-07T12:00:00.000Z".into(),
834 read_at: None,
835 done_at: None,
836 saved: false,
837 snoozed_until: None,
838 settled_reason: None,
839 settled_by: None,
840 },
841 activity: Vec::new(),
842 subscription: None,
843 };
844 let value = serde_json::to_value(&thread).unwrap();
845 assert_eq!(value["id"], "ntf_1");
846 assert_eq!(value["reason"], "mention");
847 assert!(value["activity"].is_array());
848 }
849}