Skip to content

Commit

Inbox: threads, reasons, subscriptions and watching

Each person has one item per thread (an issue or pull request, a workflow on a branch, a project's deployments): new activity brings it back to the top unread, keeps its most urgent severity while unread, counts what has happened and keeps the last 10 lines of it. Every item says why the person was told: agent, review_requested, assign, mention, ci_activity, state_change, author, comment, manual or subscribed. Who is told follows subscriptions and watching. Authors, assignees and reviewers are subscribed; commenting or being mentioned subscribes; people can subscribe, unsubscribe or ignore a thread, and watch a repository as participating, all, ignore or custom. Review requests and assignments tell whoever was asked, closes, reopens, merges and comments tell everyone subscribed, watchers hear of what they chose, and an agent stopping on a pull request needs its people until it picks back up. Never the actor, never g1t. Migration 0006_inbox_threads on g1t-events folds existing items into threads and adds subscriptions, watching and settings. Whoever creates a repository watches it as they chose (all activity unless they change it). People are emailed, through identity's new notify_by_email and the existing product mail, for the reasons they choose (by default an agent waiting, a review request and a mention), only at a confirmed address and only while they can read the repository.

syntaqxcommitted Parent3c8b38cBrowse files
8 files+2673−2640/8 viewed
+457−20
22 //!
33 //! The events service keeps it, beside the event log: as events arrive it
44 //! works out who should hear of each (see `services/events/src/inbox.rs`)
5−//! and writes one item per person. Items are kept by username, which never
6−//! changes. Methods, served at `POST /rpc/<method>` on the events service:
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:
719 //!
820 //! - `inbox_list` takes `ListInboxArgs` and returns `InboxPage`. Items about
921 //! a repository the viewer can no longer read are dropped as they are
1123 //! - `inbox_counts` takes `InboxCountsArgs` and returns `InboxCounts`: the
1224 //! unread items, by severity. One query, for every page's top bar.
1325 //! - `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`.
1437 //!
1538 //! What an event is about (the issue or pull request, its people, the
1639 //! comment) comes from the work service's `inbox_subject`, which takes
2245 use crate::credentials::Principal;
2346
2447 /// How much an item matters, and how it is shown: a failure, something a
25−/// person must answer (an agent waiting on them), something that went
26−/// well, or something to know.
48+/// person must answer (an agent waiting on them, a review asked of them),
49+/// something that went well, or something to know.
2750 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
2851 #[serde(rename_all = "lowercase")]
2952 pub enum Severity {
4871 pub fn parse(value: &str) -> Option<Severity> {
4972 Severity::ALL.into_iter().find(|severity| severity.as_str() == value)
5073 }
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,
51115 }
52116
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+
53176 /// What an item is about.
54177 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
55178 #[serde(rename_all = "lowercase")]
56179 pub enum SubjectKind {
57180 Issue,
58181 Pull,
59− /// A workflow run.
182+ /// A workflow's runs on one branch.
60183 Run,
184+ /// A project's deployments: production, or one pull request's preview.
185+ Deploy,
61186 }
62187
63188 impl SubjectKind {
189+ pub const ALL: [SubjectKind; 4] = [SubjectKind::Issue, SubjectKind::Pull, SubjectKind::Run, SubjectKind::Deploy];
190+
64191 pub fn as_str(self) -> &'static str {
65192 match self {
66193 SubjectKind::Issue => "issue",
67194 SubjectKind::Pull => "pull",
68195 SubjectKind::Run => "run",
196+ SubjectKind::Deploy => "deploy",
69197 }
70198 }
71199
72200 pub fn parse(value: &str) -> Option<SubjectKind> {
73− [SubjectKind::Issue, SubjectKind::Pull, SubjectKind::Run]
74− .into_iter()
75− .find(|kind| kind.as_str() == value)
201+ SubjectKind::ALL.into_iter().find(|kind| kind.as_str() == value)
76202 }
77203 }
78204
79−/// One thing a person was told.
205+/// One thread in a person's inbox: what it is about, and its latest
206+/// activity.
80207 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81208 #[serde(rename_all = "camelCase")]
82209 pub struct InboxItem {
210+ /// The thread's id: the same for as long as the person has it.
83211 pub id: String,
84− /// Why they were told, such as `checks_failed` or `mentioned`.
85− pub reason: String,
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.
86216 pub severity: Severity,
87− /// One line: what happened, and where.
217+ /// One line: what happened last, and where.
88218 pub title: String,
89219 /// One line: what it happened to, such as the pull request's title.
90220 pub body: String,
221+ /// The event behind the latest activity, such as `pull.merged`.
222+ pub event: Option<String>,
91223 /// `owner/name`.
92224 pub repo: Option<String>,
93225 /// The workspace it happened in.
97229 pub number: Option<u32>,
98230 /// Where it is on g1t.sh: a path such as `/acme/rocket/pull/12`.
99231 pub url: String,
100− /// Who did it: a username, or `g1t`. Absent when nobody did.
232+ /// Who did the latest: a username, or `g1t`. Absent when nobody did.
101233 pub actor: Option<String>,
102− /// RFC 3339.
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.
103237 pub created_at: String,
238+ /// RFC 3339: its latest activity.
239+ pub updated_at: String,
104240 pub read_at: Option<String>,
105241 pub done_at: Option<String>,
106242 pub saved: bool,
108244 pub snoozed_until: Option<String>,
109245 }
110246
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+
111263 /// Which items a list shows.
112264 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
113265 #[serde(rename_all = "lowercase")]
121273 Done,
122274 }
123275
124−/// `inbox_list`. Newest first, except that unread warnings (an agent
125−/// waiting on the person) come before everything else in the inbox.
126−#[derive(Clone, Debug, Serialize, Deserialize)]
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)]
127290 #[serde(rename_all = "camelCase")]
128291 pub struct ListInboxArgs {
129292 /// Whose inbox: the person signed in. Their memberships decide which
134297 /// Only items of this severity.
135298 #[serde(default)]
136299 pub severity: Option<Severity>,
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`]).
137304 #[serde(default)]
305+ pub participating: bool,
306+ /// Only items about this repository.
307+ #[serde(default)]
308+ pub repo_id: Option<String>,
309+ #[serde(default)]
138310 pub unread: bool,
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>,
139317 /// The `next` of the page before.
140318 #[serde(default)]
141319 pub before: Option<String>,
154332 pub next: Option<String>,
155333 }
156334
157−/// `inbox_counts`.
335+/// `inbox_counts`, and the other methods that need only whose.
158336 #[derive(Clone, Debug, Serialize, Deserialize)]
159337 #[serde(rename_all = "camelCase")]
160338 pub struct InboxCountsArgs {
186364 Unsave,
187365 /// Out of the inbox until `until`.
188366 Snooze,
367+ /// Back into the inbox now.
368+ Unsnooze,
189369 }
190370
191371 /// `inbox_mark`: changes the person's own items, by id, or every item in
202382 /// With `all`: only items of this severity.
203383 #[serde(default)]
204384 pub severity: Option<Severity>,
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>,
205392 /// For `snooze`: RFC 3339.
206393 #[serde(default)]
207394 pub until: Option<String>,
210397 /// The most ids one `inbox_mark` call changes.
211398 pub const MAX_MARK: usize = 100;
212399
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+
213579 /// `inbox_subject` on the work service: what an event names, for the
214580 /// inbox. A service-to-service read: it checks nobody's access, and what
215581 /// it returns is only ever shown to the people it names, or to those who
271637 pub event: bool,
272638 }
273639
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+
274659 #[cfg(test)]
275660 mod tests {
276661 use super::*;
277662
278663 #[test]
279− fn severities_and_subjects_read_back() {
664+ fn severities_reasons_and_subjects_read_back() {
280665 for severity in Severity::ALL {
281666 assert_eq!(Severity::parse(severity.as_str()), Some(severity));
282667 }
283668 assert_eq!(Severity::parse("fatal"), None);
284− assert_eq!(SubjectKind::parse("pull"), Some(SubjectKind::Pull));
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+ }
285679 assert_eq!(serde_json::to_value(InboxMark::Unsave).unwrap(), "unsave");
286680 }
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+ }
287724 }
+150−11
11 /**
22 * The inbox: what needs a person, or what they follow, as it happens. The
3− * events service keeps it and writes items as events arrive. Mirrors
4− * `crates/contracts/src/inbox.rs`, which says who is told of what.
3+ * events service keeps it and brings each person's thread about a thing
4+ * (an issue, a pull request, a workflow on a branch, a deployment) back to
5+ * the top as things happen to it. Mirrors `crates/contracts/src/inbox.rs`,
6+ * which says who is told of what, and why.
57 */
68 import type { ServiceBinding } from "./clients";
79 import type { User } from "./identity";
810
911 /**
1012 * How much an item matters: a failure, something a person must answer (an
11− * agent waiting on them), something that went well, or something to know.
13+ * agent waiting on them, a review asked of them), something that went well,
14+ * or something to know.
1215 */
1316 export type InboxSeverity = "error" | "warning" | "success" | "info";
1417
1518 export const INBOX_SEVERITIES: InboxSeverity[] = ["error", "warning", "success", "info"];
1619
17−export type InboxSubjectKind = "issue" | "pull" | "run";
20+/**
21+ * Why a person was told: what the thread asked of them, or what ties them
22+ * to it. Most specific first.
23+ */
24+export type InboxReason =
25+ | "agent"
26+ | "review_requested"
27+ | "assign"
28+ | "mention"
29+ | "ci_activity"
30+ | "security_alert"
31+ | "state_change"
32+ | "author"
33+ | "comment"
34+ | "manual"
35+ | "subscribed";
36+
37+export const INBOX_REASONS: InboxReason[] = [
38+ "agent",
39+ "review_requested",
40+ "assign",
41+ "mention",
42+ "ci_activity",
43+ "security_alert",
44+ "state_change",
45+ "author",
46+ "comment",
47+ "manual",
48+ "subscribed",
49+];
1850
51+export type InboxSubjectKind = "issue" | "pull" | "run" | "deploy";
52+
53+/** One thread in a person's inbox: what it is about, and its latest activity. */
1954 export type InboxItem = {
55+ /** The thread's id: the same for as long as the person has it. */
2056 id: string;
21− /** Why they were told, such as `checks_failed`, `agent_asked` or `mentioned`. */
22− reason: string;
57+ /** Why they were told of the latest activity. */
58+ reason: InboxReason;
59+ /** While unread, the most urgent of what happened since it was last read. */
2360 severity: InboxSeverity;
24− /** One line: what happened, and where. */
61+ /** One line: what happened last, and where. */
2562 title: string;
2663 /** One line: what it happened to, such as the pull request's title. */
2764 body: string;
65+ /** The event behind the latest activity, such as `pull.merged`. */
66+ event: string | null;
2867 /** `owner/name`. */
2968 repo: string | null;
3069 workspace: string | null;
3473 url: string;
3574 /** A username, or `g1t`. */
3675 actor: string | null;
37− /** RFC 3339. */
76+ /** How many things have happened on the thread. */
77+ count: number;
78+ /** RFC 3339: when the person was first told of the thread. */
3879 createdAt: string;
80+ /** RFC 3339: its latest activity. */
81+ updatedAt: string;
3982 readAt: string | null;
4083 doneAt: string | null;
4184 saved: boolean;
4285 snoozedUntil: string | null;
4386 };
4487
88+/** One thing that happened on a thread, as the person was told of it. */
89+export type InboxActivity = {
90+ reason: InboxReason;
91+ severity: InboxSeverity;
92+ title: string;
93+ body: string;
94+ event: string | null;
95+ actor: string | null;
96+ createdAt: string;
97+};
98+
99+/** A person's subscription to an issue or pull request. */
100+export type ThreadSubscription = {
101+ /** Whether they hear of what happens on it. */
102+ subscribed: boolean;
103+ /** Whether they hear of nothing on it at all, not even a mention. */
104+ ignored: boolean;
105+ /** Why they are subscribed; null when they are not. */
106+ reason: InboxReason | null;
107+ repo: string | null;
108+ number: number | null;
109+ /** When they last chose, or null if they never did. */
110+ updatedAt: string | null;
111+};
112+
113+/** A thread with its history, newest first, and the person's subscription. */
114+export type InboxThread = InboxItem & {
115+ activity: InboxActivity[];
116+ subscription: ThreadSubscription | null;
117+};
118+
119+/** How closely a person follows a repository. */
120+export type WatchLevel = "participating" | "all" | "ignore" | "custom";
121+
122+export const WATCH_LEVELS: WatchLevel[] = ["participating", "all", "ignore", "custom"];
123+
124+/** The kinds of activity a custom watch can follow. */
125+export type WatchEvent = "issues" | "pulls" | "deployments" | "security";
126+
127+export const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"];
128+
129+export type Watching = {
130+ repoId: string;
131+ repo: string | null;
132+ level: WatchLevel;
133+ /** With `custom`: what it follows. */
134+ events: WatchEvent[];
135+ updatedAt: string | null;
136+};
137+
138+/** A person's choices about being told. */
139+export type InboxSettings = {
140+ /** The reasons they are also emailed for. */
141+ email: InboxReason[];
142+ /** How they watch a repository they create. */
143+ defaultWatch: WatchLevel;
144+};
145+
45146 /** The inbox itself (not done, not snoozed), what was saved, or what is done. */
46147 export type InboxView = "inbox" | "saved" | "done";
47148
48149 export type InboxQuery = {
49150 view?: InboxView;
50151 severity?: InboxSeverity | null;
152+ reason?: InboxReason | null;
153+ /** Only threads the person takes part in: not those followed by watching or by hand. */
154+ participating?: boolean;
155+ repoId?: string | null;
51156 unread?: boolean;
157+ /** RFC 3339: only threads with activity at or after it. */
158+ since?: string | null;
159+ /** RFC 3339: only threads whose latest activity was before it. */
160+ updatedBefore?: string | null;
52161 /** The `next` of the page before. */
53162 before?: string | null;
54163 /** At most 100. */
60169 /** Unread items in the inbox, by severity. */
61170 export type InboxCounts = { unread: number } & Record<InboxSeverity, number>;
62171
63−export type InboxMark = "read" | "unread" | "done" | "undone" | "save" | "unsave" | "snooze";
172+export type InboxMark = "read" | "unread" | "done" | "undone" | "save" | "unsave" | "snooze" | "unsnooze";
64173
65174 export type InboxMarkArgs = {
66175 mark: InboxMark;
69178 /** Every item in the inbox, when `ids` is empty: Mark all read. */
70179 all?: boolean;
71180 severity?: InboxSeverity | null;
181+ repoId?: string | null;
182+ /** With `all`: only threads whose latest activity was at or before this. */
183+ lastReadAt?: string | null;
72184 /** For `snooze`: RFC 3339, later than now. */
73185 until?: string | null;
74186 };
75187
188+/** Which issue or pull request: a thread's id, or a repository and number. */
189+export type SubscriptionTarget = { id: string } | { repoId: string; number: number };
190+
76191 export interface InboxApi {
77192 /**
78− * Newest first; in the inbox unfiltered, unread warnings first. Items about
79− * a repository the viewer can no longer read are dropped.
193+ * Latest activity first; in the inbox unfiltered, unread warnings first.
194+ * Items about a repository the viewer can no longer read are dropped.
80195 */
81196 list(viewer: User, query?: InboxQuery): Promise<InboxPage>;
82197 counts(username: string): Promise<InboxCounts>;
83198 /** Changes the person's own items. Returns how many changed. */
84199 mark(username: string, args: InboxMarkArgs): Promise<number>;
200+ /** One of the viewer's threads with its history, or null. */
201+ thread(viewer: User, id: string): Promise<InboxThread | null>;
202+ /** The viewer's subscription to an issue or pull request; null when there is none to have. */
203+ subscription(viewer: User, target: SubscriptionTarget): Promise<ThreadSubscription | null>;
204+ /**
205+ * Subscribes (`true`), unsubscribes (`false`), ignores, or goes back to
206+ * the default (`null`, subscribed only while taking part).
207+ */
208+ subscribe(viewer: User, target: SubscriptionTarget, subscribed: boolean | null, ignored?: boolean): Promise<ThreadSubscription | null>;
209+ watching(username: string, repoId: string): Promise<Watching>;
210+ /** `null` goes back to the default, participating. */
211+ watch(username: string, repoId: string, repo: string, level: WatchLevel | null, events?: WatchEvent[]): Promise<Watching>;
212+ watched(username: string): Promise<Watching[]>;
213+ settings(username: string): Promise<InboxSettings>;
214+ updateSettings(username: string, changes: Partial<InboxSettings>): Promise<InboxSettings>;
85215 }
86216
87217 /** The inbox, which the events service keeps. */
99229 list: (viewer, query = {}) => call("inbox_list", { viewer, ...query }),
100230 counts: (username) => call("inbox_counts", { username }),
101231 mark: (username, args) => call("inbox_mark", { username, ...args }),
232+ thread: (viewer, id) => call("inbox_thread", { viewer, id }),
233+ subscription: (viewer, target) => call("inbox_subscription", { viewer, ...target }),
234+ subscribe: (viewer, target, subscribed, ignored = false) =>
235+ call("inbox_subscribe", { viewer, ...target, subscribed, ignored }),
236+ watching: (username, repoId) => call("inbox_watching", { username, repoId }),
237+ watch: (username, repoId, repo, level, events = []) => call("inbox_watch", { username, repoId, repo, level, events }),
238+ watched: (username) => call("inbox_watched", { username }),
239+ settings: (username) => call("inbox_settings", { username }),
240+ updateSettings: (username, changes) => call("inbox_update_settings", { username, ...changes }),
102241 };
103242 }
+136−0
1+-- The inbox in threads (src/inbox.rs): one item per person per thing it is
2+-- about, brought back to the top by new activity, with a short history;
3+-- why each person was told, in a fixed set of reasons; subscriptions to
4+-- issues and pull requests; how people watch repositories; and what each
5+-- person chose about being told.
6+
7+-- What the item is about, as one key: `<repo_id>#<number>` for an issue or
8+-- pull request, `<repo_id>/run/<workflow>@<branch>` for a workflow on a
9+-- branch, `<repo_id>/deploy/<project_id>/<production|number>` for a
10+-- project's deployments.
11+ALTER TABLE inbox_items ADD COLUMN thread TEXT;
12+-- The latest activity: when, as a time-sortable id lists order by, its
13+-- event type, and how many things have happened on the thread.
14+ALTER TABLE inbox_items ADD COLUMN updated_at TEXT;
15+ALTER TABLE inbox_items ADD COLUMN bumped TEXT;
16+ALTER TABLE inbox_items ADD COLUMN event TEXT;
17+ALTER TABLE inbox_items ADD COLUMN activity INTEGER NOT NULL DEFAULT 1;
18+-- A path on the site, for a subject with a page of its own (a deployment).
19+ALTER TABLE inbox_items ADD COLUMN link TEXT;
20+
21+-- Reasons were named for what happened; now they say why the person was told.
22+UPDATE inbox_items SET reason = CASE reason
23+ WHEN 'agent_asked' THEN 'agent'
24+ WHEN 'checks_failed' THEN 'ci_activity'
25+ WHEN 'workflow_failed' THEN 'ci_activity'
26+ WHEN 'mentioned' THEN 'mention'
27+ WHEN 'merged' THEN 'state_change'
28+ ELSE 'author'
29+END;
30+UPDATE inbox_items SET
31+ thread = CASE
32+ WHEN number IS NOT NULL THEN repo_id || '#' || number
33+ WHEN run_id IS NOT NULL THEN repo_id || '/run/' || run_id
34+ ELSE 'event/' || event_id
35+ END,
36+ updated_at = created_at,
37+ bumped = id;
38+
39+-- What happened on each thread, newest first, at most 10 kept. One row per
40+-- event per person, so a redelivered event is told once.
41+CREATE TABLE inbox_activity (
42+ id TEXT PRIMARY KEY,
43+ item_id TEXT NOT NULL,
44+ username TEXT NOT NULL,
45+ event_id TEXT NOT NULL,
46+ event TEXT,
47+ reason TEXT NOT NULL,
48+ severity TEXT NOT NULL,
49+ title TEXT NOT NULL,
50+ body TEXT NOT NULL,
51+ actor TEXT,
52+ created_at TEXT NOT NULL,
53+ UNIQUE (event_id, username)
54+);
55+CREATE INDEX inbox_activity_item ON inbox_activity (item_id, id);
56+
57+-- Items about the same thing become one: every item is a line of its
58+-- history, and the newest stays: unread with the most urgent severity of
59+-- those unread if any was, and saved if any was.
60+INSERT INTO inbox_activity (id, item_id, username, event_id, reason, severity, title, body, actor, created_at)
61+SELECT i.id,
62+ (SELECT max(j.id) FROM inbox_items j WHERE j.username = i.username AND j.thread = i.thread),
63+ i.username, i.event_id, i.reason, i.severity, i.title, i.body, i.actor, i.created_at
64+FROM inbox_items i;
65+UPDATE inbox_items SET severity = COALESCE((
66+ SELECT j.severity FROM inbox_items j
67+ WHERE j.username = inbox_items.username AND j.thread = inbox_items.thread AND j.read_at IS NULL AND j.done_at IS NULL
68+ ORDER BY CASE j.severity WHEN 'warning' THEN 0 WHEN 'error' THEN 1 WHEN 'success' THEN 2 ELSE 3 END
69+ LIMIT 1), severity);
70+UPDATE inbox_items SET
71+ activity = (SELECT count(*) FROM inbox_items j WHERE j.username = inbox_items.username AND j.thread = inbox_items.thread),
72+ created_at = (SELECT min(j.created_at) FROM inbox_items j WHERE j.username = inbox_items.username AND j.thread = inbox_items.thread),
73+ saved = (SELECT max(j.saved) FROM inbox_items j WHERE j.username = inbox_items.username AND j.thread = inbox_items.thread),
74+ read_at = CASE WHEN EXISTS (
75+ SELECT 1 FROM inbox_items j
76+ WHERE j.username = inbox_items.username AND j.thread = inbox_items.thread AND j.read_at IS NULL AND j.done_at IS NULL
77+ ) THEN NULL ELSE read_at END,
78+ done_at = CASE WHEN EXISTS (
79+ SELECT 1 FROM inbox_items j
80+ WHERE j.username = inbox_items.username AND j.thread = inbox_items.thread AND j.done_at IS NULL
81+ ) THEN NULL ELSE done_at END;
82+DELETE FROM inbox_items
83+WHERE id NOT IN (SELECT max(id) FROM inbox_items GROUP BY username, thread);
84+
85+CREATE UNIQUE INDEX inbox_thread ON inbox_items (username, thread);
86+CREATE INDEX inbox_thread_of ON inbox_items (thread);
87+-- Lists go by latest activity.
88+DROP INDEX inbox_recent;
89+DROP INDEX inbox_saved;
90+CREATE INDEX inbox_recent ON inbox_items (username, bumped) WHERE done_at IS NULL;
91+CREATE INDEX inbox_saved ON inbox_items (username, bumped) WHERE saved = 1;
92+
93+-- Subscriptions to issues and pull requests, beyond being their author,
94+-- assignee or reviewer (which subscribes a person without a row): someone
95+-- who commented or was mentioned, subscribed by hand, unsubscribed, or
96+-- ignores the thread.
97+CREATE TABLE inbox_subscriptions (
98+ username TEXT NOT NULL,
99+ -- `<repo_id>#<number>`.
100+ thread TEXT NOT NULL,
101+ repo_id TEXT NOT NULL,
102+ -- subscribed | unsubscribed | ignored
103+ state TEXT NOT NULL,
104+ -- Why: comment, mention, assign, review_requested, author or manual.
105+ reason TEXT,
106+ created_at TEXT NOT NULL,
107+ -- When the person last chose (subscribed, unsubscribed or ignored by
108+ -- hand); null while it only follows what they did.
109+ chosen_at TEXT,
110+ PRIMARY KEY (thread, username)
111+);
112+CREATE INDEX inbox_subscriptions_repo ON inbox_subscriptions (repo_id);
113+
114+-- How people watch repositories. No row: participating.
115+CREATE TABLE inbox_watching (
116+ username TEXT NOT NULL,
117+ repo_id TEXT NOT NULL,
118+ -- `owner/name`, kept for listing what a person watches.
119+ repo TEXT,
120+ -- participating | all | ignore | custom
121+ level TEXT NOT NULL,
122+ -- With custom: a JSON array of issues, pulls, deployments, security.
123+ events TEXT NOT NULL DEFAULT '[]',
124+ updated_at TEXT NOT NULL,
125+ PRIMARY KEY (repo_id, username)
126+);
127+CREATE INDEX inbox_watching_user ON inbox_watching (username);
128+
129+-- What each person chose: the reasons they are emailed for (a JSON array),
130+-- and how they watch repositories they create. No row: the defaults.
131+CREATE TABLE inbox_settings (
132+ username TEXT PRIMARY KEY,
133+ email TEXT,
134+ default_watch TEXT,
135+ updated_at TEXT NOT NULL
136+);
+1119−232
11 //! The inbox, kept beside the event log. See `g1t_contracts::inbox`.
22 //!
3−//! As each batch arrives from the bus, the events that need a person are
4−//! read against what they name (the work service's `inbox_subject`) and one
5−//! item is written for each person told. Who is told is worked out in
6−//! [`notices`], from the event and its subject alone:
3+//! As each batch arrives from the bus, the events that tell someone are
4+//! read against what they name (the work service's `inbox_subject`), who
5+//! subscribes to it and who watches its repository (subscriptions.rs), and
6+//! each person told gets their thread about it brought to the top, unread,
7+//! with a line added to its history. Who is told is worked out in
8+//! [`notices`], from the event, its subject and that audience alone:
79 //!
8−//! | Event | Who | Severity |
9−//! | --- | --- | --- |
10−//! | `agent.asked` | the pull request's owner, and its issue's owner and assignees | warning |
11−//! | `checks.completed`, failed or errored | the pull request's owner | error |
12−//! | `workflow.completed`, failed | the pull request's owner, or whoever pushed | error |
13−//! | `review.completed` by g1t | the pull request's owner | success, or info for changes asked |
14−//! | `pull.ready` for a change g1t made | whoever asked g1t for it | success |
15−//! | `pull.merged` | the pull request's owner | success |
16−//! | `comment.created` | everyone mentioned, then the owner | info (success for an approval) |
10+//! | Event | Who | Reason | Severity |
11+//! | --- | --- | --- | --- |
12+//! | `agent.asked`, `pull.stalled` | the pull request's owner, and its issue's owner and assignees | agent | warning |
13+//! | `pull.review_requested` | the reviewers asked | review_requested | warning |
14+//! | `issue.assigned`, `pull.assigned` | the people newly assigned | assign | info |
15+//! | `checks.completed`, failed or errored | the pull request's owner | ci_activity | error |
16+//! | `workflow.completed`, failed | the pull request's owner, or whoever pushed | ci_activity | error |
17+//! | `deployment.failed` | the pull request's owner, or whoever pushed; watchers | ci_activity | error |
18+//! | `deployment.succeeded` after a failure | the same | ci_activity | success |
19+//! | `review.completed` by g1t | the pull request's owner | author | success, or info for changes asked |
20+//! | `pull.ready` for a change g1t made | whoever asked g1t for it | author | success |
21+//! | `pull.merged`, `pull.closed`, `issue.closed`, `issue.reopened` | everyone subscribed | state_change | success for a merge, else info |
22+//! | `comment.created` | everyone mentioned, then everyone subscribed | mention, or why they are subscribed | info (success for an approval) |
23+//! | `issue.opened`, `pull.opened` | whoever was assigned or asked to review; watchers | assign, review_requested, subscribed | info |
1724 //!
18−//! Nobody is told of what they did themselves, and g1t is never told. A
19−//! failure here is logged and the batch goes on: the bus never waits on
20−//! the inbox, so an item can be missed, but nothing else is held up.
25+//! Watchers of a repository at `all` (or `custom`, for the kinds they
26+//! chose) hear of every issue and pull request opened, commented on,
27+//! closed, reopened or merged, and of deployments. Anyone who ignores the
28+//! thread or the repository hears of nothing on it; anyone who
29+//! unsubscribed hears only of what is asked of them.
30+//!
31+//! Nobody is told of what they did themselves, though an outcome they set
32+//! off (checks, a workflow, a deployment, g1t's work) is theirs to hear of.
33+//! g1t is never told. A failure here is logged and the batch goes on: the
34+//! bus never waits on the inbox, so an item can be missed, but nothing else
35+//! is held up.
2136
2237 use std::collections::{HashMap, HashSet};
2338
3146 use g1t_kit::now_ms;
3247 use serde::Deserialize;
3348 use worker::wasm_bindgen::JsValue;
34−use worker::{D1Database, Fetcher, Result};
49+use worker::{D1Database, D1PreparedStatement, Fetcher, Result};
50+
51+use crate::subscriptions::{self, Audience};
3552
3653 /// Items marked done are kept this long, then removed.
3754 pub const DONE_DAYS: u32 = 30;
5572 #[derive(Debug, PartialEq, Eq)]
5673 pub struct Notice {
5774 pub username: String,
58− pub reason: &'static str,
75+ pub reason: Reason,
5976 pub severity: Severity,
6077 pub title: String,
6178 pub body: String,
7693 fn is_named(&self, username: &str) -> bool {
7794 self.username.as_deref().is_some_and(|name| name.eq_ignore_ascii_case(username))
7895 }
96+
97+ /// A person's name, for a title: never g1t's ids.
98+ fn name(&self) -> Option<&str> {
99+ self.username.as_deref().filter(|name| !is_g1t(name))
100+ }
79101 }
80102
81−fn is_g1t(username: &str) -> bool {
103+pub(crate) fn is_g1t(username: &str) -> bool {
82104 username.eq_ignore_ascii_case(system::USERNAME) || username.eq_ignore_ascii_case("g1t-agent")
83105 }
84106
85−fn is_g1t_id(id: &str) -> bool {
107+pub(crate) fn is_g1t_id(id: &str) -> bool {
86108 system::is_system_id(id) || id == AGENT_ID
87109 }
88110
111+/// Events that end what an agent was waiting on a person for: it picked
112+/// back up, its head moved, a merge was asked for, or it is over.
113+const RESUMES: [&str; 5] = ["pull.resumed", "pull.updated", "pull.merge_requested", "pull.merged", "pull.closed"];
114+
89115 /// The issue or pull request an event names, and what to read for it.
90116 /// None for events the inbox does not tell anyone of.
91117 pub fn wants(event: &Event) -> Option<Wanted> {
101127 })
102128 };
103129 match event.kind.as_str() {
104− "agent.asked" | "pull.merged" | "pull.ready" => on(Some(number("number")?), None),
130+ "agent.asked" | "pull.stalled" | "pull.merged" | "pull.closed" | "pull.ready" | "pull.opened"
131+ | "pull.review_requested" | "pull.assigned" | "issue.opened" | "issue.closed" | "issue.reopened"
132+ | "issue.assigned" => on(Some(number("number")?), None),
105133 "checks.completed" => match data["status"].as_str() {
106134 Some("failed" | "errored") => on(Some(number("number")?), None),
107135 _ => None,
114142 Some("failure") => on(number("pull"), None),
115143 _ => None,
116144 },
145+ "deployment.failed" | "deployment.succeeded" => on(number("number"), None),
117146 "comment.created" => on(Some(number("number")?), Some(text("commentId")?)),
118147 _ => None,
119148 }
120149 }
121150
122−/// Collects who is told, each once, never the actor and never g1t.
151+/// Where an event's items go: which thread, what it is, and where it is.
152+#[derive(Clone, Debug, PartialEq, Eq)]
153+pub struct Thread {
154+ pub key: String,
155+ pub kind: Option<SubjectKind>,
156+ pub number: Option<u32>,
157+ pub run_id: Option<String>,
158+ /// A path, for a subject with a page of its own.
159+ pub link: Option<String>,
160+}
161+
162+/// The thread key of an issue or pull request.
163+pub fn numbered_thread(repo_id: &str, number: u32) -> String {
164+ format!("{repo_id}#{number}")
165+}
166+
167+/// The thread an event's items go to.
168+pub fn thread_of(event: &Event, wanted: &Wanted, subject: Option<&InboxSubject>) -> Thread {
169+ let data = &event.data;
170+ let text = |key: &str| data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
171+ match event.kind.as_str() {
172+ // A project's production, or one pull request's preview.
173+ "deployment.failed" | "deployment.succeeded" => {
174+ let which = wanted.number.map_or_else(|| "production".to_owned(), |number| number.to_string());
175+ Thread {
176+ key: format!("{}/deploy/{}/{which}", wanted.repo_id, text("projectId").unwrap_or_default()),
177+ kind: Some(SubjectKind::Deploy),
178+ number: wanted.number,
179+ run_id: text("deploymentId"),
180+ link: text("path"),
181+ }
182+ }
183+ // A workflow on a branch: its next failure bumps the same thread.
184+ "workflow.completed" if subject.is_none() => {
185+ let branch = text("ref").unwrap_or_default();
186+ let branch = branch.strip_prefix("refs/heads/").unwrap_or(&branch).to_owned();
187+ let workflow = text("path").or_else(|| text("workflow")).unwrap_or_default();
188+ Thread {
189+ key: format!("{}/run/{workflow}@{branch}", wanted.repo_id),
190+ kind: Some(SubjectKind::Run),
191+ number: None,
192+ run_id: text("runId"),
193+ link: None,
194+ }
195+ }
196+ _ => match wanted.number {
197+ Some(number) => Thread {
198+ key: numbered_thread(&wanted.repo_id, number),
199+ kind: subject.and_then(|subject| subject.kind),
200+ number: Some(number),
201+ run_id: None,
202+ link: None,
203+ },
204+ None => Thread {
205+ key: format!("event/{}", event.id),
206+ kind: None,
207+ number: None,
208+ run_id: None,
209+ link: None,
210+ },
211+ },
212+ }
213+}
214+
215+/// The issue or pull request whose agent threads an event closes: what an
216+/// agent was waiting on a person for is over.
217+pub fn resolves(event: &Event) -> Option<String> {
218+ if !RESUMES.contains(&event.kind.as_str()) {
219+ return None;
220+ }
221+ let repo_id = event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone())?;
222+ let number = event.data["number"].as_u64().and_then(|n| u32::try_from(n).ok())?;
223+ Some(numbered_thread(&repo_id, number))
224+}
225+
226+/// Collects who is told, each once with the most specific reason, never
227+/// the actor, never g1t, and never anyone ignoring the thread.
123228 struct Told<'a> {
124229 actor: &'a Actor,
230+ audience: &'a Audience,
125231 notices: Vec<Notice>,
126232 }
127233
128234 impl Told<'_> {
129− fn tell(&mut self, username: &str, reason: &'static str, severity: Severity, title: &str, body: &str) {
235+ /// `asked`: something asked of the person directly, told even when
236+ /// they unsubscribed from the thread.
237+ fn tell(&mut self, username: &str, reason: Reason, severity: Severity, title: &str, body: &str, asked: bool) {
130238 let username = username.trim().trim_start_matches('@').to_lowercase();
131239 if username.is_empty()
132240 || is_g1t(&username)
133241 || self.actor.is_named(&username)
134− || self.notices.iter().any(|told| told.username == username)
242+ || self.audience.ignores(&username)
243+ || (!asked && self.audience.unsubscribed(&username))
135244 {
136245 return;
137246 }
138− self.notices.push(Notice {
247+ let notice = Notice {
139248 username,
140249 reason,
141250 severity,
142251 title: clip(title, MAX_TITLE),
143252 body: clip(body, MAX_BODY),
144− });
253+ };
254+ match self.notices.iter_mut().find(|told| told.username == notice.username) {
255+ Some(told) if notice.reason.rank() < told.reason.rank() => *told = notice,
256+ Some(_) => {}
257+ None => self.notices.push(notice),
258+ }
145259 }
146260
147261 /// A person known by id as well as name, such as an author.
148− fn tell_person(&mut self, person: &Principal, reason: &'static str, severity: Severity, title: &str, body: &str) {
262+ fn tell_person(&mut self, person: &Principal, reason: Reason, severity: Severity, title: &str, body: &str, asked: bool) {
149263 if is_g1t_id(&person.id) || self.actor.is(person) {
150264 return;
151265 }
152− self.tell(&person.username, reason, severity, title, body);
266+ self.tell(&person.username, reason, severity, title, body, asked);
267+ }
268+
269+ /// Everyone subscribed to an issue or pull request: its owner and
270+ /// author, its assignees and reviewers, and whoever subscribed by
271+ /// commenting, being mentioned or by hand. `reason` overrides why
272+ /// each is told, as a state change does.
273+ fn tell_subscribed(&mut self, subject: &InboxSubject, reason: Option<Reason>, severity: Severity, title: &str, body: &str) {
274+ let why = |own: Reason| reason.unwrap_or(own);
275+ self.tell_person(subject.owner(), why(Reason::Author), severity, title, body, false);
276+ self.tell_person(&subject.author, why(Reason::Author), severity, title, body, false);
277+ for name in &subject.assignees {
278+ self.tell(name, why(Reason::Assign), severity, title, body, false);
279+ }
280+ for name in &subject.reviewers {
281+ self.tell(name, why(Reason::ReviewRequested), severity, title, body, false);
282+ }
283+ let subscribed: Vec<(String, Reason)> = self.audience.subscribed().collect();
284+ for (name, own) in subscribed {
285+ self.tell(&name, why(own), severity, title, body, false);
286+ }
153287 }
288+
289+ /// Everyone watching the repository for this kind of activity.
290+ fn tell_watchers(&mut self, kind: &str, severity: Severity, title: &str, body: &str) {
291+ let watching: Vec<String> = self.audience.watching(kind).collect();
292+ for name in watching {
293+ self.tell(&name, Reason::Subscribed, severity, title, body, false);
294+ }
295+ }
154296 }
155297
156298 fn clip(text: &str, max: usize) -> String {
162304 format!("{}…", cut.trim_end())
163305 }
164306
165−/// Who is told of `event`, in `repo` (`owner/name`), given what it names.
166−/// `actor` is who caused it; outcomes nobody chose (checks, workflows, a
167−/// review, an agent finishing) are told whoever caused them.
168−pub fn notices(event: &Event, repo: &str, actor: &Actor, subject: Option<&InboxSubject>) -> Vec<Notice> {
307+/// The kind of activity a watcher chooses: `issues` or `pulls`.
308+fn activity_of(subject: &InboxSubject) -> &'static str {
309+ match subject.kind {
310+ Some(SubjectKind::Pull) => "pulls",
311+ _ => "issues",
312+ }
313+}
314+
315+/// The names in an event's list field, such as the reviewers just asked.
316+fn names(data: &serde_json::Value, key: &str) -> Vec<String> {
317+ data[key]
318+ .as_array()
319+ .map(|names| names.iter().filter_map(|name| name.as_str().map(str::to_owned)).collect())
320+ .unwrap_or_default()
321+}
322+
323+/// Who is told of `event`, in `repo` (`owner/name`), given what it names
324+/// and who follows it. `actor` is who caused it; outcomes nobody chose
325+/// (checks, workflows, deployments, a review, an agent finishing or
326+/// stopping) are told whoever caused them.
327+pub fn notices(event: &Event, repo: &str, actor: &Actor, subject: Option<&InboxSubject>, audience: &Audience) -> Vec<Notice> {
169328 let nobody = Actor::default();
170329 let data = &event.data;
171330 // The issue or pull request, as titles name it: `acme/rocket#12`.
175334 };
176335 let outcome = matches!(
177336 event.kind.as_str(),
178− "checks.completed" | "workflow.completed" | "review.completed" | "pull.ready" | "agent.asked"
337+ "checks.completed"
338+ | "workflow.completed"
339+ | "review.completed"
340+ | "pull.ready"
341+ | "agent.asked"
342+ | "pull.stalled"
343+ | "deployment.failed"
344+ | "deployment.succeeded"
179345 );
180346 let mut told = Told {
181347 actor: if outcome { &nobody } else { actor },
348+ audience,
182349 notices: Vec::new(),
183350 };
351+ // Who did it, as titles name them.
352+ let who = actor.name().unwrap_or("g1t");
184353
185354 match (event.kind.as_str(), subject) {
186− ("agent.asked", Some(pull)) => {
187− let title = format!("An agent is waiting on {}", at);
188− told.tell_person(pull.owner(), "agent_asked", Severity::Warning, &title, &pull.title);
355+ ("agent.asked" | "pull.stalled", Some(pull)) => {
356+ let (title, body) = if event.kind == "agent.asked" {
357+ (format!("An agent is waiting on {at}"), pull.title.clone())
358+ } else {
359+ let detail = data["detail"].as_str().map(str::trim).filter(|detail| !detail.is_empty());
360+ (format!("g1t stopped on {at} and needs you"), detail.unwrap_or(&pull.title).to_owned())
361+ };
362+ told.tell_person(pull.owner(), Reason::Agent, Severity::Warning, &title, &body, true);
189363 if let Some(issue) = &pull.issue {
190− told.tell_person(issue.owner(), "agent_asked", Severity::Warning, &title, &pull.title);
364+ told.tell_person(issue.owner(), Reason::Agent, Severity::Warning, &title, &body, true);
191365 for name in &issue.assignees {
192− told.tell(name, "agent_asked", Severity::Warning, &title, &pull.title);
366+ told.tell(name, Reason::Agent, Severity::Warning, &title, &body, true);
193367 }
194368 }
195369 }
370+ ("pull.review_requested", Some(pull)) => {
371+ let title = format!("{who} asked you to review {at}");
372+ for name in names(data, "reviewers") {
373+ told.tell(&name, Reason::ReviewRequested, Severity::Warning, &title, &pull.title, true);
374+ }
375+ }
376+ ("issue.assigned" | "pull.assigned", Some(on)) => {
377+ let title = format!("{who} assigned you to {at}");
378+ for name in names(data, "added") {
379+ told.tell(&name, Reason::Assign, Severity::Info, &title, &on.title, true);
380+ }
381+ }
382+ ("issue.opened" | "pull.opened", Some(on)) => {
383+ let title = format!("{who} opened {at}");
384+ for name in &on.assignees {
385+ told.tell(name, Reason::Assign, Severity::Info, &format!("{who} assigned you to {at}"), &on.title, true);
386+ }
387+ for name in &on.reviewers {
388+ told.tell(name, Reason::ReviewRequested, Severity::Warning, &format!("{who} asked you to review {at}"), &on.title, true);
389+ }
390+ told.tell_watchers(activity_of(on), Severity::Info, &title, &on.title);
391+ }
196392 ("checks.completed", Some(pull)) => {
197393 let title = match data["status"].as_str() {
198− Some("errored") => format!("Checks could not run on {}", at),
199− _ => format!("Checks failed on {}", at),
394+ Some("errored") => format!("Checks could not run on {at}"),
395+ _ => format!("Checks failed on {at}"),
200396 };
201− told.tell_person(pull.owner(), "checks_failed", Severity::Error, &title, &pull.title);
397+ told.tell_person(pull.owner(), Reason::CiActivity, Severity::Error, &title, &pull.title, true);
202398 }
203399 ("workflow.completed", subject) => {
204400 let workflow = data["workflow"].as_str().filter(|name| !name.is_empty()).unwrap_or("A workflow");
205401 match subject {
206402 Some(pull) => {
207− let title = format!("{workflow} failed on {}", at);
208− told.tell_person(pull.owner(), "workflow_failed", Severity::Error, &title, &pull.title);
403+ let title = format!("{workflow} failed on {at}");
404+ told.tell_person(pull.owner(), Reason::CiActivity, Severity::Error, &title, &pull.title, true);
209405 }
210406 None => {
211407 // Not on a pull request: whoever pushed the commit it ran on.
217413 && !is_g1t_id(id)
218414 && let Some(name) = &actor.username
219415 {
220− told.tell(name, "workflow_failed", Severity::Error, &title, &body);
416+ told.tell(name, Reason::CiActivity, Severity::Error, &title, &body, true);
417+ }
418+ }
419+ }
420+ }
421+ ("deployment.failed" | "deployment.succeeded", subject) => {
422+ let failed = event.kind == "deployment.failed";
423+ let project = data["project"].as_str().filter(|name| !name.is_empty()).unwrap_or(repo);
424+ let what = match subject {
425+ Some(_) => format!("The preview of {at}"),
426+ None => format!("Production of {project}"),
427+ };
428+ let (title, severity) = if failed {
429+ (format!("{what} failed to deploy"), Severity::Error)
430+ } else {
431+ (format!("{what} is live"), Severity::Success)
432+ };
433+ let body = match (failed, data["error"].as_str().map(str::trim).filter(|error| !error.is_empty())) {
434+ (true, Some(error)) => error.to_owned(),
435+ _ => match subject {
436+ Some(pull) => pull.title.clone(),
437+ None => format!("Commit {}", short(data["commit"].as_str().unwrap_or_default())),
438+ },
439+ };
440+ // A success is news to whoever answers for it only after a failure.
441+ if failed || data["recovered"].as_bool() == Some(true) {
442+ let title = if failed { title.clone() } else { format!("{what} is live again") };
443+ match subject {
444+ Some(pull) => told.tell_person(pull.owner(), Reason::CiActivity, severity, &title, &body, true),
445+ None => {
446+ let pusher = actor
447+ .id
448+ .as_deref()
449+ .filter(|id| !is_g1t_id(id))
450+ .and(actor.username.as_deref())
451+ .or_else(|| data["triggeredBy"].as_str());
452+ if let Some(name) = pusher {
453+ told.tell(name, Reason::CiActivity, severity, &title, &body, true);
454+ }
221455 }
222456 }
223457 }
458+ told.tell_watchers("deployments", severity, &title, &body);
224459 }
225460 ("review.completed", Some(pull)) => {
226− let (title, severity, reason) = match data["verdict"].as_str() {
227− Some("approve") => (format!("g1t approved {}", at), Severity::Success, "approved"),
228− _ => (format!("g1t asked for changes on {}", at), Severity::Info, "changes_requested"),
461+ let (title, severity) = match data["verdict"].as_str() {
462+ Some("approve") => (format!("g1t approved {at}"), Severity::Success),
463+ _ => (format!("g1t asked for changes on {at}"), Severity::Info),
229464 };
230− told.tell_person(pull.owner(), reason, severity, &title, &pull.title);
465+ told.tell_person(pull.owner(), Reason::Author, severity, &title, &pull.title, true);
231466 }
232467 ("pull.ready", Some(pull)) => {
233468 // A change g1t made is ready: the agent's run is over.
234469 if let Some(owner) = pull.requested_by.as_ref().filter(|_| is_g1t_id(&pull.author.id) || is_g1t(&pull.author.username)) {
235− let title = format!("g1t finished {}", at);
236− told.tell_person(owner, "agent_finished", Severity::Success, &title, &pull.title);
470+ let title = format!("g1t finished {at}");
471+ told.tell_person(owner, Reason::Author, Severity::Success, &title, &pull.title, true);
237472 }
238473 }
239− ("pull.merged", Some(pull)) => {
240− let title = match &actor.username {
241− Some(name) if !is_g1t(name) => format!("{name} merged {}", at),
242− _ => format!("{} was merged", at),
474+ ("pull.merged" | "pull.closed" | "issue.closed" | "issue.reopened", Some(on)) => {
475+ let (verb, severity) = match event.kind.as_str() {
476+ "pull.merged" => ("merged", Severity::Success),
477+ "issue.reopened" => ("reopened", Severity::Info),
478+ _ => ("closed", Severity::Info),
479+ };
480+ let title = match (actor.name(), data["resolvedBy"].as_u64()) {
481+ (_, Some(pull)) if event.kind == "issue.closed" => format!("{at} was closed by #{pull}"),
482+ (Some(name), _) => format!("{name} {verb} {at}"),
483+ (None, _) => format!("{at} was {verb}"),
243484 };
244− told.tell_person(pull.owner(), "merged", Severity::Success, &title, &pull.title);
485+ told.tell_subscribed(on, Some(Reason::StateChange), severity, &title, &on.title);
486+ told.tell_watchers(activity_of(on), severity, &title, &on.title);
245487 }
246488 ("comment.created", Some(on)) => {
247489 let Some(comment) = on.comment.as_ref().filter(|comment| !comment.event) else {
256498 let who = &comment.author.username;
257499 let body = if comment.excerpt.is_empty() { &on.title } else { &comment.excerpt };
258500 for name in &comment.mentions {
259− let title = format!("{who} mentioned you on {}", at);
260− told.tell(name, "mentioned", Severity::Info, &title, body);
501+ let title = format!("{who} mentioned you on {at}");
502+ told.tell(name, Reason::Mention, Severity::Info, &title, body, true);
261503 }
262− let (reason, severity, title) = match comment.verdict.as_deref() {
263− Some("approve") => ("approved", Severity::Success, format!("{who} approved {}", at)),
264− Some("request_changes") => ("changes_requested", Severity::Info, format!("{who} asked for changes on {}", at)),
265− _ => ("commented", Severity::Info, format!("{who} commented on {}", at)),
504+ let (severity, title) = match comment.verdict.as_deref() {
505+ Some("approve") => (Severity::Success, format!("{who} approved {at}")),
506+ Some("request_changes") => (Severity::Info, format!("{who} asked for changes on {at}")),
507+ _ => (Severity::Info, format!("{who} commented on {at}")),
266508 };
267− told.tell_person(on.owner(), reason, severity, &title, body);
509+ // An approval or a request for changes is the owner's to hear
510+ // of whatever they chose; the rest, as they subscribed.
511+ if comment.verdict.is_some() {
512+ told.tell_person(on.owner(), Reason::Author, severity, &title, body, true);
513+ }
514+ told.tell_subscribed(on, None, severity, &title, body);
515+ told.tell_watchers(activity_of(on), severity, &title, body);
268516 return told.notices;
269517 }
270518 _ => {}
272520 told.notices
273521 }
274522
523+/// Who an event subscribes to its issue or pull request without asking,
524+/// and why: whoever commented, whoever they mentioned, the people assigned
525+/// and the reviewers asked. Never g1t.
526+pub fn subscribes(event: &Event, subject: Option<&InboxSubject>) -> Vec<(String, Reason)> {
527+ let mut people: Vec<(String, Reason)> = Vec::new();
528+ let mut add = |name: &str, reason: Reason| {
529+ let name = name.trim().trim_start_matches('@').to_lowercase();
530+ if !name.is_empty() && !is_g1t(&name) && !people.iter().any(|(had, _)| *had == name) {
531+ people.push((name, reason));
532+ }
533+ };
534+ match event.kind.as_str() {
535+ "comment.created" => {
536+ if let Some(comment) = subject.and_then(|subject| subject.comment.as_ref()).filter(|comment| !comment.event) {
537+ if !is_g1t_id(&comment.author.id) {
538+ add(&comment.author.username, Reason::Comment);
539+ }
540+ for name in &comment.mentions {
541+ add(name, Reason::Mention);
542+ }
543+ }
544+ }
545+ "issue.assigned" | "pull.assigned" => {
546+ for name in names(&event.data, "added") {
547+ add(&name, Reason::Assign);
548+ }
549+ }
550+ "pull.review_requested" => {
551+ for name in names(&event.data, "reviewers") {
552+ add(&name, Reason::ReviewRequested);
553+ }
554+ }
555+ _ => {}
556+ }
557+ people
558+}
559+
275560 fn short(sha: &str) -> &str {
276561 sha.get(..7).unwrap_or(sha)
277562 }
278563
279564 /// Where an item is on g1t.sh.
280−pub fn url(repo: Option<&str>, subject: Option<SubjectKind>, number: Option<u32>, run_id: Option<&str>) -> String {
565+pub fn url(repo: Option<&str>, subject: Option<SubjectKind>, number: Option<u32>, run_id: Option<&str>, link: Option<&str>) -> String {
566+ if let Some(link) = link.filter(|link| link.starts_with('/')) {
567+ return link.to_owned();
568+ }
281569 let Some(repo) = repo else {
282570 return "/inbox".to_owned();
283571 };
285573 (Some(SubjectKind::Pull), Some(number), _) => format!("/{repo}/pull/{number}"),
286574 (Some(SubjectKind::Issue), Some(number), _) => format!("/{repo}/issues/{number}"),
287575 (Some(SubjectKind::Run), _, Some(run)) => format!("/{repo}/actions/runs/{run}"),
576+ (Some(SubjectKind::Deploy), Some(number), _) => format!("/{repo}/pull/{number}"),
288577 _ => format!("/{repo}"),
289578 }
290579 }
298587 pub identity: &'a Fetcher,
299588 }
300589
590+/// One notice written, and whether it was news (not a redelivery): what
591+/// may be emailed.
592+struct Written {
593+ notice: Notice,
594+ repo_id: String,
595+ url: String,
596+}
597+
301598 /// Writes the items a batch from the bus calls for. Never fails the batch:
302599 /// what cannot be worked out is logged and left.
303600 pub async fn deliver(db: &D1Database, sources: &Sources<'_>, events: &[Event]) {
601+ if let Err(error) = resolve(db, events).await {
602+ worker::console_error!("inbox: agent threads not closed: {error}");
603+ }
304604 let wanted: Vec<(&Event, Wanted)> = events
305605 .iter()
306606 .filter_map(|event| wants(event).map(|wanted| (event, wanted)))
307607 .collect();
308− if wanted.is_empty() {
608+ let created: Vec<&Event> = events.iter().filter(|event| event.kind == "repo.created").collect();
609+ if wanted.is_empty() && created.is_empty() {
309610 return;
310611 }
311612 // Everyone who caused one, named in one call.
312613 let ids: Vec<String> = wanted
313614 .iter()
314− .filter_map(|(event, _)| event.actor.clone())
615+ .map(|(event, _)| *event)
616+ .chain(created.iter().copied())
617+ .filter_map(|event| event.actor.clone())
315618 .filter(|id| !is_g1t_id(id))
316619 .collect::<HashSet<_>>()
317620 .into_iter()
326629 HashMap::new()
327630 })
328631 };
632+ for event in created {
633+ if let Err(error) = subscriptions::watch_created(db, event, &names).await {
634+ worker::console_error!("inbox: {} {} not watched: {error}", event.kind, event.id);
635+ }
636+ }
329637 let mut paths: HashMap<String, Option<RepoPath>> = HashMap::new();
638+ let mut watchers: HashMap<String, Vec<subscriptions::Watcher>> = HashMap::new();
639+ let mut written: Vec<Written> = Vec::new();
330640 for (event, wanted) in wanted {
331− if let Err(error) = deliver_one(db, sources, &names, &mut paths, event, wanted).await {
332− worker::console_error!("inbox: {} {} not delivered: {error}", event.kind, event.id);
641+ match deliver_one(db, sources, &names, &mut paths, &mut watchers, event, wanted).await {
642+ Ok(mut news) => written.append(&mut news),
643+ Err(error) => worker::console_error!("inbox: {} {} not delivered: {error}", event.kind, event.id),
644+ }
645+ }
646+ if let Err(error) = email(db, sources.identity, written).await {
647+ worker::console_error!("inbox: emails not sent: {error}");
648+ }
649+}
650+
651+/// Closes what an agent was waiting on a person for once it is over.
652+async fn resolve(db: &D1Database, events: &[Event]) -> Result<()> {
653+ let now = rfc3339(now_ms());
654+ let mut statements = Vec::new();
655+ for thread in events.iter().filter_map(resolves) {
656+ statements.push(
657+ db.prepare(
658+ "UPDATE inbox_items SET done_at = ?1, read_at = COALESCE(read_at, ?1)
659+ WHERE thread = ?2 AND reason = 'agent' AND done_at IS NULL",
660+ )
661+ .bind(&[now.as_str().into(), thread.into()])?,
662+ );
663+ }
664+ // Reviews no longer asked for are no longer waiting.
665+ for event in events.iter().filter(|event| event.kind == "pull.review_request_removed") {
666+ let (Some(repo_id), Some(number)) = (
667+ event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone()),
668+ event.data["number"].as_u64(),
669+ ) else {
670+ continue;
671+ };
672+ for name in names(&event.data, "reviewers") {
673+ statements.push(
674+ db.prepare(
675+ "UPDATE inbox_items SET done_at = ?1, read_at = COALESCE(read_at, ?1)
676+ WHERE thread = ?2 AND username = ?3 AND reason = 'review_requested' AND done_at IS NULL",
677+ )
678+ .bind(&[
679+ now.as_str().into(),
680+ format!("{repo_id}#{number}").into(),
681+ name.to_lowercase().into(),
682+ ])?,
683+ );
333684 }
334685 }
686+ if !statements.is_empty() {
687+ db.batch(statements).await?;
688+ }
689+ Ok(())
335690 }
336691
337692 async fn deliver_one(
339694 sources: &Sources<'_>,
340695 names: &HashMap<String, String>,
341696 paths: &mut HashMap<String, Option<RepoPath>>,
697+ watchers: &mut HashMap<String, Vec<subscriptions::Watcher>>,
342698 event: &Event,
343699 wanted: Wanted,
344−) -> Result<()> {
700+) -> Result<Vec<Written>> {
345701 if !paths.contains_key(&wanted.repo_id) {
346702 let path: Option<RepoPath> = g1t_kit::call(
347703 sources.repos,
355711 }
356712 // A repository that is gone tells nobody.
357713 let Some(path) = paths.get(&wanted.repo_id).cloned().flatten() else {
358− return Ok(());
714+ return Ok(Vec::new());
359715 };
360716 let subject: Option<InboxSubject> = match wanted.number {
361717 Some(number) => {
370726 )
371727 .await?;
372728 if found.is_none() {
373− return Ok(());
729+ return Ok(Vec::new());
374730 }
375731 found
376732 }
384740 None => None,
385741 },
386742 };
743+ let thread = thread_of(event, &wanted, subject.as_ref());
744+ if !watchers.contains_key(&wanted.repo_id) {
745+ watchers.insert(wanted.repo_id.clone(), subscriptions::watchers(db, &wanted.repo_id).await?);
746+ }
747+ let audience = Audience {
748+ subscriptions: match thread.kind {
749+ Some(SubjectKind::Issue | SubjectKind::Pull) => subscriptions::of_thread(db, &thread.key).await?,
750+ _ => Vec::new(),
751+ },
752+ watchers: watchers.get(&wanted.repo_id).cloned().unwrap_or_default(),
753+ };
387754 let repo = format!("{}/{}", path.namespace, path.name).to_lowercase();
388− let told = notices(event, &repo, &actor, subject.as_ref());
755+ let told = notices(event, &repo, &actor, subject.as_ref(), &audience);
756+ let mut statements = subscriptions::auto_subscribe(db, &thread.key, &wanted.repo_id, &subscribes(event, subject.as_ref()), &event.time)?;
389757 if told.is_empty() {
390− return Ok(());
758+ if !statements.is_empty() {
759+ db.batch(statements).await?;
760+ }
761+ return Ok(Vec::new());
391762 }
392− let (kind, number, run_id) = match (&subject, event.kind.as_str()) {
393− (Some(subject), _) => (subject.kind, wanted.number, None),
394− (None, "workflow.completed") => (Some(SubjectKind::Run), None, event.data["runId"].as_str()),
395− (None, _) => (None, None, None),
396− };
397763 let now = now_ms();
398− let mut statements = Vec::with_capacity(told.len());
399− for notice in told {
764+ let link = thread.link.as_deref();
765+ let item_url = url(Some(&repo), thread.kind, thread.number, thread.run_id.as_deref(), link);
766+ // Three statements a notice: its thread brought up (unless this event
767+ // was told before), a line of history, and the history kept short.
768+ let first = statements.len();
769+ for notice in &told {
770+ let username = notice.username.as_str();
771+ let values: Vec<JsValue> = vec![
772+ new_id("ntf", now).into(),
773+ username.into(),
774+ thread.key.as_str().into(),
775+ event.id.as_str().into(),
776+ event.kind.as_str().into(),
777+ notice.reason.as_str().into(),
778+ notice.severity.as_str().into(),
779+ notice.title.as_str().into(),
780+ notice.body.as_str().into(),
781+ path.namespace.to_lowercase().into(),
782+ wanted.repo_id.as_str().into(),
783+ repo.as_str().into(),
784+ thread.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()),
785+ thread.number.map_or(JsValue::NULL, JsValue::from),
786+ thread.run_id.as_deref().map_or(JsValue::NULL, JsValue::from),
787+ link.map_or(JsValue::NULL, JsValue::from),
788+ actor.username.as_deref().map_or(JsValue::NULL, JsValue::from),
789+ event.time.as_str().into(),
790+ // Same prefix as item ids, so old and new sort by time together.
791+ new_id("ntf", now).into(),
792+ ];
793+ statements.push(db.prepare(BUMP).bind(&values)?);
400794 statements.push(
401795 db.prepare(
402− // A redelivered event finds its rows there already.
403− "INSERT OR IGNORE INTO inbox_items (id, username, event_id, reason, severity, title, body,
404− workspace, repo_id, repo, subject, number, run_id, actor, created_at)
405− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
796+ "INSERT OR IGNORE INTO inbox_activity (id, item_id, username, event_id, event, reason, severity, title, body, actor, created_at)
797+ SELECT ?1, id, ?2, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 FROM inbox_items WHERE username = ?2 AND thread = ?3
798+ RETURNING username",
406799 )
407800 .bind(&[
408801 new_id("ntf", now).into(),
409− notice.username.as_str().into(),
802+ username.into(),
803+ thread.key.as_str().into(),
410804 event.id.as_str().into(),
411− notice.reason.into(),
805+ event.kind.as_str().into(),
806+ notice.reason.as_str().into(),
412807 notice.severity.as_str().into(),
413808 notice.title.as_str().into(),
414809 notice.body.as_str().into(),
415− path.namespace.to_lowercase().into(),
416− wanted.repo_id.as_str().into(),
417− repo.as_str().into(),
418− kind.map_or(JsValue::NULL, |kind| kind.as_str().into()),
419− number.map_or(JsValue::NULL, JsValue::from),
420− run_id.map_or(JsValue::NULL, JsValue::from),
421810 actor.username.as_deref().map_or(JsValue::NULL, JsValue::from),
422811 event.time.as_str().into(),
423812 ])?,
424813 );
814+ statements.push(
815+ db.prepare(
816+ "DELETE FROM inbox_activity
817+ WHERE item_id = (SELECT id FROM inbox_items WHERE username = ?1 AND thread = ?2)
818+ AND id NOT IN (
819+ SELECT a.id FROM inbox_activity a
820+ WHERE a.item_id = (SELECT id FROM inbox_items WHERE username = ?1 AND thread = ?2)
821+ ORDER BY a.id DESC LIMIT ?3)",
822+ )
823+ .bind(&[username.into(), thread.key.as_str().into(), MAX_ACTIVITY.into()])?,
824+ );
825+ }
826+ let results = db.batch(statements).await?;
827+ #[derive(Deserialize)]
828+ struct Told {
829+ username: String,
425830 }
426− db.batch(statements).await?;
831+ // A line of history written means the event is news to that person.
832+ let mut news = HashSet::new();
833+ for (at, _) in told.iter().enumerate() {
834+ if let Some(result) = results.get(first + at * 3 + 1)
835+ && let Ok(rows) = result.results::<Told>()
836+ {
837+ news.extend(rows.into_iter().map(|row| row.username));
838+ }
839+ }
840+ Ok(told
841+ .into_iter()
842+ .filter(|notice| news.contains(&notice.username))
843+ .map(|notice| Written {
844+ notice,
845+ repo_id: wanted.repo_id.clone(),
846+ url: item_url.clone(),
847+ })
848+ .collect())
849+}
850+
851+/// Brings a person's thread up with new activity, or starts it. `?1` id,
852+/// `?2` username, `?3` thread, `?4` event id, `?5` event type, `?6` reason,
853+/// `?7` severity, `?8` title, `?9` body, `?10` workspace, `?11` repo id,
854+/// `?12` repo, `?13` subject, `?14` number, `?15` run id, `?16` link, `?17`
855+/// actor, `?18` time, `?19` the time-sortable id lists order by. Nothing
856+/// happens when the person was already told of this event. While the
857+/// thread is unread its most urgent severity is kept; done or not, it
858+/// comes back to the inbox. A snooze stands.
859+const BUMP: &str = "INSERT INTO inbox_items (id, username, thread, event_id, event, reason, severity, title, body,
860+ workspace, repo_id, repo, subject, number, run_id, link, actor, created_at, updated_at, bumped, activity)
861+ SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?18, ?19, 1
862+ WHERE NOT EXISTS (SELECT 1 FROM inbox_activity WHERE event_id = ?4 AND username = ?2)
863+ ON CONFLICT (username, thread) DO UPDATE SET
864+ event_id = excluded.event_id,
865+ event = excluded.event,
866+ reason = excluded.reason,
867+ severity = CASE
868+ WHEN inbox_items.read_at IS NULL AND inbox_items.done_at IS NULL
869+ AND (CASE inbox_items.severity WHEN 'warning' THEN 0 WHEN 'error' THEN 1 WHEN 'success' THEN 2 ELSE 3 END)
870+ < (CASE excluded.severity WHEN 'warning' THEN 0 WHEN 'error' THEN 1 WHEN 'success' THEN 2 ELSE 3 END)
871+ THEN inbox_items.severity ELSE excluded.severity END,
872+ title = excluded.title,
873+ body = excluded.body,
874+ workspace = excluded.workspace,
875+ repo = excluded.repo,
876+ subject = COALESCE(excluded.subject, inbox_items.subject),
877+ run_id = COALESCE(excluded.run_id, inbox_items.run_id),
878+ link = COALESCE(excluded.link, inbox_items.link),
879+ actor = excluded.actor,
880+ updated_at = excluded.updated_at,
881+ bumped = excluded.bumped,
882+ activity = inbox_items.activity + 1,
883+ read_at = NULL,
884+ done_at = NULL";
885+
886+/// Emails what was news to people who asked to be emailed for its reason.
887+/// Identity sends each, only to a confirmed address and only if the person
888+/// can still read the repository.
889+async fn email(db: &D1Database, identity: &Fetcher, written: Vec<Written>) -> Result<()> {
890+ if written.is_empty() {
891+ return Ok(());
892+ }
893+ let people: Vec<String> = written
894+ .iter()
895+ .map(|item| item.notice.username.clone())
896+ .collect::<HashSet<_>>()
897+ .into_iter()
898+ .collect();
899+ let settings = subscriptions::settings_of(db, &people).await?;
900+ for item in written {
901+ let wants = settings.get(&item.notice.username).map_or(&DEFAULT_EMAIL[..], |settings| &settings.email[..]);
902+ if !wants.contains(&item.notice.reason) {
903+ continue;
904+ }
905+ let sent: Result<bool> = g1t_kit::call(
906+ identity,
907+ "notify_by_email",
908+ &NotifyByEmailArgs {
909+ username: item.notice.username.clone(),
910+ repo_id: item.repo_id.clone(),
911+ subject: item.notice.title.clone(),
912+ intro: item.notice.body.clone(),
913+ quote: None,
914+ path: item.url.clone(),
915+ reason: item.notice.reason,
916+ },
917+ )
918+ .await;
919+ if let Err(error) = sent {
920+ worker::console_error!("inbox: email to {} not sent: {error}", item.notice.username);
921+ }
922+ }
427923 Ok(())
428924 }
429925
430926 // --- Reading and changing ----------------------------------------------------
431927
432928 #[derive(Deserialize)]
433−struct Row {
434− id: String,
929+pub(crate) struct Row {
930+ pub(crate) id: String,
435931 reason: String,
436932 severity: String,
437933 title: String,
438934 body: String,
935+ event: Option<String>,
439936 workspace: Option<String>,
440− repo_id: Option<String>,
937+ pub(crate) repo_id: Option<String>,
441938 repo: Option<String>,
442939 subject: Option<String>,
443940 number: Option<f64>,
444941 run_id: Option<String>,
942+ link: Option<String>,
445943 actor: Option<String>,
944+ activity: Option<f64>,
446945 created_at: String,
946+ updated_at: Option<String>,
447947 read_at: Option<String>,
448948 done_at: Option<String>,
449949 saved: f64,
450950 snoozed_until: Option<String>,
951+ bumped: Option<String>,
451952 }
452953
453954 impl Row {
454− fn into_item(self) -> InboxItem {
955+ pub(crate) fn into_item(self) -> InboxItem {
455956 let subject = self.subject.as_deref().and_then(SubjectKind::parse);
456957 let number = self.number.map(|n| n as u32);
457958 InboxItem {
458− url: url(self.repo.as_deref(), subject, number, self.run_id.as_deref()),
959+ url: url(self.repo.as_deref(), subject, number, self.run_id.as_deref(), self.link.as_deref()),
459960 id: self.id,
460− reason: self.reason,
961+ reason: Reason::parse(&self.reason).unwrap_or(Reason::Subscribed),
461962 severity: Severity::parse(&self.severity).unwrap_or(Severity::Info),
462963 title: self.title,
463964 body: self.body,
965+ event: self.event,
464966 repo: self.repo,
465967 workspace: self.workspace,
466968 subject,
467969 number,
468970 actor: self.actor,
971+ count: self.activity.map_or(1, |n| n as u32),
972+ updated_at: self.updated_at.unwrap_or_else(|| self.created_at.clone()),
469973 created_at: self.created_at,
470974 read_at: self.read_at,
471975 done_at: self.done_at,
475979 }
476980 }
477981
478−const COLUMNS: &str = "id, reason, severity, title, body, workspace, repo_id, repo, subject, number, run_id, actor,
479− created_at, read_at, done_at, saved, snoozed_until";
982+pub(crate) const COLUMNS: &str = "id, reason, severity, title, body, event, workspace, repo_id, repo, subject, number, run_id,
983+ link, actor, activity, created_at, updated_at, read_at, done_at, saved, snoozed_until, bumped";
480984
481985 /// The conditions that pick a view's items, after `username = ?1`; `?2` is now.
482986 fn view_filter(view: InboxView) -> &'static str {
489993
490994 /// Whether a list ranks unread warnings first: the inbox itself, unfiltered.
491995 fn ranked(a: &ListInboxArgs) -> bool {
492− a.view == InboxView::Inbox && a.severity.is_none() && !a.unread
996+ a.view == InboxView::Inbox
997+ && a.severity.is_none()
998+ && a.reason.is_none()
999+ && !a.participating
1000+ && a.repo_id.is_none()
1001+ && !a.unread
1002+ && a.since.is_none()
1003+ && a.updated_before.is_none()
4931004 }
4941005
1006+/// The conditions a list's filters add, and the values they bind, numbered
1007+/// after the `bound` values already given.
1008+fn filters(a: &ListInboxArgs, bound: usize) -> (Vec<String>, Vec<String>) {
1009+ let mut conditions = Vec::new();
1010+ let mut values: Vec<String> = Vec::new();
1011+ let mut bind = |value: &str, condition: &str| {
1012+ values.push(value.to_owned());
1013+ conditions.push(condition.replace('?', &format!("?{}", bound + values.len())));
1014+ };
1015+ if let Some(severity) = a.severity {
1016+ bind(severity.as_str(), "severity = ?");
1017+ }
1018+ if let Some(reason) = a.reason {
1019+ bind(reason.as_str(), "reason = ?");
1020+ }
1021+ if let Some(repo_id) = &a.repo_id {
1022+ bind(repo_id, "repo_id = ?");
1023+ }
1024+ if let Some(since) = &a.since {
1025+ bind(since.trim(), "COALESCE(updated_at, created_at) >= ?");
1026+ }
1027+ if let Some(before) = &a.updated_before {
1028+ bind(before.trim(), "COALESCE(updated_at, created_at) < ?");
1029+ }
1030+ if a.participating {
1031+ conditions.push("reason NOT IN ('manual', 'subscribed')".to_owned());
1032+ }
1033+ if a.unread {
1034+ conditions.push("read_at IS NULL".to_owned());
1035+ }
1036+ (conditions, values)
1037+}
1038+
4951039 pub async fn list(db: &D1Database, repos: &Fetcher, a: ListInboxArgs) -> Result<InboxPage> {
4961040 let Some(viewer) = &a.viewer else {
4971041 return Ok(InboxPage::default());
4991043 let username = viewer.username.to_lowercase();
5001044 let now = rfc3339(now_ms());
5011045 let limit = a.limit.unwrap_or(DEFAULT_INBOX_PAGE).clamp(1, MAX_INBOX_PAGE);
502− let mut conditions = vec!["username = ?1".to_owned(), view_filter(a.view).to_owned()];
5031046 let mut values: Vec<JsValue> = vec![username.as_str().into(), now.as_str().into()];
504− if let Some(severity) = a.severity {
505− values.push(severity.as_str().into());
506− conditions.push(format!("severity = ?{}", values.len()));
507− }
508− if a.unread {
509− conditions.push("read_at IS NULL".to_owned());
510− }
1047+ let mut conditions = vec!["username = ?1".to_owned(), view_filter(a.view).to_owned()];
1048+ let (filtered, bound) = filters(&a, values.len());
1049+ conditions.extend(filtered);
1050+ values.extend(bound.iter().map(|value| JsValue::from(value.as_str())));
5111051 let ranked = ranked(&a);
5121052 // Unread warnings lead the first page, and are left out of the rest.
5131053 let leading = "severity = 'warning' AND read_at IS NULL";
5141054 let mut rest = conditions.clone();
1055+ let mut rest_values = values.clone();
5151056 if ranked {
5161057 rest.push(format!("NOT ({leading})"));
5171058 }
5181059 if let Some(before) = &a.before {
519− values.push(before.as_str().into());
520− rest.push(format!("id < ?{}", values.len()));
1060+ rest_values.push(before.as_str().into());
1061+ rest.push(format!("bumped < ?{}", rest_values.len()));
5211062 }
522− values.push((limit + 1).into());
523− let order = if a.view == InboxView::Done { "done_at DESC, id DESC" } else { "id DESC" };
1063+ rest_values.push((limit + 1).into());
1064+ let order = if a.view == InboxView::Done { "done_at DESC, bumped DESC" } else { "bumped DESC" };
5241065 let mut statements = vec![
5251066 db.prepare(format!(
5261067 "SELECT {COLUMNS} FROM inbox_items WHERE {} ORDER BY {order} LIMIT ?{}",
5271068 rest.join(" AND "),
528− values.len()
1069+ rest_values.len()
5291070 ))
530− .bind(&values)?,
1071+ .bind(&rest_values)?,
5311072 ];
5321073 if ranked && a.before.is_none() {
5331074 statements.push(
5341075 db.prepare(format!(
535− "SELECT {COLUMNS} FROM inbox_items WHERE {} AND {leading} ORDER BY id DESC LIMIT ?3"
536− , conditions.join(" AND ")
1076+ "SELECT {COLUMNS} FROM inbox_items WHERE {} AND {leading} ORDER BY bumped DESC LIMIT ?3",
1077+ conditions.join(" AND ")
5371078 ))
5381079 .bind(&[username.as_str().into(), now.as_str().into(), MAX_RANKED.into()])?,
5391080 );
5421083 let mut rows = results.first().map(|result| result.results::<Row>()).transpose()?.unwrap_or_default();
5431084 let next = if rows.len() > limit as usize {
5441085 rows.truncate(limit as usize);
545− rows.last().map(|row| row.id.clone())
1086+ rows.last().map(|row| row.bumped.clone().unwrap_or_else(|| row.id.clone()))
5461087 } else {
5471088 None
5481089 };
5491090 let mut items: Vec<Row> = results.get(1).map(|result| result.results::<Row>()).transpose()?.unwrap_or_default();
5501091 items.extend(rows);
1092+ let items = readable_only(db, repos, &a.viewer, &username, items).await?;
1093+ Ok(InboxPage {
1094+ items: items.into_iter().map(Row::into_item).collect(),
1095+ next,
1096+ })
1097+}
5511098
552− // What is about a repository they can no longer read is dropped.
553− let ids: Vec<String> = items
1099+/// Rows about repositories the viewer can still read; the rest are
1100+/// removed from their inbox as they are found.
1101+pub(crate) async fn readable_only(db: &D1Database, repos: &Fetcher, viewer: &g1t_contracts::Viewer, username: &str, mut rows: Vec<Row>) -> Result<Vec<Row>> {
1102+ let ids: Vec<String> = rows
5541103 .iter()
5551104 .filter_map(|row| row.repo_id.clone())
5561105 .collect::<HashSet<_>>()
5571106 .into_iter()
5581107 .collect();
559− if !ids.is_empty() {
560− let readable: Vec<Repo> = g1t_kit::call(
561− repos,
562− "readable",
563− &ReadableArgs {
564− ids: ids.clone(),
565− viewer: a.viewer.clone(),
566− },
567− )
568− .await?;
569− let readable: HashSet<String> = readable.into_iter().map(|repo| repo.id).collect();
570− let gone: Vec<String> = ids.into_iter().filter(|id| !readable.contains(id)).collect();
571− if !gone.is_empty() {
572− forget(db, &username, &gone).await?;
573− items.retain(|row| row.repo_id.as_ref().is_none_or(|id| !gone.contains(id)));
574− }
1108+ if ids.is_empty() {
1109+ return Ok(rows);
1110+ }
1111+ let readable: Vec<Repo> = g1t_kit::call(
1112+ repos,
1113+ "readable",
1114+ &ReadableArgs {
1115+ ids: ids.clone(),
1116+ viewer: viewer.clone(),
1117+ },
1118+ )
1119+ .await?;
1120+ let readable: HashSet<String> = readable.into_iter().map(|repo| repo.id).collect();
1121+ let gone: Vec<String> = ids.into_iter().filter(|id| !readable.contains(id)).collect();
1122+ if !gone.is_empty() {
1123+ forget(db, username, &gone).await?;
1124+ rows.retain(|row| row.repo_id.as_ref().is_none_or(|id| !gone.contains(id)));
5751125 }
576− Ok(InboxPage {
577− items: items.into_iter().map(Row::into_item).collect(),
578− next,
579− })
1126+ Ok(rows)
5801127 }
5811128
5821129 /// Removes a person's items about repositories they cannot read.
5841131 let marks = vec!["?"; repo_ids.len()].join(", ");
5851132 let mut values: Vec<JsValue> = vec![username.into()];
5861133 values.extend(repo_ids.iter().map(|id| JsValue::from(id.as_str())));
587− db.prepare(format!("DELETE FROM inbox_items WHERE username = ? AND repo_id IN ({marks})"))
588− .bind(&values)?
589− .run()
590− .await?;
1134+ db.batch(vec![
1135+ db.prepare(format!(
1136+ "DELETE FROM inbox_activity WHERE item_id IN (SELECT id FROM inbox_items WHERE username = ? AND repo_id IN ({marks}))"
1137+ ))
1138+ .bind(&values)?,
1139+ db.prepare(format!("DELETE FROM inbox_items WHERE username = ? AND repo_id IN ({marks})"))
1140+ .bind(&values)?,
1141+ ])
1142+ .await?;
5911143 Ok(())
5921144 }
5931145
6331185 InboxMark::Save => "saved = 1",
6341186 InboxMark::Unsave => "saved = 0",
6351187 InboxMark::Snooze => "snoozed_until = ?2, read_at = COALESCE(read_at, ?1)",
1188+ InboxMark::Unsnooze => "snoozed_until = NULL",
6361189 }
6371190 }
6381191
6541207 let mut values: Vec<JsValue> = vec![now.as_str().into(), until.as_str().into(), a.username.to_lowercase().into()];
6551208 let target = if a.all && a.ids.is_empty() {
6561209 let mut target = "done_at IS NULL".to_owned();
1210+ let mut bind = |values: &mut Vec<JsValue>, value: &str, condition: &str| {
1211+ values.push(value.into());
1212+ target.push_str(&format!(" AND {}", condition.replace('?', &format!("?{}", values.len()))));
1213+ };
6571214 if let Some(severity) = a.severity {
658− values.push(severity.as_str().into());
659− target.push_str(&format!(" AND severity = ?{}", values.len()));
1215+ bind(&mut values, severity.as_str(), "severity = ?");
1216+ }
1217+ if let Some(repo_id) = &a.repo_id {
1218+ bind(&mut values, repo_id, "repo_id = ?");
1219+ }
1220+ if let Some(last_read_at) = a.last_read_at.as_deref().map(str::trim).filter(|at| !at.is_empty()) {
1221+ bind(&mut values, last_read_at, "COALESCE(updated_at, created_at) <= ?");
6601222 }
6611223 target
6621224 } else {
6811243 Ok(changed.len() as u32)
6821244 }
6831245
1246+#[derive(Deserialize)]
1247+struct ActivityRow {
1248+ reason: String,
1249+ severity: String,
1250+ title: String,
1251+ body: String,
1252+ event: Option<String>,
1253+ actor: Option<String>,
1254+ created_at: String,
1255+}
1256+
1257+/// One of the viewer's threads, with its history and their subscription.
1258+pub async fn thread(db: &D1Database, repos: &Fetcher, work: &Fetcher, a: ThreadArgs) -> Result<Option<InboxThread>> {
1259+ let Some(viewer) = &a.viewer else {
1260+ return Ok(None);
1261+ };
1262+ let username = viewer.username.to_lowercase();
1263+ let row = db
1264+ .prepare(format!("SELECT {COLUMNS} FROM inbox_items WHERE id = ? AND username = ?"))
1265+ .bind(&[a.id.as_str().into(), username.as_str().into()])?
1266+ .first::<Row>(None)
1267+ .await?;
1268+ let Some(row) = readable_only(db, repos, &a.viewer, &username, row.into_iter().collect()).await?.pop() else {
1269+ return Ok(None);
1270+ };
1271+ let activity = db
1272+ .prepare(
1273+ "SELECT reason, severity, title, body, event, actor, created_at FROM inbox_activity
1274+ WHERE item_id = ? ORDER BY id DESC LIMIT ?",
1275+ )
1276+ .bind(&[row.id.as_str().into(), MAX_ACTIVITY.into()])?
1277+ .all()
1278+ .await?
1279+ .results::<ActivityRow>()?
1280+ .into_iter()
1281+ .map(|row| InboxActivity {
1282+ reason: Reason::parse(&row.reason).unwrap_or(Reason::Subscribed),
1283+ severity: Severity::parse(&row.severity).unwrap_or(Severity::Info),
1284+ title: row.title,
1285+ body: row.body,
1286+ event: row.event,
1287+ actor: row.actor,
1288+ created_at: row.created_at,
1289+ })
1290+ .collect();
1291+ let item = row.into_item();
1292+ let subscription = match (item.subject, item.number) {
1293+ (Some(SubjectKind::Issue | SubjectKind::Pull), Some(_)) => {
1294+ subscriptions::subscription(
1295+ db,
1296+ work,
1297+ SubscriptionArgs {
1298+ viewer: a.viewer.clone(),
1299+ id: Some(item.id.clone()),
1300+ ..SubscriptionArgs::default()
1301+ },
1302+ )
1303+ .await?
1304+ }
1305+ _ => None,
1306+ };
1307+ Ok(Some(InboxThread {
1308+ item,
1309+ activity,
1310+ subscription,
1311+ }))
1312+}
1313+
6841314 // --- Keeping up ------------------------------------------------------------------
6851315
6861316 /// Moves rows with renamed workspaces and repositories, and drops those
7001330 }
7011331 statements.push(
7021332 db.prepare(
703− "UPDATE inbox_items SET repo = ?2 || substr(repo, length(?1) + 1), workspace = ?2
1333+ "UPDATE inbox_items SET repo = ?2 || substr(repo, length(?1) + 1), workspace = ?2,
1334+ link = CASE WHEN link LIKE '/' || ?1 || '/%' THEN '/' || ?2 || substr(link, length(?1) + 2) ELSE link END
7041335 WHERE workspace = ?1",
7051336 )
7061337 .bind(&[from.as_str().into(), to.as_str().into()])?,
7071338 );
1339+ statements.push(
1340+ db.prepare("UPDATE inbox_watching SET repo = ?2 || substr(repo, length(?1) + 1) WHERE repo LIKE ?1 || '/%'")
1341+ .bind(&[from.as_str().into(), to.as_str().into()])?,
1342+ );
7081343 }
709− "repo.renamed" => statements.push(
710− db.prepare("UPDATE inbox_items SET repo = ? WHERE repo_id = ?").bind(&[
711− format!("{}/{}", text("namespace"), text("to")).into(),
712− text("repoId").into(),
713− ])?,
714− ),
715− "repo.transferred" => statements.push(
716− db.prepare("UPDATE inbox_items SET repo = ?, workspace = ? WHERE repo_id = ?")
717− .bind(&[
718− format!("{}/{}", text("to"), text("name")).into(),
719− text("to").into(),
720− text("repoId").into(),
721− ])?,
722− ),
723− "repo.purged" => statements.push(
724− db.prepare("DELETE FROM inbox_items WHERE repo_id = ?")
725− .bind(&[text("repoId").into()])?,
726− ),
727− "workspace.deleted" => statements.push(
728− db.prepare("DELETE FROM inbox_items WHERE workspace = ?")
729− .bind(&[text("slug").into()])?,
730− ),
1344+ "repo.renamed" => {
1345+ let path = format!("{}/{}", text("namespace"), text("to"));
1346+ for table in ["inbox_items", "inbox_watching"] {
1347+ statements.push(
1348+ db.prepare(format!("UPDATE {table} SET repo = ? WHERE repo_id = ?"))
1349+ .bind(&[path.as_str().into(), text("repoId").into()])?,
1350+ );
1351+ }
1352+ }
1353+ "repo.transferred" => {
1354+ let path = format!("{}/{}", text("to"), text("name"));
1355+ statements.push(
1356+ db.prepare("UPDATE inbox_items SET repo = ?, workspace = ? WHERE repo_id = ?")
1357+ .bind(&[path.as_str().into(), text("to").into(), text("repoId").into()])?,
1358+ );
1359+ statements.push(
1360+ db.prepare("UPDATE inbox_watching SET repo = ? WHERE repo_id = ?")
1361+ .bind(&[path.as_str().into(), text("repoId").into()])?,
1362+ );
1363+ }
1364+ "repo.purged" => {
1365+ for sql in [
1366+ "DELETE FROM inbox_activity WHERE item_id IN (SELECT id FROM inbox_items WHERE repo_id = ?)",
1367+ "DELETE FROM inbox_items WHERE repo_id = ?",
1368+ "DELETE FROM inbox_subscriptions WHERE repo_id = ?",
1369+ "DELETE FROM inbox_watching WHERE repo_id = ?",
1370+ ] {
1371+ statements.push(db.prepare(sql).bind(&[text("repoId").into()])?);
1372+ }
1373+ }
1374+ "workspace.deleted" => {
1375+ statements.push(
1376+ db.prepare("DELETE FROM inbox_items WHERE workspace = ?")
1377+ .bind(&[text("slug").into()])?,
1378+ );
1379+ statements.push(
1380+ db.prepare("DELETE FROM inbox_watching WHERE repo LIKE ? || '/%'")
1381+ .bind(&[text("slug").into()])?,
1382+ );
1383+ }
7311384 _ => {}
7321385 }
7331386 }
7381391 }
7391392
7401393 /// Removes items done more than [`DONE_DAYS`] ago, and any not saved older
741−/// than [`MAX_DAYS`]. Returns how many went.
1394+/// than [`MAX_DAYS`], with their history. Returns how many went.
7421395 pub async fn purge(db: &D1Database, now: u64) -> Result<u32> {
7431396 let done = crate::audit::keep_from(now, DONE_DAYS);
7441397 let oldest = crate::audit::keep_from(now, MAX_DAYS);
745− let results = db
746− .batch(vec![
747− db.prepare("DELETE FROM inbox_items WHERE saved = 0 AND done_at < ?")
748− .bind(&[done.into()])?,
749− db.prepare("DELETE FROM inbox_items WHERE saved = 0 AND created_at < ?")
750− .bind(&[oldest.into()])?,
751− ])
752− .await?;
1398+ let statements: Vec<D1PreparedStatement> = vec![
1399+ db.prepare("DELETE FROM inbox_items WHERE saved = 0 AND done_at < ?")
1400+ .bind(&[done.into()])?,
1401+ db.prepare("DELETE FROM inbox_items WHERE saved = 0 AND COALESCE(updated_at, created_at) < ?")
1402+ .bind(&[oldest.into()])?,
1403+ db.prepare("DELETE FROM inbox_activity WHERE NOT EXISTS (SELECT 1 FROM inbox_items WHERE inbox_items.id = inbox_activity.item_id)"),
1404+ ];
1405+ let results = db.batch(statements).await?;
7531406 let mut removed = 0;
754− for result in results {
1407+ for result in results.into_iter().take(2) {
7551408 removed += result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) as u32;
7561409 }
7571410 Ok(removed)
7601413 #[cfg(test)]
7611414 mod tests {
7621415 use super::*;
1416+ use crate::subscriptions::{State, Subscription, Watcher};
7631417 use serde_json::json;
7641418
7651419 fn event(kind: &str, actor: Option<&str>, data: serde_json::Value) -> Event {
8151469 }
8161470 }
8171471
818− fn told(notices: &[Notice]) -> Vec<(&str, &str, Severity)> {
1472+ fn nobody() -> Audience {
1473+ Audience::default()
1474+ }
1475+
1476+ fn told(notices: &[Notice]) -> Vec<(&str, Reason, Severity)> {
8191477 notices
8201478 .iter()
8211479 .map(|notice| (notice.username.as_str(), notice.reason, notice.severity))
8341492 }
8351493 }
8361494
1495+ fn subscribed(rows: &[(&str, State, Option<Reason>)]) -> Audience {
1496+ Audience {
1497+ subscriptions: rows
1498+ .iter()
1499+ .map(|(name, state, reason)| Subscription {
1500+ username: (*name).into(),
1501+ state: *state,
1502+ reason: *reason,
1503+ })
1504+ .collect(),
1505+ watchers: Vec::new(),
1506+ }
1507+ }
1508+
1509+ fn watched(rows: &[(&str, WatchLevel, &[&str])]) -> Audience {
1510+ Audience {
1511+ subscriptions: Vec::new(),
1512+ watchers: rows
1513+ .iter()
1514+ .map(|(name, level, events)| Watcher {
1515+ username: (*name).into(),
1516+ level: *level,
1517+ events: events.iter().map(|kind| (*kind).to_owned()).collect(),
1518+ })
1519+ .collect(),
1520+ }
1521+ }
1522+
8371523 #[test]
8381524 fn only_events_that_tell_someone_are_read() {
8391525 let asked = |kind: &str, data| wants(&event(kind, None, data));
8531539 Some(Some("cmt_1".into()))
8541540 );
8551541 assert_eq!(asked("git.push", json!({ "repoId": "rep_1" })), None);
856− assert_eq!(asked("issue.opened", json!({ "repoId": "rep_1", "number": 1 })), None);
1542+ for kind in ["issue.opened", "pull.review_requested", "pull.stalled", "issue.assigned", "pull.closed", "issue.reopened"] {
1543+ assert!(asked(kind, json!({ "repoId": "rep_1", "number": 1 })).is_some(), "{kind}");
1544+ }
1545+ assert_eq!(asked("deployment.failed", json!({ "repoId": "rep_1" })).map(|w| w.number), Some(None));
8571546 }
8581547
8591548 #[test]
860− fn a_waiting_agent_needs_the_pull_requests_and_the_issues_people_first() {
1549+ fn a_waiting_or_stopped_agent_needs_the_pull_requests_and_the_issues_people_first() {
8611550 let asked = event("agent.asked", Some("usr_agent"), json!({ "number": 7 }));
862− let notices = notices(&asked, "acme/rocket", &Actor::default(), Some(&pull()));
1551+ let notices_ = notices(&asked, "acme/rocket", &Actor::default(), Some(&pull()), &nobody());
8631552 assert_eq!(
864− told(&notices),
1553+ told(&notices_),
8651554 vec![
866− ("ana", "agent_asked", Severity::Warning),
867− ("cy", "agent_asked", Severity::Warning),
868− ("dee", "agent_asked", Severity::Warning),
1555+ ("ana", Reason::Agent, Severity::Warning),
1556+ ("cy", Reason::Agent, Severity::Warning),
1557+ ("dee", Reason::Agent, Severity::Warning),
8691558 ]
8701559 );
871− assert_eq!(notices[0].title, "An agent is waiting on acme/rocket#7");
872− assert_eq!(notices[0].body, "Add the inbox");
1560+ assert_eq!(notices_[0].title, "An agent is waiting on acme/rocket#7");
1561+ assert_eq!(notices_[0].body, "Add the inbox");
1562+ // Stopping says why; even someone who unsubscribed hears of it.
1563+ let stalled = event("pull.stalled", None, json!({ "number": 7, "detail": "Its checks could not be run." }));
1564+ let audience = subscribed(&[("ana", State::Unsubscribed, None)]);
1565+ let notices_ = notices(&stalled, "acme/rocket", &Actor::default(), Some(&g1t_pull()), &audience);
1566+ assert_eq!(notices_[0].username, "ana");
1567+ assert_eq!(notices_[0].title, "g1t stopped on acme/rocket#7 and needs you");
1568+ assert_eq!(notices_[0].body, "Its checks could not be run.");
1569+ // Ignoring the thread silences even that.
1570+ let audience = subscribed(&[("ana", State::Ignored, None)]);
1571+ let notices_ = notices(&stalled, "acme/rocket", &Actor::default(), Some(&g1t_pull()), &audience);
1572+ assert!(!notices_.iter().any(|notice| notice.username == "ana"));
1573+ }
1574+
1575+ #[test]
1576+ fn whatever_an_agent_waits_on_closes_when_it_goes_on() {
1577+ for kind in RESUMES {
1578+ assert_eq!(resolves(&event(kind, None, json!({ "repoId": "rep_1", "number": 7 }))), Some("rep_1#7".into()));
1579+ }
1580+ assert_eq!(resolves(&event("comment.created", None, json!({ "repoId": "rep_1", "number": 7 }))), None);
8731581 }
8741582
8751583 #[test]
1584+ fn reviewers_and_assignees_asked_are_told_never_whoever_asked() {
1585+ let requested = event("pull.review_requested", Some("usr_ana"), json!({ "number": 7, "reviewers": ["bo", "g1t", "ana"] }));
1586+ let notices_ = notices(&requested, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody());
1587+ assert_eq!(told(&notices_), vec![("bo", Reason::ReviewRequested, Severity::Warning)]);
1588+ assert_eq!(notices_[0].title, "ana asked you to review acme/rocket#7");
1589+ let assigned = event("issue.assigned", Some("usr_ana"), json!({ "number": 3, "added": ["ana", "eve"] }));
1590+ let notices_ = notices(&assigned, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody());
1591+ assert_eq!(told(&notices_), vec![("eve", Reason::Assign, Severity::Info)]);
1592+ assert_eq!(notices_[0].title, "ana assigned you to acme/rocket#3");
1593+ // Both subscribe whoever they name, never g1t.
1594+ assert_eq!(subscribes(&requested, Some(&pull())), vec![("bo".into(), Reason::ReviewRequested), ("ana".into(), Reason::ReviewRequested)]);
1595+ assert_eq!(subscribes(&assigned, Some(&pull())), vec![("ana".into(), Reason::Assign), ("eve".into(), Reason::Assign)]);
1596+ }
1597+
1598+ #[test]
8761599 fn failures_go_to_whoever_answers_for_the_change() {
8771600 let failed = event("checks.completed", None, json!({ "number": 7, "status": "failed" }));
878− assert_eq!(told(&notices(&failed, "acme/rocket", &Actor::default(), Some(&pull()))), vec![("ana", "checks_failed", Severity::Error)]);
1601+ assert_eq!(
1602+ told(&notices(&failed, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())),
1603+ vec![("ana", Reason::CiActivity, Severity::Error)]
1604+ );
8791605 // g1t's change is the person's who asked for it, never g1t's.
880− let notices_ = notices(&failed, "acme/rocket", &Actor::default(), Some(&g1t_pull()));
881− assert_eq!(told(&notices_), vec![("ana", "checks_failed", Severity::Error)]);
1606+ let notices_ = notices(&failed, "acme/rocket", &Actor::default(), Some(&g1t_pull()), &nobody());
1607+ assert_eq!(told(&notices_), vec![("ana", Reason::CiActivity, Severity::Error)]);
8821608 let errored = event("checks.completed", None, json!({ "number": 7, "status": "errored" }));
883− assert_eq!(notices(&errored, "acme/rocket", &Actor::default(), Some(&pull()))[0].title, "Checks could not run on acme/rocket#7");
1609+ assert_eq!(
1610+ notices(&errored, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())[0].title,
1611+ "Checks could not run on acme/rocket#7"
1612+ );
8841613 }
8851614
8861615 #[test]
8871616 fn a_workflow_that_fails_tells_its_pull_requests_owner_even_if_they_pushed() {
8881617 let failed = event("workflow.completed", Some("usr_ana"), json!({ "pull": 7, "workflow": "CI", "conclusion": "failure" }));
889− let notices_ = notices(&failed, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()));
890− assert_eq!(told(&notices_), vec![("ana", "workflow_failed", Severity::Error)]);
1618+ let notices_ = notices(&failed, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody());
1619+ assert_eq!(told(&notices_), vec![("ana", Reason::CiActivity, Severity::Error)]);
8911620 assert_eq!(notices_[0].title, "CI failed on acme/rocket#7");
8921621 // On a branch: whoever pushed.
8931622 let pushed = event(
8941623 "workflow.completed",
8951624 Some("usr_bo"),
896− json!({ "workflow": "Deploy", "conclusion": "failure", "ref": "refs/heads/main", "number": 12, "sha": "abcdef0123" }),
1625+ json!({ "workflow": "Deploy", "path": ".g1t/workflows/deploy.yml", "conclusion": "failure", "ref": "refs/heads/main", "number": 12, "sha": "abcdef0123", "runId": "run_9" }),
8971626 );
898− let notices_ = notices(&pushed, "acme/rocket", &actor("usr_bo", "bo"), None);
899− assert_eq!(told(&notices_), vec![("bo", "workflow_failed", Severity::Error)]);
1627+ let notices_ = notices(&pushed, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody());
1628+ assert_eq!(told(&notices_), vec![("bo", Reason::CiActivity, Severity::Error)]);
9001629 assert_eq!(notices_[0].title, "Deploy failed on main in acme/rocket");
9011630 assert_eq!(notices_[0].body, "Run 12 at abcdef0");
9021631 // Nobody to tell when g1t pushed.
903− assert!(notices(&pushed, "acme/rocket", &actor("g1t", "g1t"), None).is_empty());
1632+ assert!(notices(&pushed, "acme/rocket", &actor("g1t", "g1t"), None, &nobody()).is_empty());
1633+ // Every failure of a workflow on a branch is one thread.
1634+ let wanted = wants(&pushed).unwrap();
1635+ let thread = thread_of(&pushed, &wanted, None);
1636+ assert_eq!(thread.key, "rep_1/run/.g1t/workflows/deploy.yml@main");
1637+ assert_eq!(thread.run_id.as_deref(), Some("run_9"));
1638+ }
1639+
1640+ #[test]
1641+ fn deployments_tell_whoever_answers_for_them_and_watchers() {
1642+ let failed = event(
1643+ "deployment.failed",
1644+ Some("usr_bo"),
1645+ json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "rocket", "deploymentId": "dpl_1", "error": "The build failed.", "path": "/acme/rocket/deployments/dpl_1" }),
1646+ );
1647+ let audience = watched(&[("cy", WatchLevel::Custom, &["deployments"]), ("dee", WatchLevel::Custom, &["issues"]), ("eve", WatchLevel::All, &[])]);
1648+ let notices_ = notices(&failed, "acme/rocket", &actor("usr_bo", "bo"), None, &audience);
1649+ assert_eq!(
1650+ told(&notices_),
1651+ vec![
1652+ ("bo", Reason::CiActivity, Severity::Error),
1653+ ("cy", Reason::Subscribed, Severity::Error),
1654+ ("eve", Reason::Subscribed, Severity::Error),
1655+ ]
1656+ );
1657+ assert_eq!(notices_[0].title, "Production of rocket failed to deploy");
1658+ assert_eq!(notices_[0].body, "The build failed.");
1659+ let thread = thread_of(&failed, &wants(&failed).unwrap(), None);
1660+ assert_eq!(thread.key, "rep_1/deploy/prj_1/production");
1661+ assert_eq!(url(Some("acme/rocket"), thread.kind, None, None, thread.link.as_deref()), "/acme/rocket/deployments/dpl_1");
1662+ // A success is news to the owner only after a failure.
1663+ let live = event("deployment.succeeded", Some("usr_bo"), json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "rocket", "commit": "abcdef0123" }));
1664+ assert!(notices(&live, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody()).is_empty());
1665+ let recovered = event("deployment.succeeded", Some("usr_bo"), json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "rocket", "recovered": true }));
1666+ let notices_ = notices(&recovered, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody());
1667+ assert_eq!(told(&notices_), vec![("bo", Reason::CiActivity, Severity::Success)]);
1668+ assert_eq!(notices_[0].title, "Production of rocket is live again");
1669+ // A preview's is the pull request's owner's.
1670+ let preview = event("deployment.failed", None, json!({ "repoId": "rep_1", "projectId": "prj_1", "number": 7, "triggeredBy": "g1t" }));
1671+ assert_eq!(told(&notices(&preview, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())), vec![("ana", Reason::CiActivity, Severity::Error)]);
1672+ assert_eq!(thread_of(&preview, &wants(&preview).unwrap(), Some(&pull())).key, "rep_1/deploy/prj_1/7");
9041673 }
9051674
9061675 #[test]
9071676 fn nobody_hears_of_what_they_did_themselves() {
9081677 let merged = event("pull.merged", Some("usr_ana"), json!({ "number": 7 }));
909− assert!(notices(&merged, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull())).is_empty());
910− let by_bo = notices(&merged, "acme/rocket", &actor("usr_bo", "bo"), Some(&pull()));
911− assert_eq!(told(&by_bo), vec![("ana", "merged", Severity::Success)]);
1678+ let by_ana = notices(&merged, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody());
1679+ // The assignee still hears; ana, who merged, does not.
1680+ assert_eq!(told(&by_ana), vec![("bo", Reason::StateChange, Severity::Success)]);
1681+ let by_bo = notices(&merged, "acme/rocket", &actor("usr_bo", "bo"), Some(&pull()), &nobody());
1682+ assert_eq!(told(&by_bo), vec![("ana", Reason::StateChange, Severity::Success)]);
9121683 assert_eq!(by_bo[0].title, "bo merged acme/rocket#7");
913− let by_queue = notices(&merged, "acme/rocket", &Actor::default(), Some(&pull()));
1684+ let by_queue = notices(&merged, "acme/rocket", &Actor::default(), Some(&pull()), &nobody());
9141685 assert_eq!(by_queue[0].title, "acme/rocket#7 was merged");
9151686 }
9161687
9171688 #[test]
1689+ fn closing_and_reopening_tells_everyone_subscribed_and_watchers() {
1690+ let closed = event("issue.closed", Some("usr_bo"), json!({ "number": 3, "reason": "completed" }));
1691+ let issue = InboxSubject {
1692+ kind: Some(SubjectKind::Issue),
1693+ title: "Crash".into(),
1694+ author: person("usr_cy", "cy"),
1695+ assignees: vec!["bo".into()],
1696+ ..InboxSubject::default()
1697+ };
1698+ let mut audience = subscribed(&[("eve", State::Subscribed, Some(Reason::Comment)), ("fay", State::Unsubscribed, None)]);
1699+ audience.watchers = watched(&[("gus", WatchLevel::All, &[]), ("hal", WatchLevel::Custom, &["pulls"])]).watchers;
1700+ let notices_ = notices(&closed, "acme/rocket", &actor("usr_bo", "bo"), Some(&issue), &audience);
1701+ assert_eq!(
1702+ told(&notices_),
1703+ vec![
1704+ ("cy", Reason::StateChange, Severity::Info),
1705+ ("eve", Reason::StateChange, Severity::Info),
1706+ ("gus", Reason::Subscribed, Severity::Info),
1707+ ]
1708+ );
1709+ assert_eq!(notices_[0].title, "bo closed acme/rocket#3");
1710+ let by_pull = event("issue.closed", None, json!({ "number": 3, "resolvedBy": 9 }));
1711+ assert_eq!(notices(&by_pull, "acme/rocket", &Actor::default(), Some(&issue), &nobody())[0].title, "acme/rocket#3 was closed by #9");
1712+ let reopened = event("issue.reopened", Some("usr_cy"), json!({ "number": 3 }));
1713+ assert_eq!(told(&notices(&reopened, "acme/rocket", &actor("usr_cy", "cy"), Some(&issue), &nobody())), vec![("bo", Reason::StateChange, Severity::Info)]);
1714+ }
1715+
1716+ #[test]
1717+ fn opening_tells_who_it_names_and_watchers_of_its_kind() {
1718+ let opened = event("pull.opened", Some("usr_ana"), json!({ "number": 7 }));
1719+ let subject = InboxSubject { reviewers: vec!["cy".into(), "g1t".into()], ..pull() };
1720+ let audience = watched(&[("bo", WatchLevel::All, &[]), ("dee", WatchLevel::Custom, &["issues"]), ("eve", WatchLevel::Custom, &["pulls"]), ("fay", WatchLevel::Participating, &[])]);
1721+ let notices_ = notices(&opened, "acme/rocket", &actor("usr_ana", "ana"), Some(&subject), &audience);
1722+ assert_eq!(
1723+ told(&notices_),
1724+ vec![
1725+ ("bo", Reason::Assign, Severity::Info),
1726+ ("cy", Reason::ReviewRequested, Severity::Warning),
1727+ ("eve", Reason::Subscribed, Severity::Info),
1728+ ]
1729+ );
1730+ assert_eq!(notices_[2].title, "ana opened acme/rocket#7");
1731+ }
1732+
1733+ #[test]
1734+ fn ignoring_a_repository_silences_it_and_unsubscribing_keeps_only_what_is_asked() {
1735+ let created = event("comment.created", Some("usr_bo"), json!({ "number": 7, "commentId": "cmt_1" }));
1736+ let on = comment(person("usr_bo", "bo"), "@cy have a look", &["cy"]);
1737+ let audience = watched(&[("cy", WatchLevel::Ignore, &[]), ("ana", WatchLevel::All, &[])]);
1738+ assert!(notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &audience).iter().all(|notice| notice.username != "cy"));
1739+ let audience = subscribed(&[("cy", State::Unsubscribed, None), ("ana", State::Unsubscribed, None)]);
1740+ let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &audience);
1741+ // cy was mentioned, which is asked of them; ana unsubscribed from the conversation.
1742+ assert_eq!(told(&notices_), vec![("cy", Reason::Mention, Severity::Info)]);
1743+ }
1744+
1745+ #[test]
9181746 fn g1t_finishing_or_reviewing_tells_the_person_it_worked_for() {
9191747 // The agent acts as the person it works for: still an outcome they hear of.
9201748 let ready = event("pull.ready", Some("usr_ana"), json!({ "number": 7 }));
921− let notices_ = notices(&ready, "acme/rocket", &actor("usr_ana", "ana"), Some(&g1t_pull()));
922− assert_eq!(told(&notices_), vec![("ana", "agent_finished", Severity::Success)]);
1749+ let notices_ = notices(&ready, "acme/rocket", &actor("usr_ana", "ana"), Some(&g1t_pull()), &nobody());
1750+ assert_eq!(told(&notices_), vec![("ana", Reason::Author, Severity::Success)]);
9231751 assert_eq!(notices_[0].title, "g1t finished acme/rocket#7");
9241752 // A person's draft marked ready is not news to them.
925− assert!(notices(&ready, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull())).is_empty());
1753+ assert!(notices(&ready, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody()).is_empty());
9261754
9271755 let approve = event("review.completed", None, json!({ "number": 7, "verdict": "approve" }));
928− assert_eq!(told(&notices(&approve, "acme/rocket", &Actor::default(), Some(&pull()))), vec![("ana", "approved", Severity::Success)]);
1756+ assert_eq!(
1757+ told(&notices(&approve, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())),
1758+ vec![("ana", Reason::Author, Severity::Success)]
1759+ );
9291760 let changes = event("review.completed", None, json!({ "number": 7, "verdict": "request_changes" }));
9301761 assert_eq!(
931− told(&notices(&changes, "acme/rocket", &Actor::default(), Some(&pull()))),
932− vec![("ana", "changes_requested", Severity::Info)]
1762+ told(&notices(&changes, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())),
1763+ vec![("ana", Reason::Author, Severity::Info)]
9331764 );
9341765 }
9351766
9361767 #[test]
937− fn comments_tell_those_mentioned_then_the_owner_never_the_writer() {
1768+ fn comments_tell_those_mentioned_then_everyone_subscribed_never_the_writer() {
9381769 let created = event("comment.created", Some("usr_bo"), json!({ "number": 7, "commentId": "cmt_1" }));
9391770 let on = comment(person("usr_bo", "bo"), "@cy @bo have a look", &["cy", "bo", "g1t"]);
940− let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on));
1771+ let audience = subscribed(&[("eve", State::Subscribed, Some(Reason::Comment)), ("fay", State::Subscribed, Some(Reason::Manual))]);
1772+ let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &audience);
9411773 assert_eq!(
9421774 told(&notices_),
943− vec![("cy", "mentioned", Severity::Info), ("ana", "commented", Severity::Info)]
1775+ vec![
1776+ ("cy", Reason::Mention, Severity::Info),
1777+ ("ana", Reason::Author, Severity::Info),
1778+ ("eve", Reason::Comment, Severity::Info),
1779+ ("fay", Reason::Manual, Severity::Info),
1780+ ]
9441781 );
9451782 assert_eq!(notices_[0].title, "bo mentioned you on acme/rocket#7");
1783+ assert_eq!(notices_[1].title, "bo commented on acme/rocket#7");
9461784 assert_eq!(notices_[0].body, "@cy @bo have a look");
9471785 // Mentioned and the owner: told once, as mentioned.
9481786 let on = comment(person("usr_bo", "bo"), "@ana", &["ana"]);
949− assert_eq!(told(&notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on))), vec![("ana", "mentioned", Severity::Info)]);
950− // The owner's own comment tells nobody but those they mention.
1787+ assert_eq!(
1788+ told(&notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &nobody())),
1789+ vec![("ana", Reason::Mention, Severity::Info)]
1790+ );
1791+ // The owner's own comment tells the assignee, not the owner.
9511792 let on = comment(person("usr_ana", "ana"), "thanks", &[]);
952− assert!(notices(&created, "acme/rocket", &actor("usr_ana", "ana"), Some(&on)).is_empty());
1793+ assert_eq!(
1794+ told(&notices(&created, "acme/rocket", &actor("usr_ana", "ana"), Some(&on), &nobody())),
1795+ vec![("bo", Reason::Assign, Severity::Info)]
1796+ );
9531797 // Something that happened, not something written, tells nobody.
9541798 let mut on = comment(person("usr_bo", "bo"), "assigned cy", &[]);
9551799 on.comment.as_mut().unwrap().event = true;
956− assert!(notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on)).is_empty());
1800+ assert!(notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &nobody()).is_empty());
9571801 // An approval is good news.
958− let mut on = comment(person("usr_bo", "bo"), "", &[]);
1802+ let mut on = comment(person("usr_cy", "cy"), "", &[]);
9591803 on.comment.as_mut().unwrap().verdict = Some("approve".into());
960− let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on));
961− assert_eq!(told(&notices_), vec![("ana", "approved", Severity::Success)]);
1804+ let notices_ = notices(&created, "acme/rocket", &actor("usr_cy", "cy"), Some(&on), &nobody());
1805+ assert_eq!(told(&notices_)[0], ("ana", Reason::Author, Severity::Success));
9621806 assert_eq!(notices_[0].body, "Add the inbox");
1807+ // Writing and being mentioned subscribe, never g1t.
1808+ let on = comment(person("usr_bo", "bo"), "@cy", &["cy"]);
1809+ assert_eq!(subscribes(&created, Some(&on)), vec![("bo".into(), Reason::Comment), ("cy".into(), Reason::Mention)]);
1810+ let on = comment(person(AGENT_ID, "g1t"), "done", &[]);
1811+ assert!(subscribes(&created, Some(&on)).is_empty());
1812+ }
1813+
1814+ #[test]
1815+ fn issues_and_pull_requests_are_one_thread_each() {
1816+ let created = event("comment.created", Some("usr_bo"), json!({ "repoId": "rep_1", "number": 7, "commentId": "cmt_1" }));
1817+ let wanted = wants(&created).unwrap();
1818+ let thread = thread_of(&created, &wanted, Some(&pull()));
1819+ assert_eq!(thread.key, "rep_1#7");
1820+ assert_eq!(thread.kind, Some(SubjectKind::Pull));
1821+ let merged = event("pull.merged", None, json!({ "repoId": "rep_1", "number": 7 }));
1822+ assert_eq!(thread_of(&merged, &wants(&merged).unwrap(), Some(&pull())).key, thread.key);
9631823 }
9641824
9651825 #[test]
9661826 fn items_link_to_what_they_are_about() {
967− assert_eq!(url(Some("acme/rocket"), Some(SubjectKind::Pull), Some(7), None), "/acme/rocket/pull/7");
968− assert_eq!(url(Some("acme/rocket"), Some(SubjectKind::Issue), Some(3), None), "/acme/rocket/issues/3");
969− assert_eq!(url(Some("acme/rocket"), Some(SubjectKind::Run), None, Some("run_1")), "/acme/rocket/actions/runs/run_1");
970− assert_eq!(url(Some("acme/rocket"), None, None, None), "/acme/rocket");
971− assert_eq!(url(None, None, None, None), "/inbox");
1827+ assert_eq!(url(Some("acme/rocket"), Some(SubjectKind::Pull), Some(7), None, None), "/acme/rocket/pull/7");
1828+ assert_eq!(url(Some("acme/rocket"), Some(SubjectKind::Issue), Some(3), None, None), "/acme/rocket/issues/3");
1829+ assert_eq!(url(Some("acme/rocket"), Some(SubjectKind::Run), None, Some("run_1"), None), "/acme/rocket/actions/runs/run_1");
1830+ assert_eq!(url(Some("acme/rocket"), Some(SubjectKind::Deploy), None, None, Some("/acme/site/deployments/dpl_1")), "/acme/site/deployments/dpl_1");
1831+ assert_eq!(url(Some("acme/rocket"), None, None, None, None), "/acme/rocket");
1832+ assert_eq!(url(None, None, None, None, None), "/inbox");
9721833 }
9731834
9741835 #[test]
9821843 fn marks_change_only_what_they_say() {
9831844 assert_eq!(mark_change(InboxMark::Read), "read_at = COALESCE(read_at, ?1)");
9841845 assert!(mark_change(InboxMark::Done).contains("done_at = ?1"));
985− assert!(ranked(&ListInboxArgs {
986− viewer: None,
987− view: InboxView::Inbox,
988− severity: None,
989− unread: false,
990− before: None,
991− limit: None,
992− }));
1846+ assert_eq!(mark_change(InboxMark::Unsnooze), "snoozed_until = NULL");
1847+ assert!(ranked(&ListInboxArgs::default()));
1848+ assert!(!ranked(&ListInboxArgs { reason: Some(Reason::Mention), ..ListInboxArgs::default() }));
1849+ assert!(!ranked(&ListInboxArgs { participating: true, ..ListInboxArgs::default() }));
1850+ }
1851+
1852+ #[test]
1853+ fn filters_bind_in_order_after_what_is_bound() {
1854+ let a = ListInboxArgs {
1855+ reason: Some(Reason::Mention),
1856+ repo_id: Some("rep_1".into()),
1857+ participating: true,
1858+ unread: true,
1859+ ..ListInboxArgs::default()
1860+ };
1861+ // Two values come first: the username and now.
1862+ let (conditions, values) = filters(&a, 2);
1863+ assert_eq!(
1864+ conditions,
1865+ vec!["reason = ?3", "repo_id = ?4", "reason NOT IN ('manual', 'subscribed')", "read_at IS NULL"]
1866+ );
1867+ assert_eq!(values, vec!["mention", "rep_1"]);
1868+ }
1869+
1870+ #[test]
1871+ fn the_bump_keeps_whatever_is_most_urgent_while_unread() {
1872+ assert!(BUMP.contains("WHERE NOT EXISTS (SELECT 1 FROM inbox_activity WHERE event_id = ?4 AND username = ?2)"));
1873+ assert!(BUMP.contains("ON CONFLICT (username, thread) DO UPDATE"));
1874+ assert!(BUMP.contains("activity = inbox_items.activity + 1"));
1875+ // Its numbered parameters run from 1 to 19.
1876+ for at in 1..=19 {
1877+ assert!(BUMP.contains(&format!("?{at}")), "?{at}");
1878+ }
1879+ assert!(!BUMP.contains("?20"));
9931880 }
9941881 }
+20−1
66 //! queue, so a slow or failing subscriber holds up nobody else.
77 //!
88 //! It keeps two more things beside the log: the audit log (audit.rs) and
9−//! each person's inbox (inbox.rs), written as events arrive.
9+//! each person's inbox (inbox.rs), written as events arrive, with who
10+//! follows what (subscriptions.rs).
1011 //!
1112 //! Other services reach it over `POST /rpc/<method>`; see
1213 //! `g1t_contracts::events`, `audit` and `inbox` for the methods and their
1415
1516 mod audit;
1617 mod inbox;
18+mod subscriptions;
1719
1820 use g1t_contracts::events::{Event, ListArgs, PublishArgs};
1921 use g1t_contracts::new_id;
210212 }
211213 "inbox_counts" => reply(&inbox::counts(&events.db, args(body)?).await?),
212214 "inbox_mark" => reply(&inbox::mark(&events.db, args(body)?).await?),
215+ "inbox_thread" => {
216+ let (repos, work) = (events.env.service("REPOS")?, events.env.service("WORK")?);
217+ reply(&inbox::thread(&events.db, &repos, &work, args(body)?).await?)
218+ }
219+ "inbox_subscription" => {
220+ let work = events.env.service("WORK")?;
221+ reply(&subscriptions::subscription(&events.db, &work, args(body)?).await?)
222+ }
223+ "inbox_subscribe" => {
224+ let work = events.env.service("WORK")?;
225+ reply(&subscriptions::subscribe(&events.db, &work, args(body)?).await?)
226+ }
227+ "inbox_watching" => reply(&subscriptions::watching(&events.db, args(body)?).await?),
228+ "inbox_watch" => reply(&subscriptions::watch(&events.db, args(body)?).await?),
229+ "inbox_watched" => reply(&subscriptions::watched(&events.db, args(body)?).await?),
230+ "inbox_settings" => reply(&subscriptions::settings(&events.db, args(body)?).await?),
231+ "inbox_update_settings" => reply(&subscriptions::update_settings(&events.db, args(body)?).await?),
213232 _ => Response::error("Unknown method", 404),
214233 }
215234 }
+686−0
1+//! Who follows what, for the inbox (inbox.rs): subscriptions to issues and
2+//! pull requests, how people watch repositories, and what each person
3+//! chose about being told. See `g1t_contracts::inbox`.
4+//!
5+//! An issue's or pull request's author (or whoever asked g1t for it), its
6+//! assignees and its reviewers are subscribed without a row. A row records
7+//! the rest: someone who commented or was mentioned (subscribed as they
8+//! did), or who subscribed, unsubscribed or ignored it by hand. Commenting
9+//! or being mentioned subscribes again someone who unsubscribed; nothing
10+//! but their own choice undoes ignoring.
11+
12+use std::collections::HashMap;
13+
14+use g1t_contracts::events::Event;
15+use g1t_contracts::inbox::*;
16+use g1t_kit::now_ms;
17+use g1t_contracts::time::rfc3339;
18+use serde::Deserialize;
19+use worker::wasm_bindgen::JsValue;
20+use worker::{D1Database, D1PreparedStatement, Fetcher, Result};
21+
22+use crate::inbox::{is_g1t, is_g1t_id, numbered_thread};
23+
24+/// The most watchers read for one repository's event.
25+const MAX_WATCHERS: u32 = 5000;
26+
27+/// Where a person stands on one thread, by their own row.
28+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29+pub enum State {
30+ Subscribed,
31+ Unsubscribed,
32+ Ignored,
33+}
34+
35+impl State {
36+ pub fn as_str(self) -> &'static str {
37+ match self {
38+ State::Subscribed => "subscribed",
39+ State::Unsubscribed => "unsubscribed",
40+ State::Ignored => "ignored",
41+ }
42+ }
43+
44+ pub fn parse(value: &str) -> Option<State> {
45+ [State::Subscribed, State::Unsubscribed, State::Ignored]
46+ .into_iter()
47+ .find(|state| state.as_str() == value)
48+ }
49+}
50+
51+#[derive(Clone, Debug, PartialEq, Eq)]
52+pub struct Subscription {
53+ pub username: String,
54+ pub state: State,
55+ pub reason: Option<Reason>,
56+}
57+
58+#[derive(Clone, Debug, PartialEq, Eq)]
59+pub struct Watcher {
60+ pub username: String,
61+ pub level: WatchLevel,
62+ pub events: Vec<String>,
63+}
64+
65+/// Who follows the thread an event is on, beyond its own people: its
66+/// subscription rows, and the repository's watchers.
67+#[derive(Clone, Debug, Default)]
68+pub struct Audience {
69+ pub subscriptions: Vec<Subscription>,
70+ pub watchers: Vec<Watcher>,
71+}
72+
73+impl Audience {
74+ fn row(&self, username: &str) -> Option<&Subscription> {
75+ self.subscriptions.iter().find(|row| row.username.eq_ignore_ascii_case(username))
76+ }
77+
78+ /// Hears of nothing here: they ignore the thread or the repository.
79+ pub fn ignores(&self, username: &str) -> bool {
80+ self.row(username).is_some_and(|row| row.state == State::Ignored)
81+ || self
82+ .watchers
83+ .iter()
84+ .any(|watcher| watcher.level == WatchLevel::Ignore && watcher.username.eq_ignore_ascii_case(username))
85+ }
86+
87+ /// Hears only of what is asked of them.
88+ pub fn unsubscribed(&self, username: &str) -> bool {
89+ self.row(username).is_some_and(|row| row.state == State::Unsubscribed)
90+ }
91+
92+ /// Everyone with a row saying they are subscribed, and why.
93+ pub fn subscribed(&self) -> impl Iterator<Item = (String, Reason)> + '_ {
94+ self.subscriptions
95+ .iter()
96+ .filter(|row| row.state == State::Subscribed)
97+ .map(|row| (row.username.clone(), row.reason.unwrap_or(Reason::Manual)))
98+ }
99+
100+ /// Everyone watching the repository for `kind` of activity: `issues`,
101+ /// `pulls`, `deployments` or `security`.
102+ pub fn watching<'a>(&'a self, kind: &'a str) -> impl Iterator<Item = String> + 'a {
103+ self.watchers
104+ .iter()
105+ .filter(move |watcher| match watcher.level {
106+ WatchLevel::All => true,
107+ WatchLevel::Custom => watcher.events.iter().any(|event| event == kind),
108+ WatchLevel::Participating | WatchLevel::Ignore => false,
109+ })
110+ .map(|watcher| watcher.username.clone())
111+ }
112+}
113+
114+#[derive(Deserialize)]
115+struct SubscriptionRow {
116+ username: String,
117+ state: String,
118+ reason: Option<String>,
119+ chosen_at: Option<String>,
120+}
121+
122+impl SubscriptionRow {
123+ fn into_subscription(self) -> Option<Subscription> {
124+ Some(Subscription {
125+ username: self.username,
126+ state: State::parse(&self.state)?,
127+ reason: self.reason.as_deref().and_then(Reason::parse),
128+ })
129+ }
130+}
131+
132+/// An issue's or pull request's subscription rows.
133+pub async fn of_thread(db: &D1Database, thread: &str) -> Result<Vec<Subscription>> {
134+ Ok(db
135+ .prepare("SELECT username, state, reason, chosen_at FROM inbox_subscriptions WHERE thread = ?")
136+ .bind(&[thread.into()])?
137+ .all()
138+ .await?
139+ .results::<SubscriptionRow>()?
140+ .into_iter()
141+ .filter_map(SubscriptionRow::into_subscription)
142+ .collect())
143+}
144+
145+#[derive(Deserialize)]
146+struct WatcherRow {
147+ username: String,
148+ level: String,
149+ events: Option<String>,
150+}
151+
152+fn events_of(text: Option<&str>) -> Vec<String> {
153+ text.and_then(|text| serde_json::from_str::<Vec<String>>(text).ok()).unwrap_or_default()
154+}
155+
156+/// Everyone who watches a repository other than the default way.
157+pub async fn watchers(db: &D1Database, repo_id: &str) -> Result<Vec<Watcher>> {
158+ Ok(db
159+ .prepare("SELECT username, level, events FROM inbox_watching WHERE repo_id = ? AND level != 'participating' LIMIT ?")
160+ .bind(&[repo_id.into(), MAX_WATCHERS.into()])?
161+ .all()
162+ .await?
163+ .results::<WatcherRow>()?
164+ .into_iter()
165+ .filter_map(|row| {
166+ Some(Watcher {
167+ level: WatchLevel::parse(&row.level)?,
168+ events: events_of(row.events.as_deref()),
169+ username: row.username,
170+ })
171+ })
172+ .collect())
173+}
174+
175+/// Subscribes the people an event names to its issue or pull request:
176+/// anyone without a row, and anyone who had unsubscribed. Someone who
177+/// ignores it stays ignoring it.
178+pub fn auto_subscribe(
179+ db: &D1Database,
180+ thread: &str,
181+ repo_id: &str,
182+ people: &[(String, Reason)],
183+ at: &str,
184+) -> Result<Vec<D1PreparedStatement>> {
185+ if !thread.contains('#') {
186+ return Ok(Vec::new());
187+ }
188+ people
189+ .iter()
190+ .map(|(username, reason)| {
191+ db.prepare(
192+ "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, reason, created_at)
193+ VALUES (?1, ?2, ?3, 'subscribed', ?4, ?5)
194+ ON CONFLICT (thread, username) DO UPDATE SET state = 'subscribed', reason = excluded.reason
195+ WHERE inbox_subscriptions.state = 'unsubscribed'",
196+ )
197+ .bind(&[
198+ username.as_str().into(),
199+ thread.into(),
200+ repo_id.into(),
201+ reason.as_str().into(),
202+ at.into(),
203+ ])
204+ })
205+ .collect()
206+}
207+
208+/// Whoever made a repository watches it as they chose for new ones (all of
209+/// its activity, unless they said otherwise).
210+pub async fn watch_created(db: &D1Database, event: &Event, names: &HashMap<String, String>) -> Result<()> {
211+ let Some(id) = event.actor.as_deref().filter(|id| !is_g1t_id(id)) else {
212+ return Ok(());
213+ };
214+ let (Some(username), Some(repo_id)) = (
215+ names.get(id).map(|name| name.to_lowercase()).filter(|name| !is_g1t(name)),
216+ event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone()),
217+ ) else {
218+ return Ok(());
219+ };
220+ let repo = format!(
221+ "{}/{}",
222+ event.data["namespace"].as_str().unwrap_or_default(),
223+ event.data["name"].as_str().unwrap_or_default()
224+ )
225+ .to_lowercase();
226+ db.prepare(
227+ "INSERT OR IGNORE INTO inbox_watching (username, repo_id, repo, level, events, updated_at)
228+ SELECT ?1, ?2, ?3, COALESCE((SELECT default_watch FROM inbox_settings WHERE username = ?1), ?4), '[]', ?5",
229+ )
230+ .bind(&[
231+ username.into(),
232+ repo_id.into(),
233+ repo.into(),
234+ InboxSettings::default().default_watch.as_str().into(),
235+ event.time.as_str().into(),
236+ ])?
237+ .run()
238+ .await?;
239+ Ok(())
240+}
241+
242+// --- One person's subscriptions ----------------------------------------------
243+
244+#[derive(Deserialize)]
245+struct ItemRow {
246+ thread: Option<String>,
247+ repo_id: Option<String>,
248+ repo: Option<String>,
249+ number: Option<f64>,
250+ subject: Option<String>,
251+}
252+
253+/// The issue or pull request asked about: its thread, repository and
254+/// number, and its `owner/name` when known.
255+struct Target {
256+ thread: String,
257+ repo_id: String,
258+ number: u32,
259+ repo: Option<String>,
260+}
261+
262+async fn target(db: &D1Database, username: &str, a: &SubscriptionArgs) -> Result<Option<Target>> {
263+ if let Some(id) = &a.id {
264+ let row = db
265+ .prepare("SELECT thread, repo_id, repo, number, subject FROM inbox_items WHERE id = ? AND username = ?")
266+ .bind(&[id.as_str().into(), username.into()])?
267+ .first::<ItemRow>(None)
268+ .await?;
269+ return Ok(row.and_then(|row| {
270+ let kind = row.subject.as_deref().and_then(SubjectKind::parse);
271+ if !matches!(kind, Some(SubjectKind::Issue | SubjectKind::Pull)) {
272+ return None;
273+ }
274+ Some(Target {
275+ thread: row.thread?,
276+ repo_id: row.repo_id?,
277+ number: row.number? as u32,
278+ repo: row.repo,
279+ })
280+ }));
281+ }
282+ let (Some(repo_id), Some(number)) = (&a.repo_id, a.number) else {
283+ return Ok(None);
284+ };
285+ Ok(Some(Target {
286+ thread: numbered_thread(repo_id, number),
287+ repo_id: repo_id.clone(),
288+ number,
289+ repo: None,
290+ }))
291+}
292+
293+/// Why the person is subscribed without a row: they own it, wrote it, are
294+/// assigned or were asked to review.
295+fn implicit(subject: &InboxSubject, username: &str) -> Option<Reason> {
296+ let is = |name: &str| name.eq_ignore_ascii_case(username);
297+ if is(&subject.owner().username) || is(&subject.author.username) {
298+ Some(Reason::Author)
299+ } else if subject.assignees.iter().any(|name| is(name)) {
300+ Some(Reason::Assign)
301+ } else if subject.reviewers.iter().any(|name| is(name)) {
302+ Some(Reason::ReviewRequested)
303+ } else {
304+ None
305+ }
306+}
307+
308+/// What a row, or the lack of one, says.
309+fn standing(row: Option<&SubscriptionRow>, implicit: Option<Reason>) -> (bool, bool, Option<Reason>) {
310+ match row.and_then(|row| State::parse(&row.state).map(|state| (state, row))) {
311+ Some((State::Ignored, _)) => (false, true, None),
312+ Some((State::Unsubscribed, _)) => (false, false, None),
313+ Some((State::Subscribed, row)) => (true, false, row.reason.as_deref().and_then(Reason::parse).or(Some(Reason::Manual))),
314+ None => (implicit.is_some(), false, implicit),
315+ }
316+}
317+
318+/// The person's subscription to an issue or pull request. None when there
319+/// is no such issue or pull request, or it is a thread nobody subscribes to.
320+pub async fn subscription(db: &D1Database, work: &Fetcher, a: SubscriptionArgs) -> Result<Option<ThreadSubscription>> {
321+ let Some(viewer) = &a.viewer else {
322+ return Ok(None);
323+ };
324+ let username = viewer.username.to_lowercase();
325+ let Some(target) = target(db, &username, &a).await? else {
326+ return Ok(None);
327+ };
328+ let row = db
329+ .prepare("SELECT username, state, reason, chosen_at FROM inbox_subscriptions WHERE thread = ? AND username = ?")
330+ .bind(&[target.thread.as_str().into(), username.as_str().into()])?
331+ .first::<SubscriptionRow>(None)
332+ .await?;
333+ // Without a row, whether they take part is the issue's to say; and an
334+ // issue that is not there has no subscription.
335+ let subject: Option<InboxSubject> = g1t_kit::call(
336+ work,
337+ "inbox_subject",
338+ &InboxSubjectArgs {
339+ repo_id: target.repo_id.clone(),
340+ number: target.number,
341+ comment_id: None,
342+ },
343+ )
344+ .await?;
345+ let Some(subject) = subject else {
346+ return Ok(None);
347+ };
348+ let (subscribed, ignored, reason) = standing(row.as_ref(), implicit(&subject, &username));
349+ Ok(Some(ThreadSubscription {
350+ subscribed,
351+ ignored,
352+ reason,
353+ repo: target.repo,
354+ number: Some(target.number),
355+ updated_at: row.and_then(|row| row.chosen_at),
356+ }))
357+}
358+
359+/// Subscribes, unsubscribes or ignores by hand, or goes back to the
360+/// default (subscribed only while taking part).
361+pub async fn subscribe(db: &D1Database, work: &Fetcher, a: SubscribeArgs) -> Result<Option<ThreadSubscription>> {
362+ let Some(viewer) = &a.on.viewer else {
363+ return Ok(None);
364+ };
365+ let username = viewer.username.to_lowercase();
366+ if is_g1t(&username) {
367+ return Ok(None);
368+ }
369+ let Some(target) = target(db, &username, &a.on).await? else {
370+ return Ok(None);
371+ };
372+ // Only an issue or pull request that is there.
373+ let found: Option<InboxSubject> = g1t_kit::call(
374+ work,
375+ "inbox_subject",
376+ &InboxSubjectArgs {
377+ repo_id: target.repo_id.clone(),
378+ number: target.number,
379+ comment_id: None,
380+ },
381+ )
382+ .await?;
383+ if found.is_none() {
384+ return Ok(None);
385+ }
386+ let now = rfc3339(now_ms());
387+ let state = match (a.ignored, a.subscribed) {
388+ (true, _) => Some(State::Ignored),
389+ (false, Some(true)) => Some(State::Subscribed),
390+ (false, Some(false)) => Some(State::Unsubscribed),
391+ (false, None) => None,
392+ };
393+ match state {
394+ None => {
395+ db.prepare("DELETE FROM inbox_subscriptions WHERE thread = ? AND username = ?")
396+ .bind(&[target.thread.as_str().into(), username.as_str().into()])?
397+ .run()
398+ .await?;
399+ }
400+ Some(state) => {
401+ db.prepare(
402+ "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, reason, created_at, chosen_at)
403+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
404+ ON CONFLICT (thread, username) DO UPDATE SET
405+ state = excluded.state,
406+ reason = CASE WHEN excluded.state = 'subscribed' AND inbox_subscriptions.state = 'subscribed'
407+ THEN inbox_subscriptions.reason ELSE excluded.reason END,
408+ chosen_at = excluded.chosen_at",
409+ )
410+ .bind(&[
411+ username.as_str().into(),
412+ target.thread.as_str().into(),
413+ target.repo_id.as_str().into(),
414+ state.as_str().into(),
415+ if state == State::Subscribed { Reason::Manual.as_str().into() } else { JsValue::NULL },
416+ now.as_str().into(),
417+ ])?
418+ .run()
419+ .await?;
420+ }
421+ }
422+ subscription(db, work, a.on).await
423+}
424+
425+// --- Watching ------------------------------------------------------------------
426+
427+#[derive(Deserialize)]
428+struct WatchingRow {
429+ repo_id: String,
430+ repo: Option<String>,
431+ level: String,
432+ events: Option<String>,
433+ updated_at: Option<String>,
434+}
435+
436+impl WatchingRow {
437+ fn into_watching(self) -> Watching {
438+ Watching {
439+ level: WatchLevel::parse(&self.level).unwrap_or_default(),
440+ events: events_of(self.events.as_deref()),
441+ repo_id: self.repo_id,
442+ repo: self.repo,
443+ updated_at: self.updated_at,
444+ }
445+ }
446+}
447+
448+/// How the person watches one repository: participating unless they chose.
449+pub async fn watching(db: &D1Database, a: WatchingArgs) -> Result<Watching> {
450+ let row = db
451+ .prepare("SELECT repo_id, repo, level, events, updated_at FROM inbox_watching WHERE repo_id = ? AND username = ?")
452+ .bind(&[a.repo_id.as_str().into(), a.username.to_lowercase().into()])?
453+ .first::<WatchingRow>(None)
454+ .await?;
455+ Ok(row.map(WatchingRow::into_watching).unwrap_or(Watching {
456+ repo_id: a.repo_id,
457+ ..Watching::default()
458+ }))
459+}
460+
461+/// The kinds a custom watch follows: known ones, each once, in their order.
462+pub fn custom_events(events: &[String]) -> Vec<String> {
463+ WATCH_EVENTS
464+ .into_iter()
465+ .filter(|kind| events.iter().any(|event| event.trim().eq_ignore_ascii_case(kind)))
466+ .map(str::to_owned)
467+ .collect()
468+}
469+
470+/// Sets how the person watches a repository; no level goes back to the
471+/// default.
472+pub async fn watch(db: &D1Database, a: WatchArgs) -> Result<Watching> {
473+ let username = a.username.to_lowercase();
474+ match a.level {
475+ None => {
476+ db.prepare("DELETE FROM inbox_watching WHERE repo_id = ? AND username = ?")
477+ .bind(&[a.repo_id.as_str().into(), username.as_str().into()])?
478+ .run()
479+ .await?;
480+ }
481+ Some(level) => {
482+ let events = if level == WatchLevel::Custom { custom_events(&a.events) } else { Vec::new() };
483+ db.prepare(
484+ "INSERT INTO inbox_watching (username, repo_id, repo, level, events, updated_at)
485+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
486+ ON CONFLICT (repo_id, username) DO UPDATE SET
487+ repo = COALESCE(excluded.repo, inbox_watching.repo), level = excluded.level,
488+ events = excluded.events, updated_at = excluded.updated_at",
489+ )
490+ .bind(&[
491+ username.as_str().into(),
492+ a.repo_id.as_str().into(),
493+ a.repo.as_deref().map(str::to_lowercase).map_or(JsValue::NULL, JsValue::from),
494+ level.as_str().into(),
495+ serde_json::to_string(&events)?.into(),
496+ rfc3339(now_ms()).into(),
497+ ])?
498+ .run()
499+ .await?;
500+ }
501+ }
502+ watching(
503+ db,
504+ WatchingArgs {
505+ username,
506+ repo_id: a.repo_id,
507+ },
508+ )
509+ .await
510+}
511+
512+/// The repositories the person watches other than the default way.
513+pub async fn watched(db: &D1Database, a: InboxCountsArgs) -> Result<Vec<Watching>> {
514+ Ok(db
515+ .prepare(
516+ "SELECT repo_id, repo, level, events, updated_at FROM inbox_watching
517+ WHERE username = ? AND level != 'participating' ORDER BY repo LIMIT 500",
518+ )
519+ .bind(&[a.username.to_lowercase().into()])?
520+ .all()
521+ .await?
522+ .results::<WatchingRow>()?
523+ .into_iter()
524+ .map(WatchingRow::into_watching)
525+ .collect())
526+}
527+
528+// --- Settings ------------------------------------------------------------------
529+
530+#[derive(Deserialize)]
531+struct SettingsRow {
532+ username: String,
533+ email: Option<String>,
534+ default_watch: Option<String>,
535+}
536+
537+impl SettingsRow {
538+ fn into_settings(self) -> InboxSettings {
539+ let defaults = InboxSettings::default();
540+ InboxSettings {
541+ email: match self.email.as_deref().and_then(|text| serde_json::from_str::<Vec<String>>(text).ok()) {
542+ Some(reasons) => email_reasons(&reasons),
543+ None => defaults.email,
544+ },
545+ default_watch: self.default_watch.as_deref().and_then(WatchLevel::parse).unwrap_or(defaults.default_watch),
546+ }
547+ }
548+}
549+
550+/// Reasons as given, known ones only, each once, in their order.
551+pub fn email_reasons(reasons: &[String]) -> Vec<Reason> {
552+ Reason::ALL
553+ .into_iter()
554+ .filter(|reason| reasons.iter().any(|given| given == reason.as_str()))
555+ .collect()
556+}
557+
558+/// Each person's settings, by username; the defaults for those who never chose.
559+pub async fn settings_of(db: &D1Database, usernames: &[String]) -> Result<HashMap<String, InboxSettings>> {
560+ if usernames.is_empty() {
561+ return Ok(HashMap::new());
562+ }
563+ let marks = vec!["?"; usernames.len()].join(", ");
564+ let values: Vec<JsValue> = usernames.iter().map(|name| JsValue::from(name.as_str())).collect();
565+ Ok(db
566+ .prepare(format!("SELECT username, email, default_watch FROM inbox_settings WHERE username IN ({marks})"))
567+ .bind(&values)?
568+ .all()
569+ .await?
570+ .results::<SettingsRow>()?
571+ .into_iter()
572+ .map(|row| (row.username.clone(), row.into_settings()))
573+ .collect())
574+}
575+
576+pub async fn settings(db: &D1Database, a: InboxCountsArgs) -> Result<InboxSettings> {
577+ let username = a.username.to_lowercase();
578+ Ok(settings_of(db, std::slice::from_ref(&username))
579+ .await?
580+ .remove(&username)
581+ .unwrap_or_default())
582+}
583+
584+pub async fn update_settings(db: &D1Database, a: UpdateInboxSettingsArgs) -> Result<InboxSettings> {
585+ let username = a.username.to_lowercase();
586+ let email = a
587+ .email
588+ .map(|reasons| {
589+ let names: Vec<String> = reasons.iter().map(|reason| reason.as_str().to_owned()).collect();
590+ serde_json::to_string(&email_reasons(&names).iter().map(|reason| reason.as_str()).collect::<Vec<_>>())
591+ })
592+ .transpose()?;
593+ db.prepare(
594+ "INSERT INTO inbox_settings (username, email, default_watch, updated_at) VALUES (?1, ?2, ?3, ?4)
595+ ON CONFLICT (username) DO UPDATE SET
596+ email = COALESCE(excluded.email, inbox_settings.email),
597+ default_watch = COALESCE(excluded.default_watch, inbox_settings.default_watch),
598+ updated_at = excluded.updated_at",
599+ )
600+ .bind(&[
601+ username.as_str().into(),
602+ email.map_or(JsValue::NULL, JsValue::from),
603+ a.default_watch.map_or(JsValue::NULL, |level| level.as_str().into()),
604+ rfc3339(now_ms()).into(),
605+ ])?
606+ .run()
607+ .await?;
608+ settings(db, InboxCountsArgs { username }).await
609+}
610+
611+#[cfg(test)]
612+mod tests {
613+ use super::*;
614+ use g1t_contracts::credentials::Principal;
615+
616+ fn row(state: &str, reason: Option<&str>) -> SubscriptionRow {
617+ SubscriptionRow {
618+ username: "ana".into(),
619+ state: state.into(),
620+ reason: reason.map(str::to_owned),
621+ chosen_at: None,
622+ }
623+ }
624+
625+ #[test]
626+ fn a_row_says_more_than_taking_part() {
627+ assert_eq!(standing(None, Some(Reason::Author)), (true, false, Some(Reason::Author)));
628+ assert_eq!(standing(None, None), (false, false, None));
629+ assert_eq!(standing(Some(&row("unsubscribed", None)), Some(Reason::Author)), (false, false, None));
630+ assert_eq!(standing(Some(&row("ignored", None)), Some(Reason::Author)), (false, true, None));
631+ assert_eq!(standing(Some(&row("subscribed", Some("comment"))), None), (true, false, Some(Reason::Comment)));
632+ assert_eq!(standing(Some(&row("subscribed", None)), None), (true, false, Some(Reason::Manual)));
633+ }
634+
635+ #[test]
636+ fn taking_part_is_owning_writing_being_assigned_or_reviewing() {
637+ let subject = InboxSubject {
638+ author: Principal { id: "usr_g1t_agent".into(), username: "g1t".into() },
639+ requested_by: Some(Principal { id: "usr_ana".into(), username: "ana".into() }),
640+ assignees: vec!["bo".into()],
641+ reviewers: vec!["cy".into()],
642+ ..InboxSubject::default()
643+ };
644+ assert_eq!(implicit(&subject, "ANA"), Some(Reason::Author));
645+ assert_eq!(implicit(&subject, "bo"), Some(Reason::Assign));
646+ assert_eq!(implicit(&subject, "cy"), Some(Reason::ReviewRequested));
647+ assert_eq!(implicit(&subject, "dee"), None);
648+ }
649+
650+ #[test]
651+ fn an_audience_knows_who_ignores_unsubscribed_and_watches() {
652+ let audience = Audience {
653+ subscriptions: vec![
654+ Subscription { username: "ana".into(), state: State::Ignored, reason: None },
655+ Subscription { username: "bo".into(), state: State::Unsubscribed, reason: None },
656+ Subscription { username: "cy".into(), state: State::Subscribed, reason: Some(Reason::Mention) },
657+ Subscription { username: "dee".into(), state: State::Subscribed, reason: None },
658+ ],
659+ watchers: vec![
660+ Watcher { username: "eve".into(), level: WatchLevel::Ignore, events: Vec::new() },
661+ Watcher { username: "fay".into(), level: WatchLevel::Custom, events: vec!["deployments".into()] },
662+ Watcher { username: "gus".into(), level: WatchLevel::All, events: Vec::new() },
663+ ],
664+ };
665+ assert!(audience.ignores("ana") && audience.ignores("eve") && !audience.ignores("bo"));
666+ assert!(audience.unsubscribed("bo") && !audience.unsubscribed("cy"));
667+ assert_eq!(
668+ audience.subscribed().collect::<Vec<_>>(),
669+ vec![("cy".to_owned(), Reason::Mention), ("dee".to_owned(), Reason::Manual)]
670+ );
671+ assert_eq!(audience.watching("deployments").collect::<Vec<_>>(), vec!["fay", "gus"]);
672+ assert_eq!(audience.watching("issues").collect::<Vec<_>>(), vec!["gus"]);
673+ }
674+
675+ #[test]
676+ fn choices_keep_only_what_is_known() {
677+ assert_eq!(custom_events(&["Pulls".into(), "releases".into(), "issues".into(), "pulls".into()]), vec!["issues", "pulls"]);
678+ assert_eq!(email_reasons(&["mention".into(), "nope".into(), "agent".into()]), vec![Reason::Agent, Reason::Mention]);
679+ let settings = SettingsRow { username: "ana".into(), email: None, default_watch: Some("participating".into()) }.into_settings();
680+ assert_eq!(settings.email, DEFAULT_EMAIL.to_vec());
681+ assert_eq!(settings.default_watch, WatchLevel::Participating);
682+ let settings = SettingsRow { username: "ana".into(), email: Some("[]".into()), default_watch: None }.into_settings();
683+ assert!(settings.email.is_empty());
684+ assert_eq!(settings.default_watch, WatchLevel::All);
685+ }
686+}
+60−0
367367 .await
368368 }
369369
370+/// Why a person is emailed about an inbox item, as the end of a sentence.
371+fn notified_because(reason: g1t_contracts::inbox::Reason) -> &'static str {
372+ use g1t_contracts::inbox::Reason;
373+ match reason {
374+ Reason::Agent => "an agent is waiting on you",
375+ Reason::ReviewRequested => "you were asked to review",
376+ Reason::Assign => "you were assigned",
377+ Reason::Mention => "you were mentioned",
378+ Reason::CiActivity => "it is about your work",
379+ Reason::SecurityAlert => "you look after this repository's security",
380+ Reason::StateChange => "you are subscribed to it",
381+ Reason::Author => "you opened it",
382+ Reason::Comment => "you commented on it",
383+ Reason::Manual => "you subscribed to it",
384+ Reason::Subscribed => "you watch this repository",
385+ }
386+}
387+
388+/// An item from the inbox, by email: what happened, what it happened to,
389+/// and a link to it.
390+pub fn notification_letter(a: &g1t_contracts::inbox::NotifyByEmailArgs, site: &str) -> Letter {
391+ let path = if a.path.starts_with('/') { a.path.clone() } else { format!("/{}", a.path) };
392+ Letter {
393+ paragraphs: [a.subject.trim(), a.intro.trim()]
394+ .into_iter()
395+ .filter(|line| !line.is_empty())
396+ .map(str::to_owned)
397+ .collect(),
398+ quotes: a.quote.iter().cloned().collect(),
399+ action: Some(("Open on g1t".to_owned(), format!("{site}{path}"))),
400+ footer: format!(
401+ "You are getting this because {}. Choose what you are emailed for at {site}/settings/notifications.",
402+ notified_because(a.reason)
403+ ),
404+ }
405+}
406+
407+pub async fn send_notification(env: &Env, to: &str, a: &g1t_contracts::inbox::NotifyByEmailArgs) -> Result<()> {
408+ let letter = notification_letter(a, &site(env));
409+ send(env, to, &a.subject, &letter).await
410+}
411+
370412 /// The subject and first line of a repository invitation.
371413 pub fn repo_invite_wording(from: &str, repo: &str, role: &str, new_account: bool) -> (String, String) {
372414 let subject = format!("{from} invited you to {repo} on g1t");
418460 }
419461
420462 #[test]
463+ fn an_inbox_item_says_what_happened_and_why_it_was_sent() {
464+ let a = g1t_contracts::inbox::NotifyByEmailArgs {
465+ username: "ana".into(),
466+ repo_id: "rep_1".into(),
467+ subject: "bo asked you to review acme/rocket#7".into(),
468+ intro: "Add the inbox".into(),
469+ quote: None,
470+ path: "/acme/rocket/pull/7".into(),
471+ reason: g1t_contracts::inbox::Reason::ReviewRequested,
472+ };
473+ let letter = notification_letter(&a, SITE);
474+ assert_eq!(letter.paragraphs, vec!["bo asked you to review acme/rocket#7", "Add the inbox"]);
475+ assert_eq!(letter.action.unwrap().1, "https://g1t.sh/acme/rocket/pull/7");
476+ assert!(letter.footer.starts_with("You are getting this because you were asked to review."));
477+ assert!(letter.footer.ends_with("https://g1t.sh/settings/notifications."));
478+ }
479+
480+ #[test]
421481 fn invites_say_who_sent_them_and_what_they_are_for() {
422482 let (subject, intro) = invite_wording(Some("ada"), None, false);
423483 assert_eq!(subject, "ada invited you to g1t");
+45−0
539539 .await
540540 }
541541
542+ /// `notify_by_email`: an inbox item, emailed to the person it is for,
543+ /// only at a confirmed address and only while they can still read the
544+ /// repository it is about. Returns whether it was sent.
545+ async fn notify_by_email(&self, a: g1t_contracts::inbox::NotifyByEmailArgs) -> Result<bool> {
546+ #[derive(Deserialize)]
547+ struct Address {
548+ email: Option<String>,
549+ }
550+ let user = self
551+ .find_user(
552+ "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?",
553+ &a.username.to_lowercase(),
554+ )
555+ .await?;
556+ let Some(user) = user.filter(|user| user.verified) else {
557+ return Ok(false);
558+ };
559+ let readable: Vec<g1t_contracts::repos::Repo> = g1t_kit::call(
560+ &self.env.service("REPOS")?,
561+ "readable",
562+ &g1t_contracts::repos::ReadableArgs {
563+ ids: vec![a.repo_id.clone()],
564+ viewer: Some(user.clone()),
565+ },
566+ )
567+ .await?;
568+ if readable.is_empty() {
569+ return Ok(false);
570+ }
571+ let address = self
572+ .db
573+ .prepare("SELECT email FROM users WHERE id = ?")
574+ .bind(&[user.id.as_str().into()])?
575+ .first::<Address>(None)
576+ .await?
577+ .and_then(|row| row.email)
578+ .filter(|email| !email.trim().is_empty());
579+ let Some(address) = address else {
580+ return Ok(false);
581+ };
582+ email::send_notification(&self.env, &address, &a).await?;
583+ Ok(true)
584+ }
585+
542586 async fn usernames(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
543587 #[derive(serde::Deserialize)]
544588 struct Named {
761805 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
762806 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
763807 "usernames" => reply(&identity.usernames(args(body)?).await?),
808+ "notify_by_email" => reply(&identity.notify_by_email(args(body)?).await?),
764809 "profile" => reply(&identity.profile(args(body)?).await?),
765810 "update_profile" => {
766811 let outcome = identity.update_profile(args(body)?).await?;