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.

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