Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

3,680 linesCodeBlameRaw
1//! The inbox, kept beside the event log. See `g1t_contracts::inbox`.
2//!
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:
9//!
10//! | Event | Who | Reason | Severity |
11//! | --- | --- | --- | --- |
12//! | `pull.stalled` | the pull request's owner, and its work item's owner and assignees | agent | warning |
13//! | `pull.review_requested` | the reviewers asked, and the people each team asked tells | review_requested | warning |
14//! | `work_item.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` | watchers; it settles the failure before it | subscribed | success |
19//! | `pull.auto_merge_disabled` | whoever turned it on, unless g1t handed it to the merge queue | state_change | warning |
20//! | `pull.merged`, `pull.closed`, `work_item.closed`, `work_item.reopened` | everyone subscribed | state_change | success for a merge, else info |
21//! | `comment.created` | everyone mentioned, the people of teams mentioned, then everyone subscribed | mention, team_mention, or why they are subscribed | info (success for an approval) |
22//! | `comment.created` by a workspace's agent | the same, shown as "Margo (agent)"; a review also tells the owner, "(advisory)" | the same, or author | the same |
23//! | `commit_comment.created` | everyone mentioned, the people of teams mentioned, and the commit's author when their address is a g1t account's; one thread per commit | mention, team_mention, author | info |
24//! | `work_item.opened`, `pull.opened` | whoever was assigned, asked to review or mentioned in its description (people and teams); watchers | assign, review_requested, mention, team_mention, subscribed | info |
25//! | `secret_scanning_alert.summary`, `code_scanning_alert.summary`, `vulnerability_alert.summary` | the workspace's owners and security managers, whoever pushed a refused push, and watchers who chose security and may see findings; one thread per repository and alert type | security_alert | error for critical or high (told at once), info for the rest (a digest, unless the person chose at once) |
26//! | `security_advisory.reported` | the workspace's owners and security managers | security_alert | warning |
27//! | `secret_scanning.bypass_requested`, `.bypass_reviewed` | the reviewers named; the requester | security_alert | warning; info |
28//!
29//! What leaves nothing for a person to do settles their item about it
30//! (marks it done): see "Settling" below for which event settles what.
31//! A merge or a close reaches each thread's history but settles it at
32//! once, so it is neither left unread nor pushed.
33//!
34//! Watchers of a repository at `all` (or `custom`, for the kinds they
35//! chose) hear of every work item and pull request opened, commented on,
36//! closed, reopened or merged, and of deployments. Anyone who ignores the
37//! thread or the repository hears of nothing on it; anyone who
38//! unsubscribed hears only of what is asked of them.
39//!
40//! A work item belongs to a workspace and is one thread (`item:<id>`),
41//! however many repositories it is linked to. Watchers of each of them
42//! hear of it; whoever ignores its origin hears of nothing on it. Its
43//! items are kept with its workspace and its origin, if it has one, and
44//! are shown to the workspace's members and to whoever can read the
45//! origin. Those who hear of it only by watching another repository it is
46//! linked to are not pushed it live: whether they may see it is known only
47//! when their inbox is read.
48//!
49//! Nobody is told of what they did themselves, though an outcome they set
50//! off (checks, a workflow, a deployment, g1t's work) is theirs to hear of.
51//! g1t is never told. A failure here is logged and the batch goes on: the
52//! bus never waits on the inbox, so an item can be missed, but nothing else
53//! is held up.
54
55use std::collections::{HashMap, HashSet};
56
57use g1t_contracts::credentials::Principal;
58use g1t_contracts::events::{Event, WorkspaceRenamed};
59use g1t_contracts::identity::{AGENT_ID, MembersAmongArgs, UsernamesArgs};
60use g1t_contracts::inbox::*;
61use g1t_contracts::repos::{PathByIdArgs, ReadableArgs, Repo, RepoPath};
62use g1t_contracts::security::SeverityCounts;
63use g1t_contracts::security_suite::{AlertType, SecuritySummary};
64use g1t_contracts::time::rfc3339;
65use g1t_contracts::{new_id, system};
66use g1t_kit::now_ms;
67use serde::Deserialize;
68use worker::wasm_bindgen::JsValue;
69use worker::{D1Database, D1PreparedStatement, Fetcher, Result};
70
71use crate::subscriptions::{self, Audience};
72
73/// Items marked done are kept this long, then removed.
74pub const DONE_DAYS: u32 = 30;
75/// No item is kept longer than this, unless it was saved.
76pub const MAX_DAYS: u32 = 180;
77/// Unread warnings shown ahead of everything else on the first page.
78const MAX_RANKED: u32 = 20;
79const MAX_TITLE: usize = 200;
80const MAX_BODY: usize = 300;
81
82/// What the inbox asks about an event before deciding who is told.
83#[derive(Debug, Default, PartialEq, Eq)]
84pub struct Wanted {
85 /// The repository; for a work item, its origin, if it has one.
86 pub repo_id: Option<String>,
87 /// The work item or pull request, when the event names one.
88 pub number: Option<u32>,
89 pub comment_id: Option<String>,
90 /// When the event is about a work item.
91 pub item: Option<WantedItem>,
92}
93
94/// The work item an event is about.
95#[derive(Debug, Default, PartialEq, Eq)]
96pub struct WantedItem {
97 pub id: String,
98 /// The workspace's slug.
99 pub workspace: String,
100 /// Every repository it is linked to, its origin first.
101 pub repo_ids: Vec<String>,
102}
103
104impl Wanted {
105 /// The repository's id, or empty for a work item without an origin.
106 fn repo(&self) -> &str {
107 self.repo_id.as_deref().unwrap_or_default()
108 }
109}
110
111/// One person to tell, and what.
112#[derive(Debug, PartialEq, Eq)]
113pub struct Notice {
114 pub username: String,
115 pub reason: Reason,
116 pub severity: Severity,
117 pub title: String,
118 pub body: String,
119}
120
121/// Who did it, as far as is known.
122#[derive(Debug, Default)]
123pub struct Actor {
124 pub id: Option<String>,
125 pub username: Option<String>,
126}
127
128impl Actor {
129 fn is(&self, person: &Principal) -> bool {
130 self.id.as_deref().is_some_and(|id| id == person.id) || self.is_named(&person.username)
131 }
132
133 fn is_named(&self, username: &str) -> bool {
134 self.username.as_deref().is_some_and(|name| name.eq_ignore_ascii_case(username))
135 }
136
137 /// A person's name, for a title: never g1t's ids.
138 fn name(&self) -> Option<&str> {
139 self.username.as_deref().filter(|name| !is_g1t(name))
140 }
141}
142
143pub(crate) fn is_g1t(username: &str) -> bool {
144 username.eq_ignore_ascii_case(system::USERNAME) || username.eq_ignore_ascii_case("g1t-agent")
145}
146
147pub(crate) fn is_g1t_id(id: &str) -> bool {
148 system::is_system_id(id) || id == AGENT_ID
149}
150
151/// Events that end what an agent was waiting on a person for: it picked
152/// back up, its head moved, a merge was asked for, or it is over.
153const RESUMES: [&str; 5] = ["pull.resumed", "pull.updated", "pull.merge_requested", "pull.merged", "pull.closed"];
154
155/// The work item an event's data names (`itemId`, with its workspace and
156/// linked repositories), if any.
157fn item_of(data: &serde_json::Value) -> Option<WantedItem> {
158 let id = data["itemId"].as_str().filter(|id| !id.is_empty())?;
159 Some(WantedItem {
160 id: id.to_owned(),
161 workspace: data["workspace"].as_str().unwrap_or_default().to_lowercase(),
162 repo_ids: names(data, "repoIds"),
163 })
164}
165
166/// The work item events the inbox tells people of.
167const ITEM_TOLD: [&str; 4] = ["work_item.opened", "work_item.closed", "work_item.reopened", "work_item.assigned"];
168
169/// The work item or pull request an event names, and what to read for it.
170/// None for events the inbox does not tell anyone of.
171pub fn wants(event: &Event) -> Option<Wanted> {
172 let data = &event.data;
173 let text = |key: &str| data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
174 let number = |key: &str| data[key].as_u64().and_then(|n| u32::try_from(n).ok());
175 let repo_id = text("repoId").or_else(|| event.repo_id.clone());
176 // A work item's events, and comments on one: it may have no origin.
177 let on_item = event.kind == "comment.created" && data["itemId"].is_string();
178 if ITEM_TOLD.contains(&event.kind.as_str()) || on_item {
179 return Some(Wanted {
180 repo_id,
181 number: Some(number("number")?),
182 comment_id: if on_item { Some(text("commentId")?) } else { None },
183 item: Some(item_of(data)?),
184 });
185 }
186 let repo_id = repo_id?;
187 let on = |number: Option<u32>, comment_id: Option<String>| {
188 Some(Wanted {
189 repo_id: Some(repo_id.clone()),
190 number,
191 comment_id,
192 item: None,
193 })
194 };
195 match event.kind.as_str() {
196 "pull.stalled" | "pull.merged" | "pull.closed" | "pull.opened" | "pull.auto_merge_disabled"
197 | "pull.review_requested" | "pull.assigned" => on(Some(number("number")?), None),
198 "checks.completed" => match data["status"].as_str() {
199 Some("failed" | "errored") => on(Some(number("number")?), None),
200 _ => None,
201 },
202 "workflow.completed" => match data["conclusion"].as_str() {
203 Some("failure") => on(number("pull"), None),
204 _ => None,
205 },
206 "deployment.failed" | "deployment.succeeded" => on(number("number"), None),
207 // A workflow run's jobs wait for their environment's reviewers.
208 "deployment.review_requested" => on(None, None),
209 "comment.created" => on(Some(number("number")?), Some(text("commentId")?)),
210 // A commit is a thread of its own, its link the commit's page.
211 "commit_comment.created" => on(None, None),
212 // Security alerts are threads of their own, not of a work item.
213 kind if SECURITY_EVENTS.contains(&kind) => on(None, None),
214 _ => None,
215 }
216}
217
218/// The security service's events the inbox tells people of: each scan's
219/// summary of what it opened and fixed (one thread per repository and
220/// alert type, brought up to date by the next scan; never one item per
221/// alert), push protection bypasses asked for and decided, vulnerability
222/// reports, and advisories published. The per-alert `.created`, `.fixed`
223/// and `.dismissed` events are for webhooks.
224pub const SECURITY_EVENTS: [&str; 7] = [
225 "secret_scanning_alert.summary",
226 "code_scanning_alert.summary",
227 "vulnerability_alert.summary",
228 "secret_scanning.bypass_requested",
229 "secret_scanning.bypass_reviewed",
230 "security_advisory.reported",
231 "security_advisory.published",
232];
233
234/// Where an event's items go: which thread, what it is, and where it is.
235#[derive(Clone, Debug, PartialEq, Eq)]
236pub struct Thread {
237 pub key: String,
238 pub kind: Option<SubjectKind>,
239 pub number: Option<u32>,
240 pub run_id: Option<String>,
241 /// A path, for a subject with a page of its own.
242 pub link: Option<String>,
243}
244
245/// The thread key of a pull request.
246pub fn numbered_thread(repo_id: &str, number: u32) -> String {
247 format!("{repo_id}#{number}")
248}
249
250/// What a work item's thread key starts with: `item:<item_id>`.
251pub const ITEM_THREAD: &str = "item:";
252
253/// The thread key of a work item.
254pub fn item_thread(item_id: &str) -> String {
255 format!("{ITEM_THREAD}{item_id}")
256}
257
258/// Whether people subscribe to a thread: a work item's or a pull request's.
259pub fn subscribable(thread: &str) -> bool {
260 thread.starts_with(ITEM_THREAD) || thread.contains('#')
261}
262
263/// The thread of a workflow on a branch: `<repo_id>/run/<workflow>@<branch>`.
264pub fn run_thread(repo_id: &str, data: &serde_json::Value) -> String {
265 let text = |key: &str| data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
266 let branch = text("ref").unwrap_or_default();
267 let branch = branch.strip_prefix("refs/heads/").unwrap_or(&branch).to_owned();
268 let workflow = text("path").or_else(|| text("workflow")).unwrap_or_default();
269 format!("{repo_id}/run/{workflow}@{branch}")
270}
271
272/// The thread of a project's production, or of one pull request's preview:
273/// `<repo_id>/deploy/<project_id>/<production|number>`.
274pub fn deploy_thread(repo_id: &str, data: &serde_json::Value, number: Option<u32>) -> String {
275 let which = number.map_or_else(|| "production".to_owned(), |number| number.to_string());
276 format!("{repo_id}/deploy/{}/{which}", data["projectId"].as_str().unwrap_or_default())
277}
278
279/// The thread an event's items go to.
280pub fn thread_of(event: &Event, wanted: &Wanted, subject: Option<&InboxSubject>) -> Thread {
281 let data = &event.data;
282 let text = |key: &str| data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
283 match event.kind.as_str() {
284 // A project's production, or one pull request's preview.
285 "deployment.failed" | "deployment.succeeded" => {
286 Thread {
287 key: deploy_thread(wanted.repo(), data, wanted.number),
288 kind: Some(SubjectKind::Deploy),
289 number: wanted.number,
290 run_id: text("deploymentId"),
291 link: text("path"),
292 }
293 }
294 // One run's deployment to one environment, waiting for review.
295 "deployment.review_requested" => Thread {
296 key: format!(
297 "{}/review/{}/{}",
298 wanted.repo(),
299 text("runId").unwrap_or_default(),
300 text("environment").unwrap_or_default()
301 ),
302 kind: Some(SubjectKind::Run),
303 number: None,
304 run_id: text("runId"),
305 link: text("link"),
306 },
307 // A workflow on a branch: its next failure bumps the same thread.
308 "workflow.completed" if subject.is_none() => {
309 Thread {
310 key: run_thread(wanted.repo(), data),
311 kind: Some(SubjectKind::Run),
312 number: None,
313 run_id: text("runId"),
314 link: None,
315 }
316 }
317 // One commit's comments (work/src/commit_comments.rs).
318 "commit_comment.created" => Thread {
319 key: format!("{}/commit/{}", wanted.repo(), text("commit").unwrap_or_default()),
320 kind: None,
321 number: None,
322 run_id: None,
323 link: text("link"),
324 },
325 // A repository's alerts of one type (`rep_1/security/vulnerability`),
326 // its reports, one advisory, or one bypass request: the page is the link.
327 kind if SECURITY_EVENTS.contains(&kind) => {
328 let which = match kind {
329 "security_advisory.reported" => "reports".to_owned(),
330 "security_advisory.published" => format!("advisories/{}", text("id").unwrap_or_default()),
331 _ if kind.ends_with(".summary") => text("alertType").unwrap_or_else(|| "alerts".to_owned()),
332 _ => text("requestId").or_else(|| text("alertId")).unwrap_or_else(|| event.id.clone()),
333 };
334 Thread {
335 key: format!("{}/security/{which}", wanted.repo()),
336 kind: None,
337 number: None,
338 run_id: None,
339 link: text("link"),
340 }
341 }
342 // A work item: one thread, wherever it is linked.
343 _ if wanted.item.is_some() => Thread {
344 key: item_thread(wanted.item.as_ref().map_or("", |item| item.id.as_str())),
345 kind: Some(SubjectKind::Item),
346 number: wanted.number,
347 run_id: None,
348 link: None,
349 },
350 _ => match wanted.number {
351 Some(number) => Thread {
352 key: numbered_thread(wanted.repo(), number),
353 kind: subject.and_then(|subject| subject.kind),
354 number: Some(number),
355 run_id: None,
356 link: None,
357 },
358 None => Thread {
359 key: format!("event/{}", event.id),
360 kind: None,
361 number: None,
362 run_id: None,
363 link: None,
364 },
365 },
366 }
367}
368
369// --- Settling ----------------------------------------------------------------
370//
371// An item is settled (marked done, and read) by the event that leaves
372// nothing for its person to do about it, so the inbox, its count, Home's
373// Needs you and `g1t status` show only what still waits on someone. A
374// settled item stays in Done, with its history, until it is purged; new
375// activity on its thread brings it back.
376//
377// | Item | Settled by |
378// | --- | --- |
379// | g1t stopped and needs you (`agent`) | `pull.resumed`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` |
380// | Anything on a work item or pull request | `work_item.closed`, `pull.merged`, `pull.closed`, after it is told (`closes`) |
381// | Your own item on a thread | your comment or review on it (`handled_by`) |
382// | A review asked of you | `pull.review_request_removed` naming you or your team |
383// | Checks failed on a pull request (`ci_activity`) | `checks.completed` passed on its head |
384// | A workflow failed on a branch | the next `workflow.completed` success of that workflow on that branch |
385// | A deployment failed | `deployment.succeeded` of that project's production, or that preview |
386// | A deployment waits for your review | `workflow.completed` of its run, however it ended |
387// | A push protection bypass to review | `secret_scanning.bypass_reviewed` |
388// | A token waits for your approval | `token.approval_reviewed` |
389// | A workspace invitation | `workspace_invitation.accepted`, `.declined`, `.revoked` |
390// | A scan's alerts | the next scan's summary with none open (`refresh_security_thread`) |
391
392/// Which threads a settle reaches.
393#[derive(Clone, Debug, PartialEq, Eq)]
394pub enum Scope {
395 /// One thread.
396 Thread(String),
397 /// Every thread matching a GLOB pattern, such as one pull request's
398 /// previews in every project: `rep_1/deploy/*/7`.
399 Matching(String),
400}
401
402/// Items an event settles.
403#[derive(Clone, Debug, PartialEq, Eq)]
404pub struct Settle {
405 pub scope: Scope,
406 /// One person's item, or everyone's.
407 pub username: Option<String>,
408 /// Only items whose latest activity was for one of these reasons; any when empty.
409 pub reasons: Vec<Reason>,
410 /// Only items whose latest activity was one of these events; any when empty.
411 pub events: Vec<&'static str>,
412 /// Why, in a few words (`settled_reason`): "CI passed in run 330", "Merged".
413 pub why: Option<String>,
414 /// Who settled it, when a person or g1t did (`settled_by`): a username, or g1t.
415 pub by: Option<String>,
416}
417
418/// An item a settle reached: whose, and its thread.
419#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize)]
420pub struct Settled {
421 pub username: String,
422 pub thread: String,
423}
424
425impl Settle {
426 fn thread(thread: impl Into<String>) -> Self {
427 Settle {
428 scope: Scope::Thread(thread.into()),
429 username: None,
430 reasons: Vec::new(),
431 events: Vec::new(),
432 why: None,
433 by: None,
434 }
435 }
436
437 fn matching(pattern: impl Into<String>) -> Self {
438 Settle {
439 scope: Scope::Matching(pattern.into()),
440 ..Settle::thread("")
441 }
442 }
443
444 fn because(mut self, reasons: &[Reason]) -> Self {
445 self.reasons = reasons.to_vec();
446 self
447 }
448
449 fn after(mut self, events: &[&'static str]) -> Self {
450 self.events = events.to_vec();
451 self
452 }
453
454 fn of(mut self, username: &str) -> Self {
455 self.username = Some(username.trim().trim_start_matches('@').to_lowercase());
456 self
457 }
458
459 /// Says why it settled, and who settled it when someone did.
460 fn saying(mut self, why: impl Into<String>, by: Option<&str>) -> Self {
461 self.why = Some(why.into());
462 self.by = by.map(|name| name.trim().trim_start_matches('@').to_lowercase()).filter(|name| !name.is_empty());
463 self
464 }
465
466 /// The statement that settles the items, as of `now`, naming whose it
467 /// settled and their threads.
468 fn statement(&self, db: &D1Database, now: &str) -> Result<D1PreparedStatement> {
469 let mut values: Vec<JsValue> = vec![
470 now.into(),
471 self.why.as_deref().map_or(JsValue::NULL, JsValue::from),
472 self.by.as_deref().map_or(JsValue::NULL, JsValue::from),
473 ];
474 let mut conditions = vec!["done_at IS NULL".to_owned()];
475 let bind = |values: &mut Vec<JsValue>, value: &str| {
476 values.push(value.into());
477 format!("?{}", values.len())
478 };
479 match &self.scope {
480 Scope::Thread(thread) => conditions.push(format!("thread = {}", bind(&mut values, thread))),
481 Scope::Matching(pattern) => conditions.push(format!("thread GLOB {}", bind(&mut values, pattern))),
482 }
483 if let Some(username) = &self.username {
484 conditions.push(format!("username = {}", bind(&mut values, username)));
485 }
486 if !self.reasons.is_empty() {
487 let marks: Vec<String> = self.reasons.iter().map(|reason| bind(&mut values, reason.as_str())).collect();
488 conditions.push(format!("reason IN ({})", marks.join(", ")));
489 }
490 if !self.events.is_empty() {
491 let marks: Vec<String> = self.events.iter().map(|kind| bind(&mut values, kind)).collect();
492 conditions.push(format!("event IN ({})", marks.join(", ")));
493 }
494 db.prepare(format!(
495 "UPDATE inbox_items SET done_at = ?1, read_at = COALESCE(read_at, ?1), settled_reason = ?2, settled_by = ?3
496 WHERE {} RETURNING username, thread",
497 conditions.join(" AND ")
498 ))
499 .bind(&values)
500 }
501}
502
503/// The work item or pull request an event names, as its thread.
504fn numbered(event: &Event) -> Option<String> {
505 if let Some(item) = item_of(&event.data) {
506 return Some(item_thread(&item.id));
507 }
508 let repo_id = event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone())?;
509 let number = event.data["number"].as_u64().and_then(|n| u32::try_from(n).ok())?;
510 Some(numbered_thread(&repo_id, number))
511}
512
513/// Why an agent's wait is over, by the event that ended it (`RESUMES`).
514fn resumed_because(kind: &str) -> &'static str {
515 match kind {
516 "pull.resumed" => "g1t went on",
517 "pull.updated" => "New commits were pushed",
518 "pull.merge_requested" => "Merge was asked for",
519 "pull.merged" => "Merged",
520 _ => "Closed",
521 }
522}
523
524/// What an event settles before its own notices are written: what it
525/// supersedes. A success after a failure, a review given or no longer
526/// asked for, a request answered. `actor` is who caused it, by username
527/// (g1t for g1t), when known: each settle says why, and who did.
528pub fn settles(event: &Event, actor: Option<&str>) -> Vec<Settle> {
529 let data = &event.data;
530 let text = |key: &str| data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
531 let repo_id = text("repoId").or_else(|| event.repo_id.clone());
532 let mut settled = Vec::new();
533 let kind = event.kind.as_str();
534 // What an agent was waiting on a person for is over.
535 if RESUMES.contains(&kind)
536 && let Some(thread) = numbered(event)
537 {
538 settled.push(Settle::thread(thread).because(&[Reason::Agent]).saying(resumed_because(kind), actor));
539 }
540 match kind {
541 // Reviews no longer asked for, of the people or the teams named.
542 "pull.review_request_removed" => {
543 if let Some(thread) = numbered(event) {
544 let people = names(data, "reviewers").into_iter().chain(teams_asked(data).into_iter().flat_map(|(_, people)| people));
545 for name in people.collect::<HashSet<_>>() {
546 settled.push(
547 Settle::thread(thread.clone())
548 .because(&[Reason::ReviewRequested])
549 .of(&name)
550 .saying("Review no longer asked of you", actor),
551 );
552 }
553 }
554 }
555 // A workspace invitation answered or revoked no longer waits on its person.
556 _ if INVITATION_ANSWERED.contains(&kind) => {
557 if let Some(thread) = text("thread").filter(|thread| thread.starts_with("invitation:")) {
558 let why = match kind {
559 "workspace_invitation.accepted" => "Invitation accepted",
560 "workspace_invitation.declined" => "Invitation declined",
561 _ => "Invitation revoked",
562 };
563 settled.push(Settle::thread(thread).because(&[Reason::ReviewRequested]).saying(why, actor));
564 }
565 }
566 // A token approved, denied or revoked no longer waits on the owners.
567 "token.approval_reviewed" => {
568 let (thread, _) = workspace_notices(event, None);
569 settled.push(Settle::thread(thread).because(&[Reason::ReviewRequested]).saying("Token request decided", actor));
570 }
571 // Every check on its head passes now (or none fails): the failure
572 // its owner was told of is behind them.
573 "checks.completed" if data["status"].as_str() == Some("passed") => {
574 if let Some(thread) = numbered(event) {
575 let why = match text("commit") {
576 Some(commit) => format!("Checks passed on {}", short(&commit)),
577 None => "Checks passed".to_owned(),
578 };
579 settled.push(Settle::thread(thread).because(&[Reason::CiActivity]).saying(why, None));
580 }
581 }
582 "workflow.completed" => {
583 if let Some(repo_id) = &repo_id {
584 let workflow = text("workflow").unwrap_or_else(|| "The workflow".to_owned());
585 let run = data["number"].as_u64().map(|n| format!(" in run {n}")).unwrap_or_default();
586 // The workflow passes on the branch again: its failures there are over.
587 if data["conclusion"].as_str() == Some("success") && data["pull"].as_u64().is_none() {
588 settled.push(Settle::thread(run_thread(repo_id, data)).saying(format!("{workflow} passed{run}"), None));
589 }
590 // Its run is over, however it ended: no deployment of it waits for review.
591 if let Some(id) = text("runId") {
592 settled.push(
593 Settle::matching(format!("{repo_id}/review/{id}/*"))
594 .because(&[Reason::ReviewRequested])
595 .saying(format!("{workflow} finished{run}"), None),
596 );
597 }
598 }
599 }
600 // It deployed: the failure before it is over.
601 "deployment.succeeded" => {
602 if let Some(repo_id) = &repo_id {
603 let number = data["number"].as_u64().and_then(|n| u32::try_from(n).ok());
604 let what = match number {
605 Some(n) => format!("Preview of #{n}"),
606 None => text("project").map_or_else(|| "Production".to_owned(), |project| format!("Production of {project}")),
607 };
608 let commit = text("commit").map(|commit| format!(" at {}", short(&commit))).unwrap_or_default();
609 let by = actor.map(str::to_owned).or_else(|| text("triggeredBy"));
610 settled.push(
611 Settle::thread(deploy_thread(repo_id, data, number))
612 .after(&["deployment.failed"])
613 .saying(format!("{what} deployed{commit}"), by.as_deref()),
614 );
615 }
616 }
617 // A bypass decided no longer waits on its reviewers.
618 "secret_scanning.bypass_reviewed" => {
619 if let (Some(repo_id), Some(which)) = (&repo_id, text("requestId").or_else(|| text("alertId"))) {
620 settled.push(
621 Settle::thread(format!("{repo_id}/security/{which}"))
622 .because(&[Reason::SecurityAlert])
623 .saying("Bypass request decided", actor),
624 );
625 }
626 }
627 _ => {}
628 }
629 settled
630}
631
632/// The events after which nothing is left to do about a work item or pull
633/// request: every item about it is settled once its own notices are
634/// written, so they reach each thread's history but nobody's inbox, and
635/// are not pushed. What landed shows on Home instead.
636pub const CLOSES: [&str; 3] = ["pull.merged", "pull.closed", "work_item.closed"];
637
638/// What an event settles once its own notices are written: everything
639/// about a work item or pull request that is over, a pull request's
640/// previews with it. `actor` is who merged or closed it, by username.
641pub fn closes(event: &Event, actor: Option<&str>) -> Vec<Settle> {
642 if !CLOSES.contains(&event.kind.as_str()) {
643 return Vec::new();
644 }
645 let Some(thread) = numbered(event) else {
646 return Vec::new();
647 };
648 let why = match event.kind.as_str() {
649 "pull.merged" => "Merged",
650 "work_item.closed" if event.data["reason"].as_str() == Some("not_planned") => "Closed as not planned",
651 _ => "Closed",
652 };
653 let mut settled = vec![Settle::thread(thread).saying(why, actor)];
654 if event.kind.starts_with("pull.")
655 && let (Some(repo_id), Some(number)) = (
656 event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone()),
657 event.data["number"].as_u64(),
658 )
659 {
660 settled.push(Settle::matching(format!("{repo_id}/deploy/*/{number}")).saying(why, actor));
661 }
662 settled
663}
664
665/// The item a person's own comment or review settles: theirs on that
666/// thread, which they have answered. Never an agent's, nor g1t's.
667pub fn handled_by(event: &Event, subject: Option<&InboxSubject>) -> Option<Settle> {
668 if event.kind != "comment.created" {
669 return None;
670 }
671 let comment = subject?.comment.as_ref().filter(|comment| !comment.event && comment.agent.is_none())?;
672 if is_g1t_id(&comment.author.id) || is_g1t(&comment.author.username) {
673 return None;
674 }
675 let why = if comment.verdict.is_some() { "Reviewed" } else { "Commented" };
676 Some(Settle::thread(numbered(event)?).of(&comment.author.username).saying(why, Some(&comment.author.username)))
677}
678
679/// Collects who is told, each once with the most specific reason, never
680/// the actor, never g1t, and never anyone ignoring the thread.
681struct Told<'a> {
682 actor: &'a Actor,
683 audience: &'a Audience,
684 notices: Vec<Notice>,
685}
686
687impl Told<'_> {
688 /// `asked`: something asked of the person directly, told even when
689 /// they unsubscribed from the thread.
690 fn tell(&mut self, username: &str, reason: Reason, severity: Severity, title: &str, body: &str, asked: bool) {
691 let username = username.trim().trim_start_matches('@').to_lowercase();
692 if username.is_empty()
693 || is_g1t(&username)
694 || self.actor.is_named(&username)
695 || self.audience.ignores(&username)
696 || (!asked && self.audience.unsubscribed(&username))
697 {
698 return;
699 }
700 let notice = Notice {
701 username,
702 reason,
703 severity,
704 title: clip(title, MAX_TITLE),
705 body: clip(body, MAX_BODY),
706 };
707 match self.notices.iter_mut().find(|told| told.username == notice.username) {
708 Some(told) if notice.reason.rank() < told.reason.rank() => *told = notice,
709 Some(_) => {}
710 None => self.notices.push(notice),
711 }
712 }
713
714 /// A person known by id as well as name, such as an author.
715 fn tell_person(&mut self, person: &Principal, reason: Reason, severity: Severity, title: &str, body: &str, asked: bool) {
716 if is_g1t_id(&person.id) || self.actor.is(person) {
717 return;
718 }
719 self.tell(&person.username, reason, severity, title, body, asked);
720 }
721
722 /// Everyone subscribed to a work item or pull request: its owner and
723 /// author, its assignees and reviewers, and whoever subscribed by
724 /// commenting, being mentioned or by hand. `reason` overrides why
725 /// each is told, as a state change does.
726 fn tell_subscribed(&mut self, subject: &InboxSubject, reason: Option<Reason>, severity: Severity, title: &str, body: &str) {
727 let why = |own: Reason| reason.unwrap_or(own);
728 self.tell_person(subject.owner(), why(Reason::Author), severity, title, body, false);
729 self.tell_person(&subject.author, why(Reason::Author), severity, title, body, false);
730 for name in &subject.assignees {
731 self.tell(name, why(Reason::Assign), severity, title, body, false);
732 }
733 for name in &subject.reviewers {
734 self.tell(name, why(Reason::ReviewRequested), severity, title, body, false);
735 }
736 let subscribed: Vec<(String, Reason)> = self.audience.subscribed().collect();
737 for (name, own) in subscribed {
738 self.tell(&name, why(own), severity, title, body, false);
739 }
740 }
741
742 /// Everyone watching the repository for this kind of activity.
743 fn tell_watchers(&mut self, kind: &str, severity: Severity, title: &str, body: &str) {
744 let watching: Vec<String> = self.audience.watching(kind).collect();
745 for name in watching {
746 self.tell(&name, Reason::Subscribed, severity, title, body, false);
747 }
748 }
749}
750
751fn clip(text: &str, max: usize) -> String {
752 let text = text.trim();
753 if text.chars().count() <= max {
754 return text.to_owned();
755 }
756 let cut: String = text.chars().take(max - 1).collect();
757 format!("{}…", cut.trim_end())
758}
759
760/// The kind of activity a watcher chooses: `work_items` or `pulls`.
761fn activity_of(subject: &InboxSubject) -> &'static str {
762 match subject.kind {
763 Some(SubjectKind::Pull) => "pulls",
764 _ => "work_items",
765 }
766}
767
768/// The names in an event's list field, such as the reviewers just asked.
769/// The teams an event asked to review: each `workspace/slug` with the
770/// people it tells.
771fn teams_asked(data: &serde_json::Value) -> Vec<(String, Vec<String>)> {
772 data["teams"]
773 .as_array()
774 .map(|teams| {
775 teams
776 .iter()
777 .filter_map(|team| Some((team["team"].as_str()?.to_owned(), names(team, "notified"))))
778 .collect()
779 })
780 .unwrap_or_default()
781}
782
783fn names(data: &serde_json::Value, key: &str) -> Vec<String> {
784 data[key]
785 .as_array()
786 .map(|names| names.iter().filter_map(|name| name.as_str().map(str::to_owned)).collect())
787 .unwrap_or_default()
788}
789
790/// Who is told of `event`, in `repo` (`owner/name`, or for a work item its
791/// workspace), given what it names and who follows it. `actor` is who
792/// caused it; outcomes nobody chose (checks, workflows, deployments, g1t
793/// stopping) are told whoever caused them.
794pub fn notices(event: &Event, repo: &str, actor: &Actor, subject: Option<&InboxSubject>, audience: &Audience) -> Vec<Notice> {
795 let nobody = Actor::default();
796 let data = &event.data;
797 // The work item or pull request, as titles name it: `acme/rocket#12`,
798 // or `acme#12` for a work item.
799 let at = match data["number"].as_u64().or(data["pull"].as_u64()) {
800 Some(number) if subject.is_some() => format!("{repo}#{number}"),
801 _ => repo.to_owned(),
802 };
803 let outcome = matches!(
804 event.kind.as_str(),
805 "checks.completed"
806 | "workflow.completed"
807 | "pull.stalled"
808 | "deployment.failed"
809 | "deployment.succeeded"
810 | "deployment.review_requested"
811 ) || SECURITY_EVENTS.contains(&event.kind.as_str());
812 let mut told = Told {
813 actor: if outcome { &nobody } else { actor },
814 audience,
815 notices: Vec::new(),
816 };
817 // Who did it, as titles name them.
818 let who = actor.name().unwrap_or("g1t");
819
820 match (event.kind.as_str(), subject) {
821 ("pull.stalled", Some(pull)) => {
822 let detail = data["detail"].as_str().map(str::trim).filter(|detail| !detail.is_empty());
823 let (title, body) = (format!("g1t stopped on {at} and needs you"), detail.unwrap_or(&pull.title).to_owned());
824 told.tell_person(pull.owner(), Reason::Agent, Severity::Warning, &title, &body, true);
825 if let Some(item) = &pull.item {
826 told.tell_person(item.owner(), Reason::Agent, Severity::Warning, &title, &body, true);
827 for name in &item.assignees {
828 told.tell(name, Reason::Agent, Severity::Warning, &title, &body, true);
829 }
830 }
831 }
832 // Whoever turned auto-merge on hears that it is off, unless they
833 // turned it off themselves: it will not merge without them now.
834 // Not when g1t handed it to the merge queue, which lands it: there
835 // is nothing for them to do.
836 ("pull.auto_merge_disabled", Some(pull)) => {
837 if data["queued"].as_bool() == Some(true) {
838 return told.notices;
839 }
840 if let Some(by) = data["autoMergeBy"].as_str() {
841 let detail = data["detail"].as_str().map(str::trim).filter(|detail| !detail.is_empty());
842 let title = format!("Auto-merge was turned off on {at}");
843 told.tell(by, Reason::StateChange, Severity::Warning, &title, detail.unwrap_or(&pull.title), true);
844 }
845 }
846 ("pull.review_requested", Some(pull)) => {
847 // Asked because the CODEOWNERS file says they own what changed.
848 let owned = data["codeOwners"].as_bool() == Some(true);
849 let title = if owned {
850 format!("{at} changes files you own")
851 } else {
852 format!("{who} asked you to review {at}")
853 };
854 for name in names(data, "reviewers") {
855 told.tell(&name, Reason::ReviewRequested, Severity::Warning, &title, &pull.title, true);
856 }
857 for (team, people) in teams_asked(data) {
858 let title = if owned {
859 format!("{at} changes files @{team} owns")
860 } else {
861 format!("{who} asked @{team} to review {at}")
862 };
863 for name in people {
864 told.tell(&name, Reason::ReviewRequested, Severity::Warning, &title, &pull.title, true);
865 }
866 }
867 }
868 ("work_item.assigned" | "pull.assigned", Some(on)) => {
869 let title = format!("{who} assigned you to {at}");
870 for name in names(data, "added") {
871 told.tell(&name, Reason::Assign, Severity::Info, &title, &on.title, true);
872 }
873 }
874 ("work_item.opened" | "pull.opened", Some(on)) => {
875 let title = format!("{who} opened {at}");
876 for name in &on.assignees {
877 told.tell(name, Reason::Assign, Severity::Info, &format!("{who} assigned you to {at}"), &on.title, true);
878 }
879 for name in &on.reviewers {
880 told.tell(name, Reason::ReviewRequested, Severity::Warning, &format!("{who} asked you to review {at}"), &on.title, true);
881 }
882 for name in &on.mentions {
883 told.tell(name, Reason::Mention, Severity::Info, &format!("{who} mentioned you on {at}"), &on.title, true);
884 }
885 for team in &on.team_mentions {
886 let title = format!("{who} mentioned @{} on {at}", team.team);
887 for name in &team.members {
888 told.tell(name, Reason::TeamMention, Severity::Info, &title, &on.title, true);
889 }
890 }
891 told.tell_watchers(activity_of(on), Severity::Info, &title, &on.title);
892 }
893 ("checks.completed", Some(pull)) => {
894 let title = match data["status"].as_str() {
895 Some("errored") => format!("Checks could not run on {at}"),
896 _ => format!("Checks failed on {at}"),
897 };
898 told.tell_person(pull.owner(), Reason::CiActivity, Severity::Error, &title, &pull.title, true);
899 }
900 ("workflow.completed", subject) => {
901 let workflow = data["workflow"].as_str().filter(|name| !name.is_empty()).unwrap_or("A workflow");
902 match subject {
903 Some(pull) => {
904 let title = format!("{workflow} failed on {at}");
905 told.tell_person(pull.owner(), Reason::CiActivity, Severity::Error, &title, &pull.title, true);
906 }
907 None => {
908 // Not on a pull request: whoever pushed the commit it ran on.
909 let branch = data["ref"].as_str().unwrap_or_default();
910 let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch);
911 let title = format!("{workflow} failed on {branch} in {repo}");
912 let body = format!("Run {} at {}", data["number"], short(data["sha"].as_str().unwrap_or_default()));
913 if let Some(id) = &actor.id
914 && !is_g1t_id(id)
915 && let Some(name) = &actor.username
916 {
917 told.tell(name, Reason::CiActivity, Severity::Error, &title, &body, true);
918 }
919 }
920 }
921 }
922 ("deployment.failed" | "deployment.succeeded", subject) => {
923 let failed = event.kind == "deployment.failed";
924 let project = data["project"].as_str().filter(|name| !name.is_empty()).unwrap_or(repo);
925 let what = match subject {
926 Some(_) => format!("The preview of {at}"),
927 None => format!("Production of {project}"),
928 };
929 let (title, severity) = if failed {
930 (format!("{what} failed to deploy"), Severity::Error)
931 } else {
932 (format!("{what} is live"), Severity::Success)
933 };
934 let body = match (failed, data["error"].as_str().map(str::trim).filter(|error| !error.is_empty())) {
935 (true, Some(error)) => error.to_owned(),
936 _ => match subject {
937 Some(pull) => pull.title.clone(),
938 None => format!("Commit {}", short(data["commit"].as_str().unwrap_or_default())),
939 },
940 };
941 // A failure is whoever answers for it's to fix. A success after
942 // one tells them nothing: it settles the failure's item instead
943 // (`settles`), so there is nothing left in their inbox about it.
944 if failed {
945 match subject {
946 Some(pull) => told.tell_person(pull.owner(), Reason::CiActivity, severity, &title, &body, true),
947 None => {
948 let pusher = actor
949 .id
950 .as_deref()
951 .filter(|id| !is_g1t_id(id))
952 .and(actor.username.as_deref())
953 .or_else(|| data["triggeredBy"].as_str());
954 if let Some(name) = pusher {
955 told.tell(name, Reason::CiActivity, severity, &title, &body, true);
956 }
957 }
958 }
959 }
960 told.tell_watchers("deployments", severity, &title, &body);
961 }
962 ("pull.merged" | "pull.closed" | "work_item.closed" | "work_item.reopened", Some(on)) => {
963 let (verb, severity) = match event.kind.as_str() {
964 "pull.merged" => ("merged", Severity::Success),
965 "work_item.reopened" => ("reopened", Severity::Info),
966 _ => ("closed", Severity::Info),
967 };
968 let title = match (actor.name(), landed_by(data)) {
969 (_, Some(what)) if event.kind == "work_item.closed" => format!("{at} was closed by {what}"),
970 (Some(name), _) => format!("{name} {verb} {at}"),
971 (None, _) => format!("{at} was {verb}"),
972 };
973 told.tell_subscribed(on, Some(Reason::StateChange), severity, &title, &on.title);
974 told.tell_watchers(activity_of(on), severity, &title, &on.title);
975 }
976 ("deployment.review_requested", _) => {
977 let environment = data["environment"].as_str().unwrap_or("an environment");
978 let workflow = data["workflow"].as_str().unwrap_or("A workflow");
979 let title = format!("{workflow} is waiting for your review to deploy to {environment} in {repo}");
980 let body = data["title"].as_str().unwrap_or_default();
981 // Those the actions service names: the environment's reviewers.
982 for name in names(data, "notify") {
983 told.tell(&name, Reason::ReviewRequested, Severity::Warning, &title, body, true);
984 }
985 }
986 (kind, None) if SECURITY_EVENTS.contains(&kind) => {
987 let what = data["title"].as_str().unwrap_or("A security alert");
988 match kind {
989 "secret_scanning.bypass_requested" => {
990 let title = format!("{who} asked to bypass push protection in {repo}");
991 for name in names(data, "notify") {
992 told.tell(&name, Reason::SecurityAlert, Severity::Warning, &title, what, true);
993 }
994 }
995 "secret_scanning.bypass_reviewed" => {
996 let verdict = data["state"].as_str().unwrap_or("reviewed");
997 let title = format!("Your request to bypass push protection in {repo} was {verdict}");
998 for name in names(data, "notify") {
999 told.tell(&name, Reason::SecurityAlert, Severity::Info, &title, what, true);
1000 }
1001 }
1002 // Something to act on: the owners and security managers, at once.
1003 "security_advisory.reported" => {
1004 let title = format!("A vulnerability was reported privately to {repo}");
1005 for name in names(data, "notify") {
1006 told.tell(&name, Reason::SecurityAlert, Severity::Warning, &title, what, true);
1007 }
1008 }
1009 // Published for everyone: watchers who chose security.
1010 "security_advisory.published" => {
1011 let title = format!("{repo} published a security advisory");
1012 told.tell_watchers("security", Severity::Info, &title, what);
1013 }
1014 // A scan's summary. One that opened nothing is no news: it
1015 // brings the thread up to date instead (deliver_one). Whether
1016 // what is not urgent is told now or in a digest is each
1017 // person's choice, applied there too.
1018 _ => {
1019 let Some(summary) = security_summary(event) else {
1020 return told.notices;
1021 };
1022 if summary.opened_count == 0 {
1023 return told.notices;
1024 }
1025 let severity = if summary.urgent() { Severity::Error } else { Severity::Info };
1026 let (title, pushed) = summary_titles(repo, &summary);
1027 let body = summary.latest.clone().unwrap_or_default();
1028 // Whoever pushed a refused push must act: told at once, whatever was found.
1029 if let Some(pusher) = &summary.pusher {
1030 told.tell(pusher, Reason::SecurityAlert, Severity::Error, &pushed, &body, true);
1031 }
1032 for name in &summary.notify {
1033 told.tell(name, Reason::SecurityAlert, severity, &title, &body, true);
1034 }
1035 // Watchers who chose security alerts, if they may see findings:
1036 // the service lists who may (`members`), as findings are never
1037 // shown to someone who cannot change the code.
1038 let members = names(data, "members");
1039 let watching: Vec<String> = told.audience.watching("security").collect();
1040 for name in watching.iter().filter(|name| members.iter().any(|member| member.eq_ignore_ascii_case(name))) {
1041 told.tell(name, Reason::SecurityAlert, severity, &title, &body, false);
1042 }
1043 }
1044 }
1045 }
1046 // The work service names who: the people and teams mentioned, and
1047 // the commit's author when they have an account.
1048 ("commit_comment.created", None) => {
1049 let short = data["commit"].as_str().map(short).unwrap_or_default();
1050 let at = format!("{repo}@{short}");
1051 let body = data["excerpt"].as_str().unwrap_or_default();
1052 for name in names(data, "mentions") {
1053 told.tell(&name, Reason::Mention, Severity::Info, &format!("{who} mentioned you on {at}"), body, true);
1054 }
1055 for team in data["teamMentions"].as_array().into_iter().flatten() {
1056 let title = format!("{who} mentioned @{} on {at}", team["team"].as_str().unwrap_or_default());
1057 for name in names(team, "members") {
1058 told.tell(&name, Reason::TeamMention, Severity::Info, &title, body, true);
1059 }
1060 }
1061 if let Some(author) = data["commitAuthor"].as_str() {
1062 told.tell(author, Reason::Author, Severity::Info, &format!("{who} commented on your commit {at}"), body, false);
1063 }
1064 }
1065 ("comment.created", Some(on)) => {
1066 let Some(comment) = on.comment.as_ref().filter(|comment| !comment.event) else {
1067 return Vec::new();
1068 };
1069 // Whoever wrote it is the actor, whatever the event says. One of
1070 // the workspace's agents is shown by its name, marked as an
1071 // agent; its handle is not a person's, so nobody is left out
1072 // for sharing it, and whoever it acted for hears of it as of
1073 // anything they set off.
1074 let writer = match &comment.agent {
1075 Some(agent) => Actor { id: Some(agent.id.clone()), username: None },
1076 None => Actor {
1077 id: Some(comment.author.id.clone()),
1078 username: Some(comment.author.username.clone()),
1079 },
1080 };
1081 told.actor = &writer;
1082 let who = match &comment.agent {
1083 Some(agent) => format!("{} (agent)", agent.display_name),
1084 None => comment.author.username.clone(),
1085 };
1086 let advisory = if comment.advisory { " (advisory)" } else { "" };
1087 let body = if comment.excerpt.is_empty() { &on.title } else { &comment.excerpt };
1088 for name in &comment.mentions {
1089 let title = format!("{who} mentioned you on {at}");
1090 told.tell(name, Reason::Mention, Severity::Info, &title, body, true);
1091 }
1092 for team in &comment.team_mentions {
1093 let title = format!("{who} mentioned @{} on {at}", team.team);
1094 for name in &team.members {
1095 told.tell(name, Reason::TeamMention, Severity::Info, &title, body, true);
1096 }
1097 }
1098 let (severity, title) = match comment.verdict.as_deref() {
1099 Some("approve") => (Severity::Success, format!("{who} approved {at}{advisory}")),
1100 Some("request_changes") => (Severity::Info, format!("{who} asked for changes on {at}{advisory}")),
1101 _ if comment.advisory => (Severity::Info, format!("{who} reviewed {at}{advisory}")),
1102 _ => (Severity::Info, format!("{who} commented on {at}")),
1103 };
1104 // An approval or a request for changes (or an agent's review)
1105 // is the owner's to hear of whatever they chose; the rest, as
1106 // they subscribed.
1107 if comment.verdict.is_some() || comment.advisory {
1108 told.tell_person(on.owner(), Reason::Author, severity, &title, body, true);
1109 }
1110 told.tell_subscribed(on, None, severity, &title, body);
1111 told.tell_watchers(activity_of(on), severity, &title, body);
1112 return told.notices;
1113 }
1114 _ => {}
1115 }
1116 told.notices
1117}
1118
1119/// Who an event subscribes to its work item or pull request without asking,
1120/// and why: whoever commented, whoever they mentioned, the people assigned
1121/// and the reviewers asked. Never g1t.
1122pub fn subscribes(event: &Event, subject: Option<&InboxSubject>) -> Vec<(String, Reason)> {
1123 let mut people: Vec<(String, Reason)> = Vec::new();
1124 let mut add = |name: &str, reason: Reason| {
1125 let name = name.trim().trim_start_matches('@').to_lowercase();
1126 if !name.is_empty() && !is_g1t(&name) && !people.iter().any(|(had, _)| *had == name) {
1127 people.push((name, reason));
1128 }
1129 };
1130 match event.kind.as_str() {
1131 "comment.created" => {
1132 if let Some(comment) = subject.and_then(|subject| subject.comment.as_ref()).filter(|comment| !comment.event) {
1133 // An agent is no person to subscribe; its handle may be someone's name.
1134 if !is_g1t_id(&comment.author.id) && comment.agent.is_none() {
1135 add(&comment.author.username, Reason::Comment);
1136 }
1137 for name in &comment.mentions {
1138 add(name, Reason::Mention);
1139 }
1140 for team in &comment.team_mentions {
1141 for name in &team.members {
1142 add(name, Reason::TeamMention);
1143 }
1144 }
1145 }
1146 }
1147 "work_item.opened" | "pull.opened" => {
1148 if let Some(on) = subject {
1149 for name in &on.mentions {
1150 add(name, Reason::Mention);
1151 }
1152 for team in &on.team_mentions {
1153 for name in &team.members {
1154 add(name, Reason::TeamMention);
1155 }
1156 }
1157 }
1158 }
1159 "work_item.assigned" | "pull.assigned" => {
1160 for name in names(&event.data, "added") {
1161 add(&name, Reason::Assign);
1162 }
1163 }
1164 "pull.review_requested" => {
1165 for name in names(&event.data, "reviewers") {
1166 add(&name, Reason::ReviewRequested);
1167 }
1168 for (_, people) in teams_asked(&event.data) {
1169 for name in people {
1170 add(&name, Reason::ReviewRequested);
1171 }
1172 }
1173 }
1174 _ => {}
1175 }
1176 people
1177}
1178
1179fn short(sha: &str) -> &str {
1180 sha.get(..7).unwrap_or(sha)
1181}
1182
1183/// What landed a work item, as a close says it: the pull request
1184/// (`acme/rocket#9`) or the commit (`acme/rocket@1a2b3c4`), from its
1185/// `resolution`.
1186fn landed_by(data: &serde_json::Value) -> Option<String> {
1187 let resolution = &data["resolution"];
1188 let repo = resolution["repo"].as_str().filter(|repo| !repo.is_empty())?;
1189 match (resolution["pull"].as_u64(), resolution["commit"].as_str()) {
1190 (Some(pull), _) => Some(format!("{repo}#{pull}")),
1191 (None, Some(commit)) => Some(format!("{repo}@{}", short(commit))),
1192 _ => None,
1193 }
1194}
1195
1196// --- Security summaries ------------------------------------------------------
1197
1198/// The scan summary a `<type>_alert.summary` event carries.
1199pub fn security_summary(event: &Event) -> Option<SecuritySummary> {
1200 if !event.kind.ends_with("_alert.summary") {
1201 return None;
1202 }
1203 serde_json::from_value(event.data.clone()).ok()
1204}
1205
1206/// "1 critical, 2 high": the counts that are not zero, worst first.
1207pub fn breakdown(counts: &SeverityCounts) -> String {
1208 [("critical", counts.critical), ("high", counts.high), ("medium", counts.medium), ("low", counts.low), ("unknown", counts.unknown)]
1209 .into_iter()
1210 .filter(|(_, n)| *n > 0)
1211 .map(|(name, n)| format!("{n} {name}"))
1212 .collect::<Vec<_>>()
1213 .join(", ")
1214}
1215
1216/// "vulnerability" or "vulnerabilities", as a count wants.
1217fn alerts_called(alert_type: &str, n: u32) -> &'static str {
1218 match (AlertType::parse(alert_type), n) {
1219 (Some(AlertType::SecretScanning), 1) => "secret",
1220 (Some(AlertType::SecretScanning), _) => "secrets",
1221 (Some(AlertType::CodeScanning), 1) => "code scanning alert",
1222 (Some(AlertType::CodeScanning), _) => "code scanning alerts",
1223 (Some(AlertType::Vulnerability), 1) => "vulnerability",
1224 (Some(AlertType::Vulnerability), _) => "vulnerabilities",
1225 (None, 1) => "security alert",
1226 (None, _) => "security alerts",
1227 }
1228}
1229
1230/// What a scan's item says to those who look after the repository ("3 new
1231/// vulnerabilities in acme/rocket: 1 critical, 2 high"), and to whoever
1232/// pushed a push that was refused.
1233pub fn summary_titles(repo: &str, summary: &SecuritySummary) -> (String, String) {
1234 let n = summary.opened_count;
1235 let counts = breakdown(&summary.new_by_severity);
1236 let title = if counts.is_empty() {
1237 format!("{n} new {} in {repo}", alerts_called(&summary.alert_type, n))
1238 } else {
1239 format!("{n} new {} in {repo}: {counts}", alerts_called(&summary.alert_type, n))
1240 };
1241 let pushed = match summary.blocked {
1242 0 | 1 => format!("A push to {repo} was blocked: it adds a secret"),
1243 n => format!("A push to {repo} was blocked: it adds {n} secrets"),
1244 };
1245 (title, pushed)
1246}
1247
1248/// What a scan that opened nothing makes the thread say: how many alerts
1249/// are open now ("2 open vulnerabilities in acme/rocket: 2 high"), or that
1250/// none is, with what changed as the line of history.
1251pub fn refreshed_text(repo: &str, summary: &SecuritySummary) -> (String, String) {
1252 let open = summary.open_total();
1253 let mut changes = Vec::new();
1254 if summary.fixed_count > 0 {
1255 changes.push(format!("{} fixed", summary.fixed_count));
1256 }
1257 if summary.dismissed_count > 0 {
1258 changes.push(format!("{} dismissed", summary.dismissed_count));
1259 }
1260 let title = if open == 0 {
1261 format!("No open {} in {repo}", alerts_called(&summary.alert_type, 2))
1262 } else {
1263 format!("{open} open {} in {repo}: {}", alerts_called(&summary.alert_type, open), breakdown(&summary.open))
1264 };
1265 (title, changes.join(", "))
1266}
1267
1268/// Where a scan summary's notice goes for one person.
1269#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1270pub enum Delivery {
1271 /// Their inbox, now.
1272 Now,
1273 /// Their next security digest.
1274 Digest,
1275 /// Nowhere.
1276 Never,
1277}
1278
1279/// The policy: critical and high alerts, and a refused push, reach the
1280/// people the service names (the workspace's owners and security managers,
1281/// the pusher) at once whatever they chose, and watchers at once unless
1282/// they turned security alerts off. Everything else waits for the person's
1283/// digest, or comes at once or never, as they chose.
1284pub fn security_delivery(summary: &SecuritySummary, username: &str, settings: &InboxSettings) -> Delivery {
1285 if summary.urgent() {
1286 let named = summary.notify.iter().any(|name| name.eq_ignore_ascii_case(username))
1287 || summary.pusher.as_deref().is_some_and(|pusher| pusher.eq_ignore_ascii_case(username));
1288 return if named || settings.security_alerts { Delivery::Now } else { Delivery::Never };
1289 }
1290 match settings.security_digest {
1291 DigestCadence::AtOnce => Delivery::Now,
1292 DigestCadence::Daily | DigestCadence::Weekly => Delivery::Digest,
1293 DigestCadence::Off => Delivery::Never,
1294 }
1295}
1296
1297/// Brings every person's thread about a repository's alerts of one type up
1298/// to date after a scan that opened nothing: the title says what is open
1299/// now, a line of history says what changed, and a thread with nothing
1300/// left open is done. Nobody is woken: what was read stays read, and the
1301/// thread keeps its place.
1302async fn refresh_security_thread(db: &D1Database, thread: &str, repo: &str, summary: &SecuritySummary, event: &Event) -> Result<()> {
1303 let (title, body) = refreshed_text(repo, summary);
1304 let now = now_ms();
1305 let at = rfc3339(now);
1306 let settled = i32::from(summary.open_total() == 0);
1307 db.batch(vec![
1308 db.prepare(
1309 "UPDATE inbox_items SET event_id = ?1, event = ?2, title = ?3, body = ?4, updated_at = ?5, activity = activity + 1,
1310 done_at = CASE WHEN ?6 = 1 THEN COALESCE(done_at, ?5) ELSE done_at END,
1311 read_at = CASE WHEN ?6 = 1 THEN COALESCE(read_at, ?5) ELSE read_at END,
1312 settled_reason = CASE WHEN ?6 = 1 AND done_at IS NULL THEN 'Nothing left open after the latest scan' ELSE settled_reason END,
1313 settled_by = CASE WHEN ?6 = 1 AND done_at IS NULL THEN NULL ELSE settled_by END
1314 WHERE thread = ?7
1315 AND NOT EXISTS (SELECT 1 FROM inbox_activity a WHERE a.event_id = ?1 AND a.username = inbox_items.username)",
1316 )
1317 .bind(&[
1318 event.id.as_str().into(),
1319 event.kind.as_str().into(),
1320 title.as_str().into(),
1321 body.as_str().into(),
1322 at.as_str().into(),
1323 settled.into(),
1324 thread.into(),
1325 ])?,
1326 db.prepare(
1327 "INSERT OR IGNORE INTO inbox_activity (id, item_id, username, event_id, event, reason, severity, title, body, actor, created_at)
1328 SELECT ?1 || ':' || username, id, username, ?2, ?3, 'security_alert', 'info', ?4, ?5, NULL, ?6 FROM inbox_items WHERE thread = ?7",
1329 )
1330 .bind(&[
1331 new_id("ntf", now).into(),
1332 event.id.as_str().into(),
1333 event.kind.as_str().into(),
1334 title.as_str().into(),
1335 body.as_str().into(),
1336 event.time.as_str().into(),
1337 thread.into(),
1338 ])?,
1339 ])
1340 .await?;
1341 Ok(())
1342}
1343
1344/// Where an item is on g1t.sh: a work item's in its workspace, the rest in
1345/// their repository.
1346pub fn url(
1347 repo: Option<&str>,
1348 workspace: Option<&str>,
1349 subject: Option<SubjectKind>,
1350 number: Option<u32>,
1351 run_id: Option<&str>,
1352 link: Option<&str>,
1353) -> String {
1354 if let Some(link) = link.filter(|link| link.starts_with('/')) {
1355 return link.to_owned();
1356 }
1357 if let (Some(SubjectKind::Item), Some(workspace), Some(number)) = (subject, workspace.filter(|slug| !slug.is_empty()), number) {
1358 return format!("/{workspace}/-/work/{number}");
1359 }
1360 let Some(repo) = repo else {
1361 return "/inbox".to_owned();
1362 };
1363 match (subject, number, run_id) {
1364 (Some(SubjectKind::Pull), Some(number), _) => format!("/{repo}/pull/{number}"),
1365 (Some(SubjectKind::Run), _, Some(run)) => format!("/{repo}/actions/runs/{run}"),
1366 (Some(SubjectKind::Deploy), Some(number), _) => format!("/{repo}/pull/{number}"),
1367 _ => format!("/{repo}"),
1368 }
1369}
1370
1371// --- Writing ---------------------------------------------------------------
1372
1373/// The services the inbox reads from as events arrive.
1374pub struct Sources<'a> {
1375 pub work: &'a Fetcher,
1376 pub repos: &'a Fetcher,
1377 pub identity: &'a Fetcher,
1378 /// The notify service, told of each new item for live toasts and pushes; None, nobody is.
1379 pub notify: Option<&'a Fetcher>,
1380}
1381
1382/// One notice written, and whether it was news (not a redelivery): what
1383/// may be emailed.
1384struct Written {
1385 notice: Notice,
1386 /// What its email is checked against: its repository (a work item's
1387 /// origin, which it may not have), and a work item's workspace, whose
1388 /// members may see it.
1389 repo_id: Option<String>,
1390 workspace: Option<String>,
1391 url: String,
1392 event_id: String,
1393 /// Its event leaves nothing to do (`CLOSES`): settled once written.
1394 closing: bool,
1395 /// Pushed live: not for someone who hears of a work item only by
1396 /// watching a repository it is linked to other than its origin, unless
1397 /// they are a member of its workspace, who may see it.
1398 live: bool,
1399 /// The thread it is on, which notify keeps with what it replays.
1400 thread: String,
1401}
1402
1403/// Writes the items a batch from the bus calls for. Never fails the batch:
1404/// what cannot be worked out is logged and left.
1405pub async fn deliver(db: &D1Database, sources: &Sources<'_>, events: &[Event]) {
1406 // Everyone who caused one, named in one call: notices and settles say who.
1407 let names = actor_names(sources, events).await;
1408 let actor_of = |event: &Event| -> Option<String> {
1409 match event.actor.as_deref() {
1410 Some(id) if is_g1t_id(id) => Some(system::USERNAME.to_owned()),
1411 Some(id) => names.get(id).map(|name| name.to_lowercase()),
1412 None => None,
1413 }
1414 };
1415 // Whose items were settled, and on which threads, to tell once at the end.
1416 let mut settled: HashSet<Settled> = HashSet::new();
1417 // What the batch supersedes goes first, so its own notices start afresh.
1418 let superseded: Vec<Settle> = events.iter().flat_map(|event| settles(event, actor_of(event).as_deref())).collect();
1419 match settle(db, &superseded).await {
1420 Ok(items) => settled.extend(items),
1421 Err(error) => worker::console_error!("inbox: items not settled: {error}"),
1422 }
1423 deliver_events(db, sources, events, &names, &mut settled).await;
1424 // What is over goes last, after its own notices reached each thread's history.
1425 let over: Vec<Settle> = events.iter().flat_map(|event| closes(event, actor_of(event).as_deref())).collect();
1426 match settle(db, &over).await {
1427 Ok(items) => settled.extend(items),
1428 Err(error) => worker::console_error!("inbox: closed threads not settled: {error}"),
1429 }
1430 if let Some(notify) = sources.notify {
1431 let mut threads: HashMap<&str, Vec<String>> = HashMap::new();
1432 for item in &settled {
1433 threads.entry(item.username.as_str()).or_default().push(item.thread.clone());
1434 }
1435 for (username, threads) in threads {
1436 tell_inbox_settled(db, notify, username, threads).await;
1437 }
1438 }
1439}
1440
1441/// The usernames of whoever caused the events that tell, watch or settle
1442/// anything, by account id, in one call. g1t is not looked up.
1443async fn actor_names(sources: &Sources<'_>, events: &[Event]) -> HashMap<String, String> {
1444 let ids: Vec<String> = events
1445 .iter()
1446 .filter(|event| {
1447 wants(event).is_some() || event.kind == "repo.created" || !settles(event, None).is_empty() || !closes(event, None).is_empty()
1448 })
1449 .filter_map(|event| event.actor.clone())
1450 .filter(|id| !is_g1t_id(id))
1451 .collect::<HashSet<_>>()
1452 .into_iter()
1453 .collect();
1454 if ids.is_empty() {
1455 return HashMap::new();
1456 }
1457 g1t_kit::call(sources.identity, "usernames", &UsernamesArgs { ids }).await.unwrap_or_else(|error| {
1458 worker::console_error!("inbox: could not name who acted: {error}");
1459 HashMap::new()
1460 })
1461}
1462
1463async fn deliver_events(db: &D1Database, sources: &Sources<'_>, events: &[Event], names: &HashMap<String, String>, settled: &mut HashSet<Settled>) {
1464 // A workspace's own notices, about no repository (workspace_notices).
1465 for event in events.iter().filter(|event| WORKSPACE_EVENTS.contains(&event.kind.as_str())) {
1466 if let Err(error) = deliver_workspace(db, event).await {
1467 worker::console_error!("inbox: {} {} not delivered: {error}", event.kind, event.id);
1468 } else if let Some(notify) = sources.notify {
1469 // Notify: the feed tells each person once per event, redelivered or not.
1470 let (thread, told) = workspace_notices(event, None);
1471 let link = event.data["link"].as_str().filter(|link| link.starts_with('/')).unwrap_or("/inbox");
1472 let live = told
1473 .iter()
1474 .map(|notice| (notice.username.clone(), live_notification(&event.id, notice, link, &event.time, Some(&thread))))
1475 .collect();
1476 tell_live(notify, live).await;
1477 for notice in &told {
1478 tell_inbox_count(db, notify, &notice.username).await;
1479 }
1480 }
1481 }
1482 let wanted: Vec<(&Event, Wanted)> = events
1483 .iter()
1484 .filter_map(|event| wants(event).map(|wanted| (event, wanted)))
1485 .collect();
1486 let created: Vec<&Event> = events.iter().filter(|event| event.kind == "repo.created").collect();
1487 if wanted.is_empty() && created.is_empty() {
1488 return;
1489 }
1490 for event in created {
1491 if let Err(error) = subscriptions::watch_created(db, event, names).await {
1492 worker::console_error!("inbox: {} {} not watched: {error}", event.kind, event.id);
1493 }
1494 }
1495 let mut paths: HashMap<String, Option<RepoPath>> = HashMap::new();
1496 let mut watchers: HashMap<String, Vec<subscriptions::Watcher>> = HashMap::new();
1497 let mut written: Vec<Written> = Vec::new();
1498 for (event, wanted) in wanted {
1499 match deliver_one(db, sources, names, &mut paths, &mut watchers, event, wanted, settled).await {
1500 Ok(mut news) => written.append(&mut news),
1501 Err(error) => worker::console_error!("inbox: {} {} not delivered: {error}", event.kind, event.id),
1502 }
1503 }
1504 // Notify: every item that was news, live in the person's tabs
1505 // (services/notify), but for what is over, which is settled at once:
1506 // nobody is pushed a notification that is already done.
1507 if let Some(notify) = sources.notify {
1508 let live = written
1509 .iter()
1510 .filter(|item| !item.closing && item.live)
1511 .map(|item| {
1512 let live = live_notification(&item.event_id, &item.notice, &item.url, &rfc3339(now_ms()), Some(&item.thread));
1513 (item.notice.username.clone(), live)
1514 })
1515 .collect::<Vec<_>>();
1516 tell_live(notify, live).await;
1517 let people: HashSet<&str> = written.iter().filter(|item| !item.closing).map(|item| item.notice.username.as_str()).collect();
1518 for username in people {
1519 if !settled.iter().any(|item| item.username == username) {
1520 tell_inbox_count(db, notify, username).await;
1521 }
1522 }
1523 }
1524 if let Err(error) = email(db, sources.identity, written).await {
1525 worker::console_error!("inbox: emails not sent: {error}");
1526 }
1527}
1528
1529/// Events about a workspace rather than a repository, each naming who to
1530/// tell (`notify`), what to say (`title`, `body`) and where it is (`link`):
1531/// identity's personal access token approvals.
1532///
1533/// | Event | Who | Reason | Severity |
1534/// | --- | --- | --- | --- |
1535/// | `token.approval_requested` | the workspace's owners | review_requested | warning |
1536/// | `token.approval_reviewed` | the token's owner | author | info |
1537/// | `workspace_invitation.created` | the person invited | review_requested | warning |
1538/// | `workspace_invitation.accepted` | who invited them | author | success |
1539/// | `workspace_invitation.declined` | who invited them | author | info |
1540///
1541/// An invitation's item for the person invited is done once it is
1542/// accepted, declined or revoked (`workspace_invitation.revoked`, which
1543/// tells nobody).
1544pub const WORKSPACE_EVENTS: [&str; 5] = [
1545 "token.approval_requested",
1546 "token.approval_reviewed",
1547 "workspace_invitation.created",
1548 "workspace_invitation.accepted",
1549 "workspace_invitation.declined",
1550];
1551
1552/// Events that close what a workspace invitation asked of its person.
1553pub const INVITATION_ANSWERED: [&str; 3] =
1554 ["workspace_invitation.accepted", "workspace_invitation.declined", "workspace_invitation.revoked"];
1555
1556/// The notices a workspace event calls for, and the thread they go to.
1557pub fn workspace_notices(event: &Event, actor: Option<&str>) -> (String, Vec<Notice>) {
1558 let data = &event.data;
1559 let text = |key: &str| data[key].as_str().unwrap_or_default().to_owned();
1560 let (reason, severity) = match event.kind.as_str() {
1561 "token.approval_requested" | "workspace_invitation.created" => (Reason::ReviewRequested, Severity::Warning),
1562 "workspace_invitation.accepted" => (Reason::Author, Severity::Success),
1563 _ => (Reason::Author, Severity::Info),
1564 };
1565 // An invitation names its own thread; a token approval's is the token's.
1566 let thread = match data["thread"].as_str().filter(|thread| !thread.is_empty()) {
1567 Some(thread) => thread.to_owned(),
1568 None => format!("workspace:{}/token/{}", text("workspace"), text("tokenId")),
1569 };
1570 let notices = names(data, "notify")
1571 .into_iter()
1572 .map(|name| name.to_lowercase())
1573 .filter(|name| actor.is_none_or(|actor| !actor.eq_ignore_ascii_case(name)) && !is_g1t(name))
1574 .collect::<HashSet<_>>()
1575 .into_iter()
1576 .map(|username| Notice { username, reason, severity, title: text("title"), body: text("body") })
1577 .collect();
1578 (thread, notices)
1579}
1580
1581/// Writes a workspace event's notices: a thread each, with no repository.
1582async fn deliver_workspace(db: &D1Database, event: &Event) -> Result<()> {
1583 let (thread, told) = workspace_notices(event, None);
1584 if told.is_empty() {
1585 return Ok(());
1586 }
1587 let now = now_ms();
1588 let workspace = event.data["workspace"].as_str().unwrap_or_default().to_lowercase();
1589 let link = event.data["link"].as_str().filter(|link| link.starts_with('/'));
1590 let mut statements = Vec::new();
1591 for notice in &told {
1592 let username = notice.username.as_str();
1593 statements.push(db.prepare(BUMP).bind(&[
1594 new_id("ntf", now).into(),
1595 username.into(),
1596 thread.as_str().into(),
1597 event.id.as_str().into(),
1598 event.kind.as_str().into(),
1599 notice.reason.as_str().into(),
1600 notice.severity.as_str().into(),
1601 notice.title.as_str().into(),
1602 notice.body.as_str().into(),
1603 workspace.as_str().into(),
1604 JsValue::NULL,
1605 JsValue::NULL,
1606 JsValue::NULL,
1607 JsValue::NULL,
1608 JsValue::NULL,
1609 link.map_or(JsValue::NULL, JsValue::from),
1610 JsValue::NULL,
1611 event.time.as_str().into(),
1612 new_id("ntf", now).into(),
1613 ])?);
1614 statements.push(
1615 db.prepare(
1616 "INSERT OR IGNORE INTO inbox_activity (id, item_id, username, event_id, event, reason, severity, title, body, actor, created_at)
1617 SELECT ?1, id, ?2, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10 FROM inbox_items WHERE username = ?2 AND thread = ?3",
1618 )
1619 .bind(&[
1620 new_id("ntf", now).into(),
1621 username.into(),
1622 thread.as_str().into(),
1623 event.id.as_str().into(),
1624 event.kind.as_str().into(),
1625 notice.reason.as_str().into(),
1626 notice.severity.as_str().into(),
1627 notice.title.as_str().into(),
1628 notice.body.as_str().into(),
1629 event.time.as_str().into(),
1630 ])?,
1631 );
1632 }
1633 db.batch(statements).await?;
1634 Ok(())
1635}
1636
1637/// Settles items (see [`settles`]), in one batch. Returns the items it
1638/// settled, by person and thread, so their counts can be told again and
1639/// notify can stop replaying them.
1640async fn settle(db: &D1Database, settled: &[Settle]) -> Result<HashSet<Settled>> {
1641 if settled.is_empty() {
1642 return Ok(HashSet::new());
1643 }
1644 let now = rfc3339(now_ms());
1645 let statements = settled.iter().map(|settle| settle.statement(db, &now)).collect::<Result<Vec<_>>>()?;
1646 let mut items = HashSet::new();
1647 for result in db.batch(statements).await? {
1648 items.extend(result.results::<Settled>()?);
1649 }
1650 Ok(items)
1651}
1652
1653#[allow(clippy::too_many_arguments)]
1654async fn deliver_one(
1655 db: &D1Database,
1656 sources: &Sources<'_>,
1657 names: &HashMap<String, String>,
1658 paths: &mut HashMap<String, Option<RepoPath>>,
1659 watchers: &mut HashMap<String, Vec<subscriptions::Watcher>>,
1660 event: &Event,
1661 wanted: Wanted,
1662 settled: &mut HashSet<Settled>,
1663) -> Result<Vec<Written>> {
1664 if let Some(repo_id) = &wanted.repo_id
1665 && !paths.contains_key(repo_id)
1666 {
1667 let path: Option<RepoPath> = g1t_kit::call(sources.repos, "path_by_id", &PathByIdArgs { id: repo_id.clone() }).await?;
1668 paths.insert(repo_id.clone(), path);
1669 }
1670 let path = wanted.repo_id.as_ref().and_then(|repo_id| paths.get(repo_id).cloned().flatten());
1671 // A repository that is gone tells nobody; a work item stays in its
1672 // workspace when its origin goes.
1673 if path.is_none() && wanted.item.is_none() {
1674 return Ok(Vec::new());
1675 }
1676 let subject: Option<InboxSubject> = match wanted.number {
1677 Some(number) => {
1678 let found: Option<InboxSubject> = g1t_kit::call(
1679 sources.work,
1680 "inbox_subject",
1681 &InboxSubjectArgs {
1682 repo_id: wanted.repo_id.clone(),
1683 number,
1684 item_id: wanted.item.as_ref().map(|item| item.id.clone()),
1685 comment_id: wanted.comment_id.clone(),
1686 },
1687 )
1688 .await?;
1689 if found.is_none() {
1690 return Ok(Vec::new());
1691 }
1692 found
1693 }
1694 None => None,
1695 };
1696 let actor = Actor {
1697 id: event.actor.clone(),
1698 username: match event.actor.as_deref() {
1699 Some(id) if is_g1t_id(id) => Some(system::USERNAME.to_owned()),
1700 Some(id) => names.get(id).map(|name| name.to_lowercase()),
1701 None => None,
1702 },
1703 };
1704 let thread = thread_of(event, &wanted, subject.as_ref());
1705 // Whoever commented has answered what was on the thread for them.
1706 if let Some(handled) = handled_by(event, subject.as_ref()) {
1707 settled.extend(settle(db, &[handled]).await?);
1708 }
1709 // Who watches it: its repository's watchers, or a work item's linked
1710 // repositories', its origin's first.
1711 let linked: Vec<&str> = wanted.item.as_ref().map_or_else(Vec::new, |item| {
1712 item.repo_ids
1713 .iter()
1714 .map(String::as_str)
1715 .filter(|id| wanted.repo_id.as_deref() != Some(*id))
1716 .collect()
1717 });
1718 for repo_id in wanted.repo_id.iter().map(String::as_str).chain(linked.iter().copied()) {
1719 if !watchers.contains_key(repo_id) {
1720 watchers.insert(repo_id.to_owned(), subscriptions::watchers(db, repo_id).await?);
1721 }
1722 }
1723 let own = wanted.repo_id.as_ref().and_then(|id| watchers.get(id)).cloned().unwrap_or_default();
1724 let others: Vec<Vec<subscriptions::Watcher>> = linked.iter().map(|id| watchers.get(*id).cloned().unwrap_or_default()).collect();
1725 let (watching, elsewhere) = subscriptions::linked_watchers(own, &others);
1726 let audience = Audience {
1727 subscriptions: match thread.kind {
1728 Some(SubjectKind::Item | SubjectKind::Pull) => subscriptions::of_thread(db, &thread.key).await?,
1729 _ => Vec::new(),
1730 },
1731 watchers: watching,
1732 };
1733 let repo = path.as_ref().map(|path| format!("{}/{}", path.namespace, path.name).to_lowercase());
1734 // Where titles say it is: a work item's workspace, else the repository.
1735 let workspace = match &wanted.item {
1736 Some(item) => item.workspace.clone(),
1737 None => path.as_ref().map(|path| path.namespace.to_lowercase()).unwrap_or_default(),
1738 };
1739 let place = match &wanted.item {
1740 Some(item) => item.workspace.clone(),
1741 None => repo.clone().unwrap_or_default(),
1742 };
1743 let mut told = notices(event, &place, &actor, subject.as_ref(), &audience);
1744 let mut statements =
1745 subscriptions::auto_subscribe(db, &thread.key, wanted.repo_id.as_deref(), &subscribes(event, subject.as_ref()), &event.time)?;
1746 // A scan's summary: one that opened nothing brings the thread up to date
1747 // without waking anyone, and what is not urgent waits for each person's
1748 // digest, or comes at once or never, as they chose (security_delivery).
1749 if let (Some(summary), Some(path)) = (security_summary(event), &path) {
1750 if summary.opened_count == 0 {
1751 refresh_security_thread(db, &thread.key, &place, &summary, event).await?;
1752 return Ok(Vec::new());
1753 }
1754 let people: Vec<String> = told.iter().map(|notice| notice.username.clone()).collect();
1755 let settings = subscriptions::settings_of(db, &people).await?;
1756 let now = now_ms();
1757 let mut kept = Vec::with_capacity(told.len());
1758 for notice in told {
1759 let setting = settings.get(&notice.username).cloned().unwrap_or_default();
1760 match security_delivery(&summary, &notice.username, &setting) {
1761 Delivery::Now => kept.push(notice),
1762 Delivery::Digest => statements.push(crate::security_digest::queue(db, &notice.username, path, &summary, &event.id, now)?),
1763 Delivery::Never => {}
1764 }
1765 }
1766 told = kept;
1767 }
1768 if told.is_empty() {
1769 if !statements.is_empty() {
1770 db.batch(statements).await?;
1771 }
1772 return Ok(Vec::new());
1773 }
1774 let now = now_ms();
1775 let link = thread.link.as_deref();
1776 let item_url = url(repo.as_deref(), Some(&workspace), thread.kind, thread.number, thread.run_id.as_deref(), link);
1777 // Three statements a notice: its thread brought up (unless this event
1778 // was told before), a line of history, and the history kept short.
1779 let first = statements.len();
1780 for notice in &told {
1781 let username = notice.username.as_str();
1782 let values: Vec<JsValue> = vec![
1783 new_id("ntf", now).into(),
1784 username.into(),
1785 thread.key.as_str().into(),
1786 event.id.as_str().into(),
1787 event.kind.as_str().into(),
1788 notice.reason.as_str().into(),
1789 notice.severity.as_str().into(),
1790 notice.title.as_str().into(),
1791 notice.body.as_str().into(),
1792 workspace.as_str().into(),
1793 wanted.repo_id.as_deref().map_or(JsValue::NULL, JsValue::from),
1794 repo.as_deref().map_or(JsValue::NULL, JsValue::from),
1795 thread.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()),
1796 thread.number.map_or(JsValue::NULL, JsValue::from),
1797 thread.run_id.as_deref().map_or(JsValue::NULL, JsValue::from),
1798 link.map_or(JsValue::NULL, JsValue::from),
1799 actor.username.as_deref().map_or(JsValue::NULL, JsValue::from),
1800 event.time.as_str().into(),
1801 // Same prefix as item ids, so old and new sort by time together.
1802 new_id("ntf", now).into(),
1803 ];
1804 statements.push(db.prepare(BUMP).bind(&values)?);
1805 statements.push(
1806 db.prepare(
1807 "INSERT OR IGNORE INTO inbox_activity (id, item_id, username, event_id, event, reason, severity, title, body, actor, created_at)
1808 SELECT ?1, id, ?2, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 FROM inbox_items WHERE username = ?2 AND thread = ?3
1809 RETURNING username",
1810 )
1811 .bind(&[
1812 new_id("ntf", now).into(),
1813 username.into(),
1814 thread.key.as_str().into(),
1815 event.id.as_str().into(),
1816 event.kind.as_str().into(),
1817 notice.reason.as_str().into(),
1818 notice.severity.as_str().into(),
1819 notice.title.as_str().into(),
1820 notice.body.as_str().into(),
1821 actor.username.as_deref().map_or(JsValue::NULL, JsValue::from),
1822 event.time.as_str().into(),
1823 ])?,
1824 );
1825 statements.push(
1826 db.prepare(
1827 "DELETE FROM inbox_activity
1828 WHERE item_id = (SELECT id FROM inbox_items WHERE username = ?1 AND thread = ?2)
1829 AND id NOT IN (
1830 SELECT a.id FROM inbox_activity a
1831 WHERE a.item_id = (SELECT id FROM inbox_items WHERE username = ?1 AND thread = ?2)
1832 ORDER BY a.id DESC LIMIT ?3)",
1833 )
1834 .bind(&[username.into(), thread.key.as_str().into(), MAX_ACTIVITY.into()])?,
1835 );
1836 }
1837 let results = db.batch(statements).await?;
1838 #[derive(Deserialize)]
1839 struct Told {
1840 username: String,
1841 }
1842 // A line of history written means the event is news to that person.
1843 let mut news = HashSet::new();
1844 for (at, _) in told.iter().enumerate() {
1845 if let Some(result) = results.get(first + at * 3 + 1)
1846 && let Ok(rows) = result.results::<Told>()
1847 {
1848 news.extend(rows.into_iter().map(|row| row.username));
1849 }
1850 }
1851 // Who hears of a work item only by watching another of its
1852 // repositories is pushed it live when they are a member of its
1853 // workspace, who may see it (all one filed with no repository has).
1854 let members: HashSet<String> = match &wanted.item {
1855 Some(item) => {
1856 let usernames: Vec<String> = told
1857 .iter()
1858 .filter(|notice| notice.reason == Reason::Subscribed && elsewhere.contains(&notice.username) && news.contains(&notice.username))
1859 .map(|notice| notice.username.clone())
1860 .collect();
1861 if usernames.is_empty() {
1862 HashSet::new()
1863 } else {
1864 let asked = MembersAmongArgs { workspace: item.workspace.clone(), usernames };
1865 g1t_kit::call::<_, Vec<String>>(sources.identity, "members_among", &asked)
1866 .await
1867 .unwrap_or_else(|error| {
1868 worker::console_error!("inbox: could not tell who is a member of {}: {error}", item.workspace);
1869 Vec::new()
1870 })
1871 .into_iter()
1872 .collect()
1873 }
1874 }
1875 None => HashSet::new(),
1876 };
1877 Ok(told
1878 .into_iter()
1879 .filter(|notice| news.contains(&notice.username))
1880 .map(|notice| Written {
1881 live: !(notice.reason == Reason::Subscribed
1882 && elsewhere.contains(&notice.username)
1883 && !members.contains(&notice.username)),
1884 notice,
1885 repo_id: wanted.repo_id.clone(),
1886 workspace: wanted.item.as_ref().map(|item| item.workspace.clone()),
1887 url: item_url.clone(),
1888 event_id: event.id.clone(),
1889 closing: CLOSES.contains(&event.kind.as_str()),
1890 thread: thread.key.clone(),
1891 })
1892 .collect())
1893}
1894
1895/// Brings a person's thread up with new activity, or starts it. `?1` id,
1896/// `?2` username, `?3` thread, `?4` event id, `?5` event type, `?6` reason,
1897/// `?7` severity, `?8` title, `?9` body, `?10` workspace, `?11` repo id,
1898/// `?12` repo, `?13` subject, `?14` number, `?15` run id, `?16` link, `?17`
1899/// actor, `?18` time, `?19` the time-sortable id lists order by. Nothing
1900/// happens when the person was already told of this event. While the
1901/// thread is unread its most urgent severity is kept; done or not, it
1902/// comes back to the inbox. A snooze stands.
1903pub(crate) const BUMP: &str = "INSERT INTO inbox_items (id, username, thread, event_id, event, reason, severity, title, body,
1904 workspace, repo_id, repo, subject, number, run_id, link, actor, created_at, updated_at, bumped, activity)
1905 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?18, ?19, 1
1906 WHERE NOT EXISTS (SELECT 1 FROM inbox_activity WHERE event_id = ?4 AND username = ?2)
1907 ON CONFLICT (username, thread) DO UPDATE SET
1908 event_id = excluded.event_id,
1909 event = excluded.event,
1910 reason = excluded.reason,
1911 severity = CASE
1912 WHEN inbox_items.read_at IS NULL AND inbox_items.done_at IS NULL
1913 AND (CASE inbox_items.severity WHEN 'warning' THEN 0 WHEN 'error' THEN 1 WHEN 'success' THEN 2 ELSE 3 END)
1914 < (CASE excluded.severity WHEN 'warning' THEN 0 WHEN 'error' THEN 1 WHEN 'success' THEN 2 ELSE 3 END)
1915 THEN inbox_items.severity ELSE excluded.severity END,
1916 title = excluded.title,
1917 body = excluded.body,
1918 workspace = excluded.workspace,
1919 repo = excluded.repo,
1920 subject = COALESCE(excluded.subject, inbox_items.subject),
1921 number = COALESCE(excluded.number, inbox_items.number),
1922 run_id = COALESCE(excluded.run_id, inbox_items.run_id),
1923 link = COALESCE(excluded.link, inbox_items.link),
1924 actor = excluded.actor,
1925 updated_at = excluded.updated_at,
1926 bumped = excluded.bumped,
1927 activity = inbox_items.activity + 1,
1928 read_at = NULL,
1929 done_at = NULL,
1930 settled_reason = NULL,
1931 settled_by = NULL";
1932
1933/// What the notify service is told of an inbox item (`FeedNotification` in
1934/// @g1t/contracts): its kind from why the person was told, the workspace
1935/// from where it links, and the item's title and first line.
1936pub fn live_notification(event_id: &str, notice: &Notice, url: &str, at: &str, thread: Option<&str>) -> serde_json::Value {
1937 let kind = match notice.reason {
1938 Reason::ReviewRequested => "approval",
1939 Reason::Mention | Reason::TeamMention => "mention",
1940 // A critical or high alert, a refused push, a report or a bypass to
1941 // review: toasted like a mention. A digest, or what came at once by
1942 // choice, is an ordinary inbox item.
1943 Reason::SecurityAlert if notice.severity != Severity::Info => "security",
1944 _ => "inbox",
1945 };
1946 let path = url.split(['?', '#']).next().unwrap_or_default();
1947 let mut segments = path.trim_start_matches('/').split('/');
1948 let workspace = segments.next().unwrap_or_default().to_lowercase();
1949 let repo = segments.next().map(|name| format!("{workspace}/{name}"));
1950 let name = repo.unwrap_or_else(|| if workspace.is_empty() { "g1t".to_owned() } else { workspace.clone() });
1951 serde_json::json!({
1952 "id": format!("inbox:{event_id}"),
1953 "kind": kind,
1954 "workspace": workspace,
1955 "title": notice.title,
1956 "body": clip(&notice.body, 140),
1957 "href": if url.starts_with('/') { url } else { "/inbox" },
1958 "actor": { "kind": "system", "id": name, "name": name },
1959 "inbox_thread": thread,
1960 "created_at": at,
1961 })
1962}
1963
1964/// What the notify service is told when a person's inbox count changes
1965/// (its `set_inbox`): the count as it now is, never a difference, so a
1966/// call repeated or out of order still ends right.
1967pub fn inbox_count_update(username: &str, unread: u32) -> serde_json::Value {
1968 serde_json::json!({ "username": username.to_lowercase(), "unread": unread })
1969}
1970
1971/// What the notify service is told when some of a person's items settled
1972/// (its `set_inbox` with `settled`): the count as it now is, and the
1973/// threads that settled, whose notifications it then stops replaying to
1974/// tabs that open later and drops from open tabs' lists.
1975pub fn inbox_settled_update(username: &str, unread: u32, threads: &[String]) -> serde_json::Value {
1976 let mut threads: Vec<&str> = threads.iter().map(String::as_str).collect();
1977 threads.sort_unstable();
1978 threads.dedup();
1979 serde_json::json!({ "username": username.to_lowercase(), "unread": unread, "settled": threads })
1980}
1981
1982/// Tells the notify service a person's unread count as it now is, so every
1983/// tab of theirs shows it at once: after items arrive, and after marks made
1984/// anywhere (the site, the API, MCP). Logged and left when it fails.
1985pub async fn tell_inbox_count(db: &D1Database, notify: &Fetcher, username: &str) {
1986 let counted = counts(db, InboxCountsArgs { username: username.to_owned() }).await;
1987 let told: Result<serde_json::Value> = match counted {
1988 Ok(counts) => g1t_kit::call(notify, "set_inbox", &inbox_count_update(username, counts.unread)).await,
1989 Err(error) => Err(error),
1990 };
1991 if let Err(error) = told {
1992 worker::console_error!("inbox: live count not sent: {error}");
1993 }
1994}
1995
1996/// Tells the notify service a person's count after items of theirs
1997/// settled, with the threads that did. Logged and left when it fails.
1998pub async fn tell_inbox_settled(db: &D1Database, notify: &Fetcher, username: &str, threads: Vec<String>) {
1999 let counted = counts(db, InboxCountsArgs { username: username.to_owned() }).await;
2000 let told: Result<serde_json::Value> = match counted {
2001 Ok(counts) => g1t_kit::call(notify, "set_inbox", &inbox_settled_update(username, counts.unread, &threads)).await,
2002 Err(error) => Err(error),
2003 };
2004 if let Err(error) = told {
2005 worker::console_error!("inbox: settled items not told: {error}");
2006 }
2007}
2008
2009/// Hands items to the notify service, one call each. Logged and left when it
2010/// cannot be reached: the inbox and email do not wait on it.
2011pub(crate) async fn tell_live(notify: &Fetcher, items: Vec<(String, serde_json::Value)>) {
2012 #[derive(serde::Serialize)]
2013 struct NotifyArgs {
2014 username: String,
2015 notification: serde_json::Value,
2016 }
2017 for (username, notification) in items {
2018 let told: Result<serde_json::Value> = g1t_kit::call(notify, "notify", &NotifyArgs { username, notification }).await;
2019 if let Err(error) = told {
2020 worker::console_error!("inbox: live notification not sent: {error}");
2021 }
2022 }
2023}
2024
2025/// Emails what was news to people who asked to be emailed for its reason.
2026/// Identity sends each, only to a confirmed address and only if the person
2027/// can still see it: they can read its repository, or are a member of a
2028/// work item's workspace (all one filed with no repository has).
2029async fn email(db: &D1Database, identity: &Fetcher, written: Vec<Written>) -> Result<()> {
2030 let written: Vec<Written> = written.into_iter().filter(|item| item.repo_id.is_some() || item.workspace.is_some()).collect();
2031 if written.is_empty() {
2032 return Ok(());
2033 }
2034 let people: Vec<String> = written
2035 .iter()
2036 .map(|item| item.notice.username.clone())
2037 .collect::<HashSet<_>>()
2038 .into_iter()
2039 .collect();
2040 let settings = subscriptions::settings_of(db, &people).await?;
2041 for item in written {
2042 let wants = settings.get(&item.notice.username).map_or(&DEFAULT_EMAIL[..], |settings| &settings.email[..]);
2043 if !wants.contains(&item.notice.reason) {
2044 continue;
2045 }
2046 let sent: Result<bool> = g1t_kit::call(
2047 identity,
2048 "notify_by_email",
2049 &NotifyByEmailArgs {
2050 username: item.notice.username.clone(),
2051 repo_id: item.repo_id.clone(),
2052 workspace: item.workspace.clone(),
2053 subject: item.notice.title.clone(),
2054 intro: item.notice.body.clone(),
2055 quote: None,
2056 path: item.url.clone(),
2057 reason: item.notice.reason,
2058 },
2059 )
2060 .await;
2061 if let Err(error) = sent {
2062 worker::console_error!("inbox: email to {} not sent: {error}", item.notice.username);
2063 }
2064 }
2065 Ok(())
2066}
2067
2068// --- Reading and changing ----------------------------------------------------
2069
2070#[derive(Deserialize)]
2071pub(crate) struct Row {
2072 pub(crate) id: String,
2073 #[serde(default)]
2074 thread: Option<String>,
2075 reason: String,
2076 severity: String,
2077 title: String,
2078 body: String,
2079 event: Option<String>,
2080 workspace: Option<String>,
2081 pub(crate) repo_id: Option<String>,
2082 repo: Option<String>,
2083 subject: Option<String>,
2084 number: Option<f64>,
2085 run_id: Option<String>,
2086 link: Option<String>,
2087 actor: Option<String>,
2088 activity: Option<f64>,
2089 created_at: String,
2090 updated_at: Option<String>,
2091 read_at: Option<String>,
2092 done_at: Option<String>,
2093 saved: f64,
2094 snoozed_until: Option<String>,
2095 bumped: Option<String>,
2096 settled_reason: Option<String>,
2097 settled_by: Option<String>,
2098}
2099
2100impl Row {
2101 pub(crate) fn into_item(self) -> InboxItem {
2102 let subject = self.subject.as_deref().and_then(SubjectKind::parse);
2103 let number = self.number.map(|n| n as u32);
2104 // A work item whose thread still has its issue's number, until the
2105 // work service's numbers arrive (work_items.rs): the old link,
2106 // which leads to it.
2107 let legacy = subject == Some(SubjectKind::Item) && self.thread.as_deref().is_some_and(|thread| !thread.starts_with(ITEM_THREAD));
2108 let link = match (legacy, &self.repo, number) {
2109 (true, Some(repo), Some(number)) => Some(format!("/{repo}/issues/{number}")),
2110 _ => self.link.clone(),
2111 };
2112 InboxItem {
2113 url: url(self.repo.as_deref(), self.workspace.as_deref(), subject, number, self.run_id.as_deref(), link.as_deref()),
2114 id: self.id,
2115 reason: Reason::parse(&self.reason).unwrap_or(Reason::Subscribed),
2116 severity: Severity::parse(&self.severity).unwrap_or(Severity::Info),
2117 title: self.title,
2118 body: self.body,
2119 event: self.event,
2120 repo: self.repo,
2121 workspace: self.workspace,
2122 subject,
2123 number,
2124 actor: self.actor,
2125 count: self.activity.map_or(1, |n| n as u32),
2126 updated_at: self.updated_at.unwrap_or_else(|| self.created_at.clone()),
2127 created_at: self.created_at,
2128 read_at: self.read_at,
2129 done_at: self.done_at,
2130 saved: self.saved != 0.0,
2131 snoozed_until: self.snoozed_until,
2132 settled_reason: self.settled_reason,
2133 settled_by: self.settled_by,
2134 }
2135 }
2136}
2137
2138pub(crate) const COLUMNS: &str = "id, thread, reason, severity, title, body, event, workspace, repo_id, repo, subject, number, run_id,
2139 link, actor, activity, created_at, updated_at, read_at, done_at, saved, snoozed_until, bumped,
2140 settled_reason, settled_by";
2141
2142/// The conditions that pick a view's items, after `username = ?1`; `?2` is now.
2143fn view_filter(view: InboxView) -> &'static str {
2144 match view {
2145 InboxView::Inbox => "done_at IS NULL AND (snoozed_until IS NULL OR snoozed_until <= ?2)",
2146 InboxView::Saved => "saved = 1",
2147 InboxView::Done => "done_at IS NOT NULL",
2148 }
2149}
2150
2151/// Whether a list ranks unread warnings first: the inbox itself, unfiltered.
2152fn ranked(a: &ListInboxArgs) -> bool {
2153 a.view == InboxView::Inbox
2154 && a.severity.is_none()
2155 && a.reason.is_none()
2156 && !a.participating
2157 && a.repo_id.is_none()
2158 && !a.unread
2159 && a.since.is_none()
2160 && a.updated_before.is_none()
2161}
2162
2163/// The conditions a list's filters add, and the values they bind, numbered
2164/// after the `bound` values already given.
2165fn filters(a: &ListInboxArgs, bound: usize) -> (Vec<String>, Vec<String>) {
2166 let mut conditions = Vec::new();
2167 let mut values: Vec<String> = Vec::new();
2168 let mut bind = |value: &str, condition: &str| {
2169 values.push(value.to_owned());
2170 conditions.push(condition.replace('?', &format!("?{}", bound + values.len())));
2171 };
2172 if let Some(severity) = a.severity {
2173 bind(severity.as_str(), "severity = ?");
2174 }
2175 if let Some(reason) = a.reason {
2176 bind(reason.as_str(), "reason = ?");
2177 }
2178 if let Some(repo_id) = &a.repo_id {
2179 bind(repo_id, "repo_id = ?");
2180 }
2181 if let Some(since) = &a.since {
2182 bind(since.trim(), "COALESCE(updated_at, created_at) >= ?");
2183 }
2184 if let Some(before) = &a.updated_before {
2185 bind(before.trim(), "COALESCE(updated_at, created_at) < ?");
2186 }
2187 if a.participating {
2188 conditions.push("reason NOT IN ('manual', 'subscribed')".to_owned());
2189 }
2190 if a.unread {
2191 conditions.push("read_at IS NULL".to_owned());
2192 }
2193 (conditions, values)
2194}
2195
2196pub async fn list(db: &D1Database, repos: &Fetcher, a: ListInboxArgs) -> Result<InboxPage> {
2197 let Some(viewer) = &a.viewer else {
2198 return Ok(InboxPage::default());
2199 };
2200 let username = viewer.username.to_lowercase();
2201 let now = rfc3339(now_ms());
2202 let limit = a.limit.unwrap_or(DEFAULT_INBOX_PAGE).clamp(1, MAX_INBOX_PAGE);
2203 let mut values: Vec<JsValue> = vec![username.as_str().into(), now.as_str().into()];
2204 let mut conditions = vec!["username = ?1".to_owned(), view_filter(a.view).to_owned()];
2205 let (filtered, bound) = filters(&a, values.len());
2206 conditions.extend(filtered);
2207 values.extend(bound.iter().map(|value| JsValue::from(value.as_str())));
2208 let ranked = ranked(&a);
2209 // Unread warnings lead the first page, and are left out of the rest.
2210 let leading = "severity = 'warning' AND read_at IS NULL";
2211 let mut rest = conditions.clone();
2212 let mut rest_values = values.clone();
2213 if ranked {
2214 rest.push(format!("NOT ({leading})"));
2215 }
2216 if let Some(before) = &a.before {
2217 rest_values.push(before.as_str().into());
2218 rest.push(format!("bumped < ?{}", rest_values.len()));
2219 }
2220 rest_values.push((limit + 1).into());
2221 let order = if a.view == InboxView::Done { "done_at DESC, bumped DESC" } else { "bumped DESC" };
2222 let mut statements = vec![
2223 db.prepare(format!(
2224 "SELECT {COLUMNS} FROM inbox_items WHERE {} ORDER BY {order} LIMIT ?{}",
2225 rest.join(" AND "),
2226 rest_values.len()
2227 ))
2228 .bind(&rest_values)?,
2229 ];
2230 if ranked && a.before.is_none() {
2231 statements.push(
2232 db.prepare(format!(
2233 "SELECT {COLUMNS} FROM inbox_items WHERE {} AND {leading} ORDER BY bumped DESC LIMIT ?3",
2234 conditions.join(" AND ")
2235 ))
2236 .bind(&[username.as_str().into(), now.as_str().into(), MAX_RANKED.into()])?,
2237 );
2238 }
2239 let results = db.batch(statements).await?;
2240 let mut rows = results.first().map(|result| result.results::<Row>()).transpose()?.unwrap_or_default();
2241 let next = if rows.len() > limit as usize {
2242 rows.truncate(limit as usize);
2243 rows.last().map(|row| row.bumped.clone().unwrap_or_else(|| row.id.clone()))
2244 } else {
2245 None
2246 };
2247 let mut items: Vec<Row> = results.get(1).map(|result| result.results::<Row>()).transpose()?.unwrap_or_default();
2248 items.extend(rows);
2249 let items = readable_only(db, repos, &a.viewer, &username, items).await?;
2250 Ok(InboxPage {
2251 items: items.into_iter().map(Row::into_item).collect(),
2252 next,
2253 })
2254}
2255
2256/// The workspaces whose work items the viewer sees all of: those they are
2257/// a member of, lowercased.
2258fn member_of(viewer: &g1t_contracts::Viewer) -> Vec<String> {
2259 let Some(user) = viewer else {
2260 return Vec::new();
2261 };
2262 user.workspaces
2263 .iter()
2264 .filter(|membership| g1t_contracts::access::workspace_role(user, &membership.slug).is_some())
2265 .map(|membership| membership.slug.to_lowercase())
2266 .collect()
2267}
2268
2269impl Row {
2270 /// A work item of one of these workspaces, which a member sees
2271 /// whatever its origin.
2272 fn seen_as_member(&self, members: &[String]) -> bool {
2273 self.subject.as_deref() == Some(SubjectKind::Item.as_str())
2274 && self.workspace.as_deref().is_some_and(|slug| members.iter().any(|member| member.eq_ignore_ascii_case(slug)))
2275 }
2276}
2277
2278/// Rows the viewer may still see: about repositories they can read, and
2279/// work items of workspaces they belong to or whose origin they can read.
2280/// The rest are removed from their inbox as they are found.
2281pub(crate) async fn readable_only(db: &D1Database, repos: &Fetcher, viewer: &g1t_contracts::Viewer, username: &str, mut rows: Vec<Row>) -> Result<Vec<Row>> {
2282 let members = member_of(viewer);
2283 // A work item outside the viewer's workspaces with no origin is not theirs to see.
2284 let unseen: Vec<String> = rows
2285 .iter()
2286 .filter(|row| row.subject.as_deref() == Some(SubjectKind::Item.as_str()) && row.repo_id.is_none() && !row.seen_as_member(&members))
2287 .map(|row| row.id.clone())
2288 .collect();
2289 let ids: Vec<String> = rows
2290 .iter()
2291 .filter(|row| !row.seen_as_member(&members))
2292 .filter_map(|row| row.repo_id.clone())
2293 .collect::<HashSet<_>>()
2294 .into_iter()
2295 .collect();
2296 let gone: Vec<String> = if ids.is_empty() {
2297 Vec::new()
2298 } else {
2299 let readable: Vec<Repo> = g1t_kit::call(
2300 repos,
2301 "readable",
2302 &ReadableArgs {
2303 ids: ids.clone(),
2304 viewer: viewer.clone(),
2305 },
2306 )
2307 .await?;
2308 let readable: HashSet<String> = readable.into_iter().map(|repo| repo.id).collect();
2309 ids.into_iter().filter(|id| !readable.contains(id)).collect()
2310 };
2311 if !gone.is_empty() || !unseen.is_empty() {
2312 forget(db, username, &gone, &unseen, &members).await?;
2313 rows.retain(|row| {
2314 !unseen.contains(&row.id) && (row.seen_as_member(&members) || row.repo_id.as_ref().is_none_or(|id| !gone.contains(id)))
2315 });
2316 }
2317 Ok(rows)
2318}
2319
2320/// Removes a person's items about repositories they cannot read (but for
2321/// work items of the workspaces they are a member of), and the items by
2322/// id in `unseen`.
2323async fn forget(db: &D1Database, username: &str, repo_ids: &[String], unseen: &[String], members: &[String]) -> Result<()> {
2324 let which = "username = ?1 AND (
2325 (repo_id IN (SELECT value FROM json_each(?2))
2326 AND NOT (subject = 'item' AND workspace IN (SELECT value FROM json_each(?3))))
2327 OR id IN (SELECT value FROM json_each(?4)))";
2328 let values: Vec<JsValue> = vec![
2329 username.into(),
2330 serde_json::to_string(repo_ids)?.into(),
2331 serde_json::to_string(members)?.into(),
2332 serde_json::to_string(unseen)?.into(),
2333 ];
2334 db.batch(vec![
2335 db.prepare(format!("DELETE FROM inbox_activity WHERE item_id IN (SELECT id FROM inbox_items WHERE {which})"))
2336 .bind(&values)?,
2337 db.prepare(format!("DELETE FROM inbox_items WHERE {which}")).bind(&values)?,
2338 ])
2339 .await?;
2340 Ok(())
2341}
2342
2343#[derive(Deserialize)]
2344struct CountRow {
2345 severity: String,
2346 n: f64,
2347}
2348
2349pub async fn counts(db: &D1Database, a: InboxCountsArgs) -> Result<InboxCounts> {
2350 let rows = db
2351 .prepare(
2352 "SELECT severity, count(*) AS n FROM inbox_items
2353 WHERE username = ? AND read_at IS NULL AND done_at IS NULL
2354 AND (snoozed_until IS NULL OR snoozed_until <= ?)
2355 GROUP BY severity",
2356 )
2357 .bind(&[a.username.to_lowercase().into(), rfc3339(now_ms()).into()])?
2358 .all()
2359 .await?
2360 .results::<CountRow>()?;
2361 let mut counts = InboxCounts::default();
2362 for row in rows {
2363 let n = row.n as u32;
2364 counts.unread += n;
2365 match Severity::parse(&row.severity) {
2366 Some(Severity::Error) => counts.error += n,
2367 Some(Severity::Warning) => counts.warning += n,
2368 Some(Severity::Success) => counts.success += n,
2369 Some(Severity::Info) | None => counts.info += n,
2370 }
2371 }
2372 Ok(counts)
2373}
2374
2375/// The change a mark makes, as a `SET` clause; `?1` is now.
2376fn mark_change(mark: InboxMark) -> &'static str {
2377 match mark {
2378 InboxMark::Read => "read_at = COALESCE(read_at, ?1)",
2379 InboxMark::Unread => "read_at = NULL",
2380 InboxMark::Done => "done_at = ?1, read_at = COALESCE(read_at, ?1), settled_reason = NULL, settled_by = NULL",
2381 InboxMark::Undone => "done_at = NULL, settled_reason = NULL, settled_by = NULL",
2382 InboxMark::Save => "saved = 1",
2383 InboxMark::Unsave => "saved = 0",
2384 InboxMark::Snooze => "snoozed_until = ?2, read_at = COALESCE(read_at, ?1)",
2385 InboxMark::Unsnooze => "snoozed_until = NULL",
2386 }
2387}
2388
2389#[derive(Deserialize)]
2390struct IdRow {
2391 #[allow(dead_code)]
2392 id: String,
2393}
2394
2395/// Changes the person's own items. Returns how many changed.
2396pub async fn mark(db: &D1Database, a: MarkInboxArgs) -> Result<u32> {
2397 let now = rfc3339(now_ms());
2398 let until = match (a.mark, a.until.as_deref().map(str::trim)) {
2399 // Times compare as text (`g1t_contracts::time`); a snooze is for later.
2400 (InboxMark::Snooze, Some(until)) if until.len() == now.len() && until > now.as_str() => until.to_owned(),
2401 (InboxMark::Snooze, _) => return Ok(0),
2402 _ => String::new(),
2403 };
2404 let mut values: Vec<JsValue> = vec![now.as_str().into(), until.as_str().into(), a.username.to_lowercase().into()];
2405 let target = if a.all && a.ids.is_empty() {
2406 let mut target = "done_at IS NULL".to_owned();
2407 let mut bind = |values: &mut Vec<JsValue>, value: &str, condition: &str| {
2408 values.push(value.into());
2409 target.push_str(&format!(" AND {}", condition.replace('?', &format!("?{}", values.len()))));
2410 };
2411 if let Some(severity) = a.severity {
2412 bind(&mut values, severity.as_str(), "severity = ?");
2413 }
2414 if let Some(repo_id) = &a.repo_id {
2415 bind(&mut values, repo_id, "repo_id = ?");
2416 }
2417 if let Some(last_read_at) = a.last_read_at.as_deref().map(str::trim).filter(|at| !at.is_empty()) {
2418 bind(&mut values, last_read_at, "COALESCE(updated_at, created_at) <= ?");
2419 }
2420 target
2421 } else {
2422 let ids: Vec<&String> = a.ids.iter().take(MAX_MARK).collect();
2423 if ids.is_empty() {
2424 return Ok(0);
2425 }
2426 let first = values.len() + 1;
2427 values.extend(ids.iter().map(|id| JsValue::from(id.as_str())));
2428 let marks: Vec<String> = (first..values.len() + 1).map(|at| format!("?{at}")).collect();
2429 format!("id IN ({})", marks.join(", "))
2430 };
2431 let changed = db
2432 .prepare(format!(
2433 "UPDATE inbox_items SET {} WHERE username = ?3 AND {target} RETURNING id",
2434 mark_change(a.mark)
2435 ))
2436 .bind(&values)?
2437 .all()
2438 .await?
2439 .results::<IdRow>()?;
2440 Ok(changed.len() as u32)
2441}
2442
2443#[derive(Deserialize)]
2444struct ActivityRow {
2445 reason: String,
2446 severity: String,
2447 title: String,
2448 body: String,
2449 event: Option<String>,
2450 actor: Option<String>,
2451 created_at: String,
2452}
2453
2454/// One of the viewer's threads, with its history and their subscription.
2455pub async fn thread(db: &D1Database, repos: &Fetcher, work: &Fetcher, a: ThreadArgs) -> Result<Option<InboxThread>> {
2456 let Some(viewer) = &a.viewer else {
2457 return Ok(None);
2458 };
2459 let username = viewer.username.to_lowercase();
2460 let row = db
2461 .prepare(format!("SELECT {COLUMNS} FROM inbox_items WHERE id = ? AND username = ?"))
2462 .bind(&[a.id.as_str().into(), username.as_str().into()])?
2463 .first::<Row>(None)
2464 .await?;
2465 let Some(row) = readable_only(db, repos, &a.viewer, &username, row.into_iter().collect()).await?.pop() else {
2466 return Ok(None);
2467 };
2468 let activity = db
2469 .prepare(
2470 "SELECT reason, severity, title, body, event, actor, created_at FROM inbox_activity
2471 WHERE item_id = ? ORDER BY id DESC LIMIT ?",
2472 )
2473 .bind(&[row.id.as_str().into(), MAX_ACTIVITY.into()])?
2474 .all()
2475 .await?
2476 .results::<ActivityRow>()?
2477 .into_iter()
2478 .map(|row| InboxActivity {
2479 reason: Reason::parse(&row.reason).unwrap_or(Reason::Subscribed),
2480 severity: Severity::parse(&row.severity).unwrap_or(Severity::Info),
2481 title: row.title,
2482 body: row.body,
2483 event: row.event,
2484 actor: row.actor,
2485 created_at: row.created_at,
2486 })
2487 .collect();
2488 let item = row.into_item();
2489 let subscription = match (item.subject, item.number) {
2490 (Some(SubjectKind::Item | SubjectKind::Pull), Some(_)) => {
2491 subscriptions::subscription(
2492 db,
2493 work,
2494 SubscriptionArgs {
2495 viewer: a.viewer.clone(),
2496 id: Some(item.id.clone()),
2497 ..SubscriptionArgs::default()
2498 },
2499 )
2500 .await?
2501 }
2502 _ => None,
2503 };
2504 Ok(Some(InboxThread {
2505 item,
2506 activity,
2507 subscription,
2508 }))
2509}
2510
2511// --- Keeping up ------------------------------------------------------------------
2512
2513/// Moves rows with renamed workspaces and repositories, drops those of
2514/// purged repositories, deleted workspaces and purged accounts, and
2515/// rewrites what was kept by issue number as issues become work items
2516/// (work_items.rs).
2517pub async fn follow(db: &D1Database, events: &[Event]) -> Result<()> {
2518 let mut statements = Vec::new();
2519 for event in events {
2520 let text = |key: &str| event.data[key].as_str().unwrap_or_default().to_lowercase();
2521 match event.kind.as_str() {
2522 "workspace.renamed" => {
2523 let Ok(renamed) = serde_json::from_value::<WorkspaceRenamed>(event.data.clone()) else {
2524 continue;
2525 };
2526 let (from, to) = (renamed.from.to_lowercase(), renamed.to.to_lowercase());
2527 if from == to {
2528 continue;
2529 }
2530 // A work item's origin may be in another workspace by now.
2531 statements.push(
2532 db.prepare(
2533 "UPDATE inbox_items SET workspace = ?2,
2534 repo = CASE WHEN repo LIKE ?1 || '/%' THEN ?2 || substr(repo, length(?1) + 1) ELSE repo END,
2535 link = CASE WHEN link LIKE '/' || ?1 || '/%' THEN '/' || ?2 || substr(link, length(?1) + 2) ELSE link END
2536 WHERE workspace = ?1",
2537 )
2538 .bind(&[from.as_str().into(), to.as_str().into()])?,
2539 );
2540 statements.push(
2541 db.prepare("UPDATE inbox_items SET repo = ?2 || substr(repo, length(?1) + 1) WHERE workspace IS NOT ?2 AND repo LIKE ?1 || '/%'")
2542 .bind(&[from.as_str().into(), to.as_str().into()])?,
2543 );
2544 statements.push(
2545 db.prepare("UPDATE inbox_watching SET repo = ?2 || substr(repo, length(?1) + 1) WHERE repo LIKE ?1 || '/%'")
2546 .bind(&[from.as_str().into(), to.as_str().into()])?,
2547 );
2548 }
2549 "repo.renamed" => {
2550 let path = format!("{}/{}", text("namespace"), text("to"));
2551 for table in ["inbox_items", "inbox_watching"] {
2552 statements.push(
2553 db.prepare(format!("UPDATE {table} SET repo = ? WHERE repo_id = ?"))
2554 .bind(&[path.as_str().into(), text("repoId").into()])?,
2555 );
2556 }
2557 }
2558 "repo.transferred" => {
2559 let path = format!("{}/{}", text("to"), text("name"));
2560 // A work item stays in its workspace when its origin moves.
2561 statements.push(
2562 db.prepare(
2563 "UPDATE inbox_items SET repo = ?1, workspace = CASE WHEN subject = 'item' THEN workspace ELSE ?2 END
2564 WHERE repo_id = ?3",
2565 )
2566 .bind(&[path.as_str().into(), text("to").into(), text("repoId").into()])?,
2567 );
2568 statements.push(
2569 db.prepare("UPDATE inbox_watching SET repo = ? WHERE repo_id = ?")
2570 .bind(&[path.as_str().into(), text("repoId").into()])?,
2571 );
2572 }
2573 // Its work items stay in their workspace, with no origin now.
2574 "repo.purged" => {
2575 for sql in [
2576 "UPDATE inbox_items SET repo_id = NULL, repo = NULL WHERE repo_id = ?1 AND subject = 'item'",
2577 "UPDATE inbox_subscriptions SET repo_id = NULL WHERE repo_id = ?1 AND thread LIKE 'item:%'",
2578 "DELETE FROM inbox_activity WHERE item_id IN (SELECT id FROM inbox_items WHERE repo_id = ?1)",
2579 "DELETE FROM inbox_items WHERE repo_id = ?1",
2580 "DELETE FROM inbox_subscriptions WHERE repo_id = ?1",
2581 "DELETE FROM inbox_watching WHERE repo_id = ?1",
2582 ] {
2583 statements.push(db.prepare(sql).bind(&[text("repoId").into()])?);
2584 }
2585 }
2586 "workspace.deleted" => {
2587 statements.push(
2588 db.prepare("DELETE FROM inbox_items WHERE workspace = ?")
2589 .bind(&[text("slug").into()])?,
2590 );
2591 statements.push(
2592 db.prepare("DELETE FROM inbox_watching WHERE repo LIKE ? || '/%'")
2593 .bind(&[text("slug").into()])?,
2594 );
2595 }
2596 // Issues moved into work items: what was kept by their numbers follows.
2597 "work.migrated" => statements.extend(crate::work_items::moved(db, event)?),
2598 // An account purged: its inbox, what it watched and its
2599 // settings go with it (identity's account_deletion.rs).
2600 "user.deleted" => {
2601 let username = text("username");
2602 if username.is_empty() {
2603 continue;
2604 }
2605 for sql in [
2606 "DELETE FROM inbox_activity WHERE username = ?",
2607 "DELETE FROM inbox_items WHERE username = ?",
2608 "DELETE FROM inbox_subscriptions WHERE username = ?",
2609 "DELETE FROM inbox_watching WHERE username = ?",
2610 "DELETE FROM inbox_settings WHERE username = ?",
2611 ] {
2612 statements.push(db.prepare(sql).bind(&[username.as_str().into()])?);
2613 }
2614 }
2615 _ => {}
2616 }
2617 }
2618 if !statements.is_empty() {
2619 db.batch(statements).await?;
2620 }
2621 Ok(())
2622}
2623
2624/// Removes items done more than [`DONE_DAYS`] ago, and any not saved older
2625/// than [`MAX_DAYS`], with their history. Returns how many went.
2626pub async fn purge(db: &D1Database, now: u64) -> Result<u32> {
2627 let done = crate::audit::keep_from(now, DONE_DAYS);
2628 let oldest = crate::audit::keep_from(now, MAX_DAYS);
2629 let statements: Vec<D1PreparedStatement> = vec![
2630 db.prepare("DELETE FROM inbox_items WHERE saved = 0 AND done_at < ?")
2631 .bind(&[done.into()])?,
2632 db.prepare("DELETE FROM inbox_items WHERE saved = 0 AND COALESCE(updated_at, created_at) < ?")
2633 .bind(&[oldest.into()])?,
2634 db.prepare("DELETE FROM inbox_activity WHERE NOT EXISTS (SELECT 1 FROM inbox_items WHERE inbox_items.id = inbox_activity.item_id)"),
2635 ];
2636 let results = db.batch(statements).await?;
2637 let mut removed = 0;
2638 for result in results.into_iter().take(2) {
2639 removed += result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) as u32;
2640 }
2641 Ok(removed)
2642}
2643
2644#[cfg(test)]
2645mod tests {
2646 use super::*;
2647 use crate::subscriptions::{State, Subscription, Watcher};
2648 use serde_json::json;
2649
2650 fn event(kind: &str, actor: Option<&str>, data: serde_json::Value) -> Event {
2651 Event {
2652 id: "evt_1".into(),
2653 kind: kind.into(),
2654 source: "work".into(),
2655 time: "2026-10-07T12:00:00.000Z".into(),
2656 repo_id: Some("rep_1".into()),
2657 actor: actor.map(str::to_owned),
2658 data,
2659 }
2660 }
2661
2662 fn person(id: &str, username: &str) -> Principal {
2663 Principal {
2664 id: id.into(),
2665 username: username.into(),
2666 }
2667 }
2668
2669 fn actor(id: &str, username: &str) -> Actor {
2670 Actor {
2671 id: Some(id.into()),
2672 username: Some(username.into()),
2673 }
2674 }
2675
2676 /// A pull request ana opened, assigned to bo, for a work item cy filed and dee is assigned.
2677 fn pull() -> InboxSubject {
2678 InboxSubject {
2679 kind: Some(SubjectKind::Pull),
2680 title: "Add the inbox".into(),
2681 author: person("usr_ana", "ana"),
2682 assignees: vec!["bo".into()],
2683 item: Some(Box::new(InboxSubject {
2684 kind: Some(SubjectKind::Item),
2685 title: "An inbox".into(),
2686 author: person("usr_cy", "cy"),
2687 assignees: vec!["dee".into()],
2688 ..InboxSubject::default()
2689 })),
2690 ..InboxSubject::default()
2691 }
2692 }
2693
2694 /// A change g1t made for ana.
2695 fn g1t_pull() -> InboxSubject {
2696 InboxSubject {
2697 author: person(AGENT_ID, "g1t"),
2698 requested_by: Some(person("usr_ana", "ana")),
2699 ..pull()
2700 }
2701 }
2702
2703 fn nobody() -> Audience {
2704 Audience::default()
2705 }
2706
2707 fn told(notices: &[Notice]) -> Vec<(&str, Reason, Severity)> {
2708 notices
2709 .iter()
2710 .map(|notice| (notice.username.as_str(), notice.reason, notice.severity))
2711 .collect()
2712 }
2713
2714 fn comment(author: Principal, body: &str, mentions: &[&str]) -> InboxSubject {
2715 InboxSubject {
2716 comment: Some(InboxComment {
2717 author,
2718 excerpt: body.into(),
2719 mentions: mentions.iter().map(|name| (*name).to_owned()).collect(),
2720 ..InboxComment::default()
2721 }),
2722 ..pull()
2723 }
2724 }
2725
2726 fn subscribed(rows: &[(&str, State, Option<Reason>)]) -> Audience {
2727 Audience {
2728 subscriptions: rows
2729 .iter()
2730 .map(|(name, state, reason)| Subscription {
2731 username: (*name).into(),
2732 state: *state,
2733 reason: *reason,
2734 })
2735 .collect(),
2736 watchers: Vec::new(),
2737 }
2738 }
2739
2740 fn watched(rows: &[(&str, WatchLevel, &[&str])]) -> Audience {
2741 Audience {
2742 subscriptions: Vec::new(),
2743 watchers: rows
2744 .iter()
2745 .map(|(name, level, events)| Watcher {
2746 username: (*name).into(),
2747 level: *level,
2748 events: events.iter().map(|kind| (*kind).to_owned()).collect(),
2749 })
2750 .collect(),
2751 }
2752 }
2753
2754 #[test]
2755 fn only_events_that_tell_someone_are_read() {
2756 let asked = |kind: &str, data| wants(&event(kind, None, data));
2757 assert_eq!(
2758 asked("checks.completed", json!({ "repoId": "rep_1", "number": 4, "status": "failed" })),
2759 Some(Wanted { repo_id: Some("rep_1".into()), number: Some(4), ..Wanted::default() })
2760 );
2761 assert_eq!(asked("checks.completed", json!({ "repoId": "rep_1", "number": 4, "status": "passed" })), None);
2762 assert_eq!(asked("review.completed", json!({ "repoId": "rep_1", "number": 4, "verdict": "approve" })), None);
2763 assert_eq!(asked("agent.asked", json!({ "repoId": "rep_1", "number": 4 })), None);
2764 assert_eq!(asked("workflow.completed", json!({ "repoId": "rep_1", "conclusion": "success" })), None);
2765 assert_eq!(
2766 asked("workflow.completed", json!({ "repoId": "rep_1", "conclusion": "failure" })),
2767 Some(Wanted { repo_id: Some("rep_1".into()), ..Wanted::default() })
2768 );
2769 assert_eq!(
2770 asked("comment.created", json!({ "repoId": "rep_1", "number": 2, "commentId": "cmt_1" })).map(|w| w.comment_id),
2771 Some(Some("cmt_1".into()))
2772 );
2773 assert_eq!(asked("git.push", json!({ "repoId": "rep_1" })), None);
2774 for kind in ["pull.review_requested", "pull.stalled", "pull.closed"] {
2775 assert!(asked(kind, json!({ "repoId": "rep_1", "number": 1 })).is_some(), "{kind}");
2776 }
2777 assert_eq!(asked("deployment.failed", json!({ "repoId": "rep_1" })).map(|w| w.number), Some(None));
2778 }
2779
2780 #[test]
2781 fn a_work_items_events_are_read_by_its_id_with_or_without_an_origin() {
2782 let item = json!({ "itemId": "wki_1", "workspace": "Acme", "number": 40, "repoIds": ["rep_1", "rep_2"] });
2783 for kind in ITEM_TOLD {
2784 let wanted = wants(&event(kind, None, item.clone())).unwrap_or_else(|| panic!("{kind}"));
2785 assert_eq!(wanted.repo_id.as_deref(), Some("rep_1"));
2786 assert_eq!(wanted.number, Some(40));
2787 assert_eq!(
2788 wanted.item,
2789 Some(WantedItem { id: "wki_1".into(), workspace: "acme".into(), repo_ids: vec!["rep_1".into(), "rep_2".into()] })
2790 );
2791 }
2792 // Filed in the workspace: no origin.
2793 let filed = Event { repo_id: None, ..event("work_item.opened", None, item.clone()) };
2794 assert_eq!(wants(&filed).map(|wanted| wanted.repo_id), Some(None));
2795 // A comment on one names it; one on a pull request does not.
2796 let comment = json!({ "itemId": "wki_1", "workspace": "acme", "number": 40, "commentId": "cmt_1", "repoIds": [] });
2797 let wanted = wants(&Event { repo_id: None, ..event("comment.created", None, comment) }).unwrap();
2798 assert_eq!((wanted.comment_id.as_deref(), wanted.item.map(|item| item.id)), (Some("cmt_1"), Some("wki_1".into())));
2799 // Without its id there is nothing to read.
2800 assert_eq!(wants(&event("work_item.opened", None, json!({ "number": 40 }))), None);
2801 // What the inbox does not tell of.
2802 assert_eq!(wants(&event("work_item.labeled", None, item)), None);
2803 }
2804
2805 #[test]
2806 fn a_work_item_is_one_thread_in_its_workspace() {
2807 let data = json!({ "itemId": "wki_1", "workspace": "acme", "number": 40, "repoIds": ["rep_1"] });
2808 let opened = event("work_item.opened", None, data.clone());
2809 let wanted = wants(&opened).unwrap();
2810 let thread = thread_of(&opened, &wanted, None);
2811 assert_eq!(thread.key, "item:wki_1");
2812 assert_eq!(thread.kind, Some(SubjectKind::Item));
2813 assert_eq!(url(Some("acme/rocket"), Some("acme"), thread.kind, thread.number, None, None), "/acme/-/work/40");
2814 // Closing it settles everything on it, and says what landed it.
2815 let closed = event(
2816 "work_item.closed",
2817 None,
2818 json!({ "itemId": "wki_1", "workspace": "acme", "number": 40, "resolution": { "repo": "acme/api", "pull": 9 } }),
2819 );
2820 assert_eq!(closes(&closed, Some("bo")), vec![Settle::thread("item:wki_1").saying("Closed", Some("bo"))]);
2821 let item = InboxSubject { kind: Some(SubjectKind::Item), title: "Ship it".into(), author: person("usr_cy", "cy"), ..InboxSubject::default() };
2822 assert_eq!(notices(&closed, "acme", &Actor::default(), Some(&item), &nobody())[0].title, "acme#40 was closed by acme/api#9");
2823 // A comment on it is handled on its thread.
2824 let comment = event("comment.created", None, json!({ "itemId": "wki_1", "workspace": "acme", "number": 40, "commentId": "cmt_1" }));
2825 let on = InboxSubject {
2826 comment: Some(InboxComment { author: person("usr_bo", "bo"), ..InboxComment::default() }),
2827 ..item
2828 };
2829 assert_eq!(handled_by(&comment, Some(&on)).map(|settle| settle.scope), Some(Scope::Thread("item:wki_1".into())));
2830 assert!(subscribable("item:wki_1") && subscribable("rep_1#7") && !subscribable("rep_1/run/ci@main"));
2831 }
2832
2833 #[test]
2834 fn a_work_items_watchers_are_its_origins_then_its_other_repositories() {
2835 let watcher = |name: &str, level: WatchLevel| Watcher { username: name.into(), level, events: Vec::new() };
2836 let own = vec![watcher("ana", WatchLevel::All), watcher("bo", WatchLevel::Ignore)];
2837 let others = vec![vec![watcher("bo", WatchLevel::All), watcher("cy", WatchLevel::All), watcher("dee", WatchLevel::Ignore)]];
2838 let (watchers, elsewhere) = subscriptions::linked_watchers(own, &others);
2839 let names: Vec<(&str, WatchLevel)> = watchers.iter().map(|w| (w.username.as_str(), w.level)).collect();
2840 // Ignoring the origin stands; ignoring another repository says nothing.
2841 assert_eq!(names, vec![("ana", WatchLevel::All), ("bo", WatchLevel::Ignore), ("cy", WatchLevel::All)]);
2842 assert_eq!(elsewhere.into_iter().collect::<Vec<_>>(), vec!["cy".to_owned()]);
2843 }
2844
2845 #[test]
2846 fn a_stopped_pull_request_needs_its_people_and_its_work_items_people_first() {
2847 let stalled = event("pull.stalled", None, json!({ "number": 7 }));
2848 let notices_ = notices(&stalled, "acme/rocket", &Actor::default(), Some(&pull()), &nobody());
2849 assert_eq!(
2850 told(&notices_),
2851 vec![
2852 ("ana", Reason::Agent, Severity::Warning),
2853 ("cy", Reason::Agent, Severity::Warning),
2854 ("dee", Reason::Agent, Severity::Warning),
2855 ]
2856 );
2857 assert_eq!(notices_[0].title, "g1t stopped on acme/rocket#7 and needs you");
2858 assert_eq!(notices_[0].body, "Add the inbox");
2859 // Stopping says why; even someone who unsubscribed hears of it.
2860 let stalled = event("pull.stalled", None, json!({ "number": 7, "detail": "Its checks could not be run." }));
2861 let audience = subscribed(&[("ana", State::Unsubscribed, None)]);
2862 let notices_ = notices(&stalled, "acme/rocket", &Actor::default(), Some(&g1t_pull()), &audience);
2863 assert_eq!(notices_[0].username, "ana");
2864 assert_eq!(notices_[0].title, "g1t stopped on acme/rocket#7 and needs you");
2865 assert_eq!(notices_[0].body, "Its checks could not be run.");
2866 // Ignoring the thread silences even that.
2867 let audience = subscribed(&[("ana", State::Ignored, None)]);
2868 let notices_ = notices(&stalled, "acme/rocket", &Actor::default(), Some(&g1t_pull()), &audience);
2869 assert!(!notices_.iter().any(|notice| notice.username == "ana"));
2870 }
2871
2872 #[test]
2873 fn whoever_turned_on_auto_merge_hears_it_is_off() {
2874 let off = event("pull.auto_merge_disabled", None, json!({ "number": 7, "autoMergeBy": "bo", "detail": "Its branch conflicts with main." }));
2875 let notices_ = notices(&off, "acme/rocket", &Actor::default(), Some(&pull()), &nobody());
2876 assert_eq!(told(&notices_), vec![("bo", Reason::StateChange, Severity::Warning)]);
2877 assert_eq!(notices_[0].title, "Auto-merge was turned off on acme/rocket#7");
2878 assert_eq!(notices_[0].body, "Its branch conflicts with main.");
2879 // Turning it off yourself tells no one.
2880 let notices_ = notices(&off, "acme/rocket", &actor("usr_bo", "bo"), Some(&pull()), &nobody());
2881 assert!(notices_.is_empty());
2882 }
2883
2884 #[test]
2885 fn g1t_handing_a_pull_request_to_the_merge_queue_tells_no_one() {
2886 let off = event(
2887 "pull.auto_merge_disabled",
2888 None,
2889 json!({ "number": 7, "autoMergeBy": "bo", "detail": "It joined the merge queue.", "queued": true }),
2890 );
2891 assert!(notices(&off, "acme/rocket", &Actor::default(), Some(&pull()), &nobody()).is_empty());
2892 }
2893
2894 #[test]
2895 fn whatever_an_agent_waits_on_closes_when_it_goes_on() {
2896 for kind in RESUMES {
2897 let settled = settles(&event(kind, None, json!({ "repoId": "rep_1", "number": 7 })), Some("ana"));
2898 let agent = settled.iter().find(|settle| settle.reasons == [Reason::Agent]).unwrap_or_else(|| panic!("{kind}"));
2899 assert_eq!(agent.scope, Scope::Thread("rep_1#7".into()));
2900 assert_eq!(agent.why.as_deref(), Some(resumed_because(kind)));
2901 assert_eq!(agent.by.as_deref(), Some("ana"));
2902 }
2903 assert_eq!(resumed_because("pull.updated"), "New commits were pushed");
2904 assert!(settles(&event("comment.created", None, json!({ "repoId": "rep_1", "number": 7 })), None).is_empty());
2905 }
2906
2907 #[test]
2908 fn a_merge_or_a_close_settles_everything_about_it() {
2909 let merged = event("pull.merged", Some("usr_ana"), json!({ "repoId": "rep_1", "number": 7 }));
2910 assert_eq!(
2911 closes(&merged, Some("Ana")),
2912 vec![
2913 Settle::thread("rep_1#7").saying("Merged", Some("ana")),
2914 Settle::matching("rep_1/deploy/*/7").saying("Merged", Some("ana")),
2915 ]
2916 );
2917 let closed = event("pull.closed", None, json!({ "repoId": "rep_1", "number": 7 }));
2918 assert_eq!(closes(&closed, None).len(), 2);
2919 assert_eq!(closes(&closed, None)[0].why.as_deref(), Some("Closed"));
2920 assert_eq!(closes(&closed, None)[0].by, None);
2921 // A work item has no previews.
2922 let item = event("work_item.closed", None, json!({ "itemId": "wki_1", "workspace": "acme", "number": 3, "reason": "not_planned" }));
2923 assert_eq!(closes(&item, Some("bo")), vec![Settle::thread("item:wki_1").saying("Closed as not planned", Some("bo"))]);
2924 // Reopening is something to hear of, and settles nothing.
2925 assert!(closes(&event("work_item.reopened", None, json!({ "itemId": "wki_1", "number": 3 })), None).is_empty());
2926 assert!(settles(&event("work_item.reopened", None, json!({ "itemId": "wki_1", "number": 3 })), None).is_empty());
2927 }
2928
2929 #[test]
2930 fn a_workflow_passing_on_a_branch_settles_its_failures_there() {
2931 let data = |conclusion: &str| {
2932 json!({ "repoId": "rep_1", "runId": "run_9", "workflow": "Deploy", "path": ".g1t/workflows/deploy.yml", "ref": "refs/heads/main", "conclusion": conclusion, "number": 328 })
2933 };
2934 let failed = event("workflow.completed", Some("usr_bo"), data("failure"));
2935 let thread = thread_of(&failed, &wants(&failed).unwrap(), None).key;
2936 assert_eq!(thread, "rep_1/run/.g1t/workflows/deploy.yml@main");
2937 let passed = event("workflow.completed", Some("usr_bo"), data("success"));
2938 assert!(wants(&passed).is_none(), "a success tells no one");
2939 // Whoever started the run did not settle anything: the run passing did.
2940 assert_eq!(
2941 settles(&passed, Some("bo")),
2942 vec![
2943 Settle::thread(thread.clone()).saying("Deploy passed in run 328", None),
2944 Settle::matching("rep_1/review/run_9/*").because(&[Reason::ReviewRequested]).saying("Deploy finished in run 328", None),
2945 ]
2946 );
2947 // A failure or a cancelled run settles no failure, but ends what its deployments waited for.
2948 for conclusion in ["failure", "cancelled"] {
2949 assert_eq!(
2950 settles(&event("workflow.completed", None, data(conclusion)), None),
2951 vec![Settle::matching("rep_1/review/run_9/*").because(&[Reason::ReviewRequested]).saying("Deploy finished in run 328", None)]
2952 );
2953 }
2954 // On a pull request, its checks passing settles the failure instead.
2955 let on_pull = event("workflow.completed", None, json!({ "repoId": "rep_1", "runId": "run_9", "conclusion": "success", "pull": 7 }));
2956 assert!(!settles(&on_pull, None).iter().any(|settle| settle.scope == Scope::Thread(thread.clone())));
2957 }
2958
2959 #[test]
2960 fn checks_passing_settle_the_failure_its_owner_was_told_of() {
2961 let passed = event("checks.completed", None, json!({ "repoId": "rep_1", "number": 7, "status": "passed", "commit": "abcdef0123456" }));
2962 assert_eq!(
2963 settles(&passed, None),
2964 vec![Settle::thread("rep_1#7").because(&[Reason::CiActivity]).saying("Checks passed on abcdef0", None)]
2965 );
2966 let failed = event("checks.completed", None, json!({ "repoId": "rep_1", "number": 7, "status": "failed" }));
2967 assert!(settles(&failed, None).is_empty());
2968 }
2969
2970 #[test]
2971 fn a_deployment_settles_the_failure_before_it() {
2972 let live = event("deployment.succeeded", Some("usr_bo"), json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "web", "commit": "1234567890" }));
2973 assert_eq!(
2974 settles(&live, Some("bo")),
2975 vec![Settle::thread("rep_1/deploy/prj_1/production").after(&["deployment.failed"]).saying("Production of web deployed at 1234567", Some("bo"))]
2976 );
2977 // Started by g1t or a workflow: the deployment names who.
2978 let preview = event("deployment.succeeded", None, json!({ "repoId": "rep_1", "projectId": "prj_1", "number": 7, "triggeredBy": "g1t" }));
2979 assert_eq!(
2980 settles(&preview, None),
2981 vec![Settle::thread("rep_1/deploy/prj_1/7").after(&["deployment.failed"]).saying("Preview of #7 deployed", Some("g1t"))]
2982 );
2983 }
2984
2985 #[test]
2986 fn requests_answered_settle_what_they_asked() {
2987 let removed = event(
2988 "pull.review_request_removed",
2989 None,
2990 json!({ "repoId": "rep_1", "number": 7, "reviewers": ["Bo"], "teams": [{ "team": "acme/web", "notified": ["cy"] }] }),
2991 );
2992 let mut people: Vec<Option<String>> = settles(&removed, Some("ana")).into_iter().map(|settle| settle.username).collect();
2993 people.sort();
2994 assert_eq!(people, vec![Some("bo".to_owned()), Some("cy".to_owned())]);
2995 assert!(settles(&removed, Some("ana")).iter().all(|settle| settle.why.as_deref() == Some("Review no longer asked of you")));
2996 let token = event("token.approval_reviewed", None, json!({ "workspace": "acme", "tokenId": "tok_1" }));
2997 assert_eq!(
2998 settles(&token, Some("ana")),
2999 vec![Settle::thread("workspace:acme/token/tok_1").because(&[Reason::ReviewRequested]).saying("Token request decided", Some("ana"))]
3000 );
3001 let bypass = event("secret_scanning.bypass_reviewed", None, json!({ "repoId": "rep_1", "requestId": "byp_1" }));
3002 assert_eq!(
3003 settles(&bypass, None),
3004 vec![Settle::thread("rep_1/security/byp_1").because(&[Reason::SecurityAlert]).saying("Bypass request decided", None)]
3005 );
3006 let invitation = event("workspace_invitation.declined", None, json!({ "thread": "invitation:inv_1" }));
3007 assert_eq!(
3008 settles(&invitation, Some("cy")),
3009 vec![Settle::thread("invitation:inv_1").because(&[Reason::ReviewRequested]).saying("Invitation declined", Some("cy"))]
3010 );
3011 }
3012
3013 #[test]
3014 fn your_own_comment_settles_your_item_on_the_thread() {
3015 let commented = event("comment.created", Some("usr_bo"), json!({ "repoId": "rep_1", "number": 7, "commentId": "cmt_1" }));
3016 let by_bo = comment(person("usr_bo", "Bo"), "Looks good", &[]);
3017 assert_eq!(handled_by(&commented, Some(&by_bo)), Some(Settle::thread("rep_1#7").of("bo").saying("Commented", Some("bo"))));
3018 let mut review = comment(person("usr_bo", "Bo"), "Ship it", &[]);
3019 review.comment.as_mut().unwrap().verdict = Some("approve".into());
3020 assert_eq!(handled_by(&commented, Some(&review)).and_then(|settle| settle.why), Some("Reviewed".to_owned()));
3021 // Not g1t's, nor an agent's, nor a note the service wrote.
3022 let by_g1t = comment(person(AGENT_ID, "g1t"), "Done", &[]);
3023 assert_eq!(handled_by(&commented, Some(&by_g1t)), None);
3024 let mut note = comment(person("usr_bo", "bo"), "", &[]);
3025 note.comment.as_mut().unwrap().event = true;
3026 assert_eq!(handled_by(&commented, Some(&note)), None);
3027 assert_eq!(handled_by(&event("pull.merged", None, json!({ "repoId": "rep_1", "number": 7 })), Some(&by_bo)), None);
3028 }
3029
3030 #[test]
3031 fn a_deployments_reviewers_are_told() {
3032 let asked = event(
3033 "deployment.review_requested",
3034 Some("usr_ana"),
3035 json!({
3036 "repoId": "rep_1", "runId": "run_7", "environment": "production", "workflow": "Deploy",
3037 "title": "Ship it", "notify": ["cy", "ana"], "link": "/acme/rocket/actions/runs/run_7"
3038 }),
3039 );
3040 let wanted = wants(&asked).unwrap();
3041 assert_eq!(wanted.number, None);
3042 let thread = thread_of(&asked, &wanted, None);
3043 assert_eq!(thread.key, "rep_1/review/run_7/production");
3044 assert_eq!(thread.link.as_deref(), Some("/acme/rocket/actions/runs/run_7"));
3045 let told = notices(&asked, "acme/rocket", &actor("usr_ana", "ana"), None, &nobody());
3046 let names: Vec<&str> = told.iter().map(|notice| notice.username.as_str()).collect();
3047 // Whoever started the run is told too, when they review it.
3048 assert_eq!(names, ["cy", "ana"]);
3049 assert!(told[0].title.contains("waiting for your review to deploy to production"));
3050 }
3051
3052 #[test]
3053 fn teams_asked_to_review_tell_the_people_they_name() {
3054 let requested = event(
3055 "pull.review_requested",
3056 Some("usr_ana"),
3057 json!({ "number": 7, "reviewers": ["cy"], "teams": [
3058 { "team": "acme/backend", "notified": ["cy", "dee", "ana"], "assigned": ["cy"] }
3059 ] }),
3060 );
3061 let notices_ = notices(&requested, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody());
3062 // cy was picked and is told as a reviewer; dee through the team;
3063 // never whoever asked.
3064 assert_eq!(
3065 told(&notices_),
3066 vec![("cy", Reason::ReviewRequested, Severity::Warning), ("dee", Reason::ReviewRequested, Severity::Warning)]
3067 );
3068 assert_eq!(notices_[0].title, "ana asked you to review acme/rocket#7");
3069 assert_eq!(notices_[1].title, "ana asked @acme/backend to review acme/rocket#7");
3070 assert_eq!(
3071 subscribes(&requested, Some(&pull())),
3072 vec![("cy".into(), Reason::ReviewRequested), ("dee".into(), Reason::ReviewRequested), ("ana".into(), Reason::ReviewRequested)]
3073 );
3074 // Asked by the CODEOWNERS file.
3075 let owned = event(
3076 "pull.review_requested",
3077 None,
3078 json!({ "number": 7, "reviewers": ["bo"], "codeOwners": true, "teams": [{ "team": "acme/docs", "notified": ["wren"], "assigned": [] }] }),
3079 );
3080 let notices_ = notices(&owned, "acme/rocket", &Actor::default(), Some(&pull()), &nobody());
3081 assert_eq!(notices_[0].title, "acme/rocket#7 changes files you own");
3082 assert_eq!(notices_[1].title, "acme/rocket#7 changes files @acme/docs owns");
3083 }
3084
3085 #[test]
3086 fn a_team_mention_tells_its_people_once_and_a_name_wins() {
3087 let mut on = comment(person("usr_bo", "bo"), "cc @ana @acme/backend", &["ana"]);
3088 if let Some(comment) = on.comment.as_mut() {
3089 comment.team_mentions = vec![TeamMentioned {
3090 team: "acme/backend".into(),
3091 members: vec!["ana".into(), "cy".into(), "bo".into()],
3092 }];
3093 }
3094 let created = event("comment.created", Some("usr_bo"), json!({ "number": 7, "commentId": "cmt_1" }));
3095 let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &nobody());
3096 let mentioned: Vec<(&str, Reason)> = notices_.iter().map(|n| (n.username.as_str(), n.reason)).filter(|(_, r)| matches!(r, Reason::Mention | Reason::TeamMention)).collect();
3097 assert_eq!(mentioned, vec![("ana", Reason::Mention), ("cy", Reason::TeamMention)]);
3098 assert_eq!(
3099 notices_.iter().find(|n| n.username == "cy").unwrap().title,
3100 "bo mentioned @acme/backend on acme/rocket#7"
3101 );
3102 let subscribed = subscribes(&created, Some(&on));
3103 assert!(subscribed.contains(&("cy".into(), Reason::TeamMention)));
3104 assert!(subscribed.contains(&("ana".into(), Reason::Mention)));
3105 }
3106
3107 #[test]
3108 fn a_description_tells_the_people_and_teams_it_mentions() {
3109 let mut on = pull();
3110 on.mentions = vec!["dee".into()];
3111 on.team_mentions = vec![TeamMentioned {
3112 team: "acme/web".into(),
3113 members: vec!["eve".into()],
3114 }];
3115 let opened = event("pull.opened", Some("usr_ana"), json!({ "number": 7 }));
3116 let notices_ = notices(&opened, "acme/rocket", &actor("usr_ana", "ana"), Some(&on), &nobody());
3117 assert!(told(&notices_).contains(&("dee", Reason::Mention, Severity::Info)));
3118 assert!(told(&notices_).contains(&("eve", Reason::TeamMention, Severity::Info)));
3119 assert_eq!(
3120 subscribes(&opened, Some(&on)),
3121 vec![("dee".into(), Reason::Mention), ("eve".into(), Reason::TeamMention)]
3122 );
3123 }
3124
3125 #[test]
3126 fn reviewers_and_assignees_asked_are_told_never_whoever_asked() {
3127 let requested = event("pull.review_requested", Some("usr_ana"), json!({ "number": 7, "reviewers": ["bo", "g1t", "ana"] }));
3128 let notices_ = notices(&requested, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody());
3129 assert_eq!(told(&notices_), vec![("bo", Reason::ReviewRequested, Severity::Warning)]);
3130 assert_eq!(notices_[0].title, "ana asked you to review acme/rocket#7");
3131 let assigned = event("work_item.assigned", Some("usr_ana"), json!({ "number": 3, "added": ["ana", "eve"] }));
3132 let notices_ = notices(&assigned, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody());
3133 assert_eq!(told(&notices_), vec![("eve", Reason::Assign, Severity::Info)]);
3134 assert_eq!(notices_[0].title, "ana assigned you to acme/rocket#3");
3135 // Both subscribe whoever they name, never g1t.
3136 assert_eq!(subscribes(&requested, Some(&pull())), vec![("bo".into(), Reason::ReviewRequested), ("ana".into(), Reason::ReviewRequested)]);
3137 assert_eq!(subscribes(&assigned, Some(&pull())), vec![("ana".into(), Reason::Assign), ("eve".into(), Reason::Assign)]);
3138 }
3139
3140 #[test]
3141 fn failures_go_to_whoever_answers_for_the_change() {
3142 let failed = event("checks.completed", None, json!({ "number": 7, "status": "failed" }));
3143 assert_eq!(
3144 told(&notices(&failed, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())),
3145 vec![("ana", Reason::CiActivity, Severity::Error)]
3146 );
3147 // g1t's change is the person's who asked for it, never g1t's.
3148 let notices_ = notices(&failed, "acme/rocket", &Actor::default(), Some(&g1t_pull()), &nobody());
3149 assert_eq!(told(&notices_), vec![("ana", Reason::CiActivity, Severity::Error)]);
3150 let errored = event("checks.completed", None, json!({ "number": 7, "status": "errored" }));
3151 assert_eq!(
3152 notices(&errored, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())[0].title,
3153 "Checks could not run on acme/rocket#7"
3154 );
3155 }
3156
3157 #[test]
3158 fn a_workflow_that_fails_tells_its_pull_requests_owner_even_if_they_pushed() {
3159 let failed = event("workflow.completed", Some("usr_ana"), json!({ "pull": 7, "workflow": "CI", "conclusion": "failure" }));
3160 let notices_ = notices(&failed, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody());
3161 assert_eq!(told(&notices_), vec![("ana", Reason::CiActivity, Severity::Error)]);
3162 assert_eq!(notices_[0].title, "CI failed on acme/rocket#7");
3163 // On a branch: whoever pushed.
3164 let pushed = event(
3165 "workflow.completed",
3166 Some("usr_bo"),
3167 json!({ "workflow": "Deploy", "path": ".g1t/workflows/deploy.yml", "conclusion": "failure", "ref": "refs/heads/main", "number": 12, "sha": "abcdef0123", "runId": "run_9" }),
3168 );
3169 let notices_ = notices(&pushed, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody());
3170 assert_eq!(told(&notices_), vec![("bo", Reason::CiActivity, Severity::Error)]);
3171 assert_eq!(notices_[0].title, "Deploy failed on main in acme/rocket");
3172 assert_eq!(notices_[0].body, "Run 12 at abcdef0");
3173 // Nobody to tell when g1t pushed.
3174 assert!(notices(&pushed, "acme/rocket", &actor("g1t", "g1t"), None, &nobody()).is_empty());
3175 // Every failure of a workflow on a branch is one thread.
3176 let wanted = wants(&pushed).unwrap();
3177 let thread = thread_of(&pushed, &wanted, None);
3178 assert_eq!(thread.key, "rep_1/run/.g1t/workflows/deploy.yml@main");
3179 assert_eq!(thread.run_id.as_deref(), Some("run_9"));
3180 }
3181
3182 #[test]
3183 fn deployments_tell_whoever_answers_for_them_and_watchers() {
3184 let failed = event(
3185 "deployment.failed",
3186 Some("usr_bo"),
3187 json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "rocket", "deploymentId": "dpl_1", "error": "The build failed.", "path": "/acme/rocket/deployments/dpl_1" }),
3188 );
3189 let audience = watched(&[("cy", WatchLevel::Custom, &["deployments"]), ("dee", WatchLevel::Custom, &["work_items"]), ("eve", WatchLevel::All, &[])]);
3190 let notices_ = notices(&failed, "acme/rocket", &actor("usr_bo", "bo"), None, &audience);
3191 assert_eq!(
3192 told(&notices_),
3193 vec![
3194 ("bo", Reason::CiActivity, Severity::Error),
3195 ("cy", Reason::Subscribed, Severity::Error),
3196 ("eve", Reason::Subscribed, Severity::Error),
3197 ]
3198 );
3199 assert_eq!(notices_[0].title, "Production of rocket failed to deploy");
3200 assert_eq!(notices_[0].body, "The build failed.");
3201 let thread = thread_of(&failed, &wants(&failed).unwrap(), None);
3202 assert_eq!(thread.key, "rep_1/deploy/prj_1/production");
3203 assert_eq!(url(Some("acme/rocket"), None, thread.kind, None, None, thread.link.as_deref()), "/acme/rocket/deployments/dpl_1");
3204 // A success tells the owner nothing, even after a failure: it
3205 // settles the failure (settles). Watchers who chose deployments hear of it.
3206 let live = event("deployment.succeeded", Some("usr_bo"), json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "rocket", "commit": "abcdef0123" }));
3207 assert!(notices(&live, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody()).is_empty());
3208 let recovered = event("deployment.succeeded", Some("usr_bo"), json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "rocket", "recovered": true }));
3209 assert!(notices(&recovered, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody()).is_empty());
3210 let notices_ = notices(&recovered, "acme/rocket", &actor("usr_bo", "bo"), None, &audience);
3211 assert_eq!(told(&notices_), vec![("cy", Reason::Subscribed, Severity::Success), ("eve", Reason::Subscribed, Severity::Success)]);
3212 assert_eq!(notices_[0].title, "Production of rocket is live");
3213 // A preview's is the pull request's owner's.
3214 let preview = event("deployment.failed", None, json!({ "repoId": "rep_1", "projectId": "prj_1", "number": 7, "triggeredBy": "g1t" }));
3215 assert_eq!(told(&notices(&preview, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())), vec![("ana", Reason::CiActivity, Severity::Error)]);
3216 assert_eq!(thread_of(&preview, &wants(&preview).unwrap(), Some(&pull())).key, "rep_1/deploy/prj_1/7");
3217 }
3218
3219 /// A scan's summary, as the security service publishes it.
3220 fn summary(alert_type: &str, opened: &[&str], new: (u32, u32, u32, u32), open: (u32, u32, u32, u32), pusher: Option<&str>) -> Event {
3221 let counts = |(critical, high, medium, low): (u32, u32, u32, u32)| json!({ "critical": critical, "high": high, "medium": medium, "low": low, "unknown": 0 });
3222 let worst = [(new.0, "critical"), (new.1, "high"), (new.2, "medium"), (new.3, "low")].into_iter().find(|(n, _)| *n > 0).map(|(_, name)| name);
3223 let page = match alert_type {
3224 "secret_scanning" => "secret-scanning",
3225 "code_scanning" => "code-scanning",
3226 _ => "vulnerabilities",
3227 };
3228 event(
3229 &format!("{alert_type}_alert.summary"),
3230 None,
3231 json!({
3232 "repoId": "rep_1", "alertType": alert_type, "scan": if pusher.is_some() { "push" } else { "dependencies" },
3233 "opened": opened, "openedCount": opened.len(), "fixedCount": 0, "dismissedCount": 0,
3234 "newBySeverity": counts(new), "open": counts(open), "worst": worst,
3235 "latest": if opened.is_empty() { serde_json::Value::Null } else { json!("lodash 4.17.20 in package-lock.json: GHSA-1") },
3236 "link": format!("/acme/rocket/security/{page}"),
3237 "pusher": pusher, "blocked": if pusher.is_some() { opened.len() } else { 0 },
3238 "notify": ["ana"], "members": ["ana", "bo", "cy"]
3239 }),
3240 )
3241 }
3242
3243 #[test]
3244 fn a_scan_is_one_item_per_repository_and_alert_type() {
3245 let found = summary("vulnerability", &["vul_3", "vul_2", "vul_1"], (1, 2, 0, 0), (1, 2, 0, 0), None);
3246 assert_eq!(wants(&found), Some(Wanted { repo_id: Some("rep_1".into()), ..Wanted::default() }));
3247 // cy watches for security alerts and is a member; eve watches
3248 // everything but cannot see findings; dee watches work items only.
3249 let audience = watched(&[
3250 ("cy", WatchLevel::Custom, &["security"]),
3251 ("dee", WatchLevel::Custom, &["work_items"]),
3252 ("eve", WatchLevel::All, &[]),
3253 ]);
3254 let notices_ = notices(&found, "acme/rocket", &Actor::default(), None, &audience);
3255 assert_eq!(told(&notices_), vec![("ana", Reason::SecurityAlert, Severity::Error), ("cy", Reason::SecurityAlert, Severity::Error)]);
3256 assert_eq!(notices_[0].title, "3 new vulnerabilities in acme/rocket: 1 critical, 2 high");
3257 assert_eq!(notices_[0].body, "lodash 4.17.20 in package-lock.json: GHSA-1");
3258 // The same thread for every scan of this type, however many alerts.
3259 let thread = thread_of(&found, &wants(&found).unwrap(), None);
3260 assert_eq!(thread.key, "rep_1/security/vulnerability");
3261 assert_eq!(url(Some("acme/rocket"), None, thread.kind, None, None, thread.link.as_deref()), "/acme/rocket/security/vulnerabilities");
3262 // Medium and low are news, but not urgent: an info item, or the digest.
3263 let quiet = summary("code_scanning", &["cod_1"], (0, 0, 1, 0), (0, 0, 1, 0), None);
3264 let notices_ = notices(&quiet, "acme/rocket", &Actor::default(), None, &audience);
3265 assert_eq!(told(&notices_), vec![("ana", Reason::SecurityAlert, Severity::Info), ("cy", Reason::SecurityAlert, Severity::Info)]);
3266 assert_eq!(notices_[0].title, "1 new code scanning alert in acme/rocket: 1 medium");
3267 // A scan that opened nothing tells nobody: the thread is brought up to date instead.
3268 let fixed = summary("vulnerability", &[], (0, 0, 0, 0), (0, 1, 0, 0), None);
3269 assert!(notices(&fixed, "acme/rocket", &Actor::default(), None, &audience).is_empty());
3270 let (title, body) = refreshed_text("acme/rocket", &SecuritySummary { fixed_count: 2, ..security_summary(&fixed).unwrap() });
3271 assert_eq!(title, "1 open vulnerability in acme/rocket: 1 high");
3272 assert_eq!(body, "2 fixed");
3273 let settled = SecuritySummary { fixed_count: 1, dismissed_count: 1, open: SeverityCounts::default(), ..security_summary(&fixed).unwrap() };
3274 assert_eq!(refreshed_text("acme/rocket", &settled), ("No open vulnerabilities in acme/rocket".to_owned(), "1 fixed, 1 dismissed".to_owned()));
3275 // Per-alert events are for webhooks.
3276 assert_eq!(wants(&event("vulnerability_alert.created", None, json!({ "repoId": "rep_1" }))), None);
3277 assert_eq!(wants(&event("code_scanning_alert.fixed", None, json!({ "repoId": "rep_1" }))), None);
3278 }
3279
3280 #[test]
3281 fn a_refused_push_tells_the_pusher_at_once_and_the_owners_once() {
3282 let blocked = summary("secret_scanning", &["sec_1", "sec_2"], (2, 0, 0, 0), (2, 0, 0, 0), Some("bo"));
3283 let notices_ = notices(&blocked, "acme/rocket", &Actor::default(), None, &nobody());
3284 assert_eq!(told(&notices_), vec![("bo", Reason::SecurityAlert, Severity::Error), ("ana", Reason::SecurityAlert, Severity::Error)]);
3285 assert_eq!(notices_[0].title, "A push to acme/rocket was blocked: it adds 2 secrets");
3286 assert_eq!(notices_[1].title, "2 new secrets in acme/rocket: 2 critical");
3287 assert_eq!(thread_of(&blocked, &wants(&blocked).unwrap(), None).key, "rep_1/security/secret_scanning");
3288 // Whatever bo chose, the push is theirs to fix; ana, an owner, is told whatever she chose.
3289 let summary_ = security_summary(&blocked).unwrap();
3290 let off = InboxSettings { security_alerts: false, security_digest: DigestCadence::Off, ..InboxSettings::default() };
3291 assert_eq!(security_delivery(&summary_, "bo", &off), Delivery::Now);
3292 assert_eq!(security_delivery(&summary_, "ana", &off), Delivery::Now);
3293 // A watcher who turned security alerts off hears of nothing urgent.
3294 assert_eq!(security_delivery(&summary_, "cy", &off), Delivery::Never);
3295 assert_eq!(security_delivery(&summary_, "cy", &InboxSettings::default()), Delivery::Now);
3296 }
3297
3298 #[test]
3299 fn what_is_not_urgent_waits_for_the_digest_unless_the_person_chose_otherwise() {
3300 let medium = security_summary(&summary("vulnerability", &["vul_1"], (0, 0, 1, 0), (0, 0, 1, 0), None)).unwrap();
3301 assert!(!medium.urgent());
3302 let choose = |cadence| InboxSettings { security_digest: cadence, ..InboxSettings::default() };
3303 // Owners too: a medium alert is for the digest, as they chose.
3304 assert_eq!(security_delivery(&medium, "ana", &choose(DigestCadence::Daily)), Delivery::Digest);
3305 assert_eq!(security_delivery(&medium, "cy", &choose(DigestCadence::Weekly)), Delivery::Digest);
3306 assert_eq!(security_delivery(&medium, "cy", &choose(DigestCadence::AtOnce)), Delivery::Now);
3307 assert_eq!(security_delivery(&medium, "ana", &choose(DigestCadence::Off)), Delivery::Never);
3308 let high = security_summary(&summary("vulnerability", &["vul_1"], (0, 1, 0, 0), (0, 1, 0, 0), None)).unwrap();
3309 assert_eq!(security_delivery(&high, "ana", &choose(DigestCadence::Off)), Delivery::Now);
3310 assert_eq!(breakdown(&SeverityCounts { critical: 1, low: 3, ..SeverityCounts::default() }), "1 critical, 3 low");
3311 assert_eq!(breakdown(&SeverityCounts::default()), "");
3312 }
3313
3314 #[test]
3315 fn bypass_requests_reports_and_advisories_tell_who_they_name() {
3316 let audience = watched(&[("cy", WatchLevel::Custom, &["security"]), ("eve", WatchLevel::All, &[])]);
3317 // A bypass request goes to the reviewers named, never to watchers.
3318 let requested = event(
3319 "secret_scanning.bypass_requested",
3320 Some("usr_bo"),
3321 json!({ "repoId": "rep_1", "alertId": "sec_1", "requestId": "byp_1", "title": "An AWS access key in a.env", "notify": ["ana"], "link": "/acme/-/security/bypass-requests" }),
3322 );
3323 let notices_ = notices(&requested, "acme/rocket", &actor("usr_bo", "bo"), None, &audience);
3324 assert_eq!(told(&notices_), vec![("ana", Reason::SecurityAlert, Severity::Warning)]);
3325 assert_eq!(notices_[0].title, "bo asked to bypass push protection in acme/rocket");
3326 assert_eq!(thread_of(&requested, &wants(&requested).unwrap(), None).key, "rep_1/security/byp_1");
3327 // A private report is for the owners and security managers, at once, with no reporter named.
3328 let reported = event(
3329 "security_advisory.reported",
3330 None,
3331 json!({ "repoId": "rep_1", "id": "vrp_1", "title": "XSS in the search box", "state": "new", "link": "/acme/rocket/security/reports", "notify": ["ana"], "members": ["ana", "cy"] }),
3332 );
3333 let notices_ = notices(&reported, "acme/rocket", &Actor::default(), None, &audience);
3334 assert_eq!(told(&notices_), vec![("ana", Reason::SecurityAlert, Severity::Warning)]);
3335 assert_eq!(notices_[0].title, "A vulnerability was reported privately to acme/rocket");
3336 assert_eq!(thread_of(&reported, &wants(&reported).unwrap(), None).key, "rep_1/security/reports");
3337 // A published advisory is for watchers who chose security; it is public, so no members list is needed.
3338 let published = event(
3339 "security_advisory.published",
3340 Some("usr_ana"),
3341 json!({ "repoId": "rep_1", "id": "adv_1", "g1tsaId": "G1TSA-2026-abcd-efgh", "title": "XSS in the search box", "severity": "high", "state": "published", "link": "/acme/rocket/security/advisories/G1TSA-2026-abcd-efgh" }),
3342 );
3343 let notices_ = notices(&published, "acme/rocket", &actor("usr_ana", "ana"), None, &audience);
3344 assert_eq!(told(&notices_), vec![("cy", Reason::Subscribed, Severity::Info), ("eve", Reason::Subscribed, Severity::Info)]);
3345 assert_eq!(thread_of(&published, &wants(&published).unwrap(), None).key, "rep_1/security/advisories/adv_1");
3346 }
3347
3348 #[test]
3349 fn nobody_hears_of_what_they_did_themselves() {
3350 let merged = event("pull.merged", Some("usr_ana"), json!({ "number": 7 }));
3351 let by_ana = notices(&merged, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody());
3352 // The assignee still hears; ana, who merged, does not.
3353 assert_eq!(told(&by_ana), vec![("bo", Reason::StateChange, Severity::Success)]);
3354 let by_bo = notices(&merged, "acme/rocket", &actor("usr_bo", "bo"), Some(&pull()), &nobody());
3355 assert_eq!(told(&by_bo), vec![("ana", Reason::StateChange, Severity::Success)]);
3356 assert_eq!(by_bo[0].title, "bo merged acme/rocket#7");
3357 let by_queue = notices(&merged, "acme/rocket", &Actor::default(), Some(&pull()), &nobody());
3358 assert_eq!(by_queue[0].title, "acme/rocket#7 was merged");
3359 }
3360
3361 #[test]
3362 fn closing_and_reopening_tells_everyone_subscribed_and_watchers() {
3363 let closed = event("work_item.closed", Some("usr_bo"), json!({ "number": 3, "reason": "completed" }));
3364 let item = InboxSubject {
3365 kind: Some(SubjectKind::Item),
3366 title: "Crash".into(),
3367 author: person("usr_cy", "cy"),
3368 assignees: vec!["bo".into()],
3369 ..InboxSubject::default()
3370 };
3371 let mut audience = subscribed(&[("eve", State::Subscribed, Some(Reason::Comment)), ("fay", State::Unsubscribed, None)]);
3372 audience.watchers = watched(&[("gus", WatchLevel::All, &[]), ("hal", WatchLevel::Custom, &["pulls"])]).watchers;
3373 let notices_ = notices(&closed, "acme", &actor("usr_bo", "bo"), Some(&item), &audience);
3374 assert_eq!(
3375 told(&notices_),
3376 vec![
3377 ("cy", Reason::StateChange, Severity::Info),
3378 ("eve", Reason::StateChange, Severity::Info),
3379 ("gus", Reason::Subscribed, Severity::Info),
3380 ]
3381 );
3382 assert_eq!(notices_[0].title, "bo closed acme#3");
3383 let by_pull = event("work_item.closed", None, json!({ "number": 3, "resolution": { "repo": "acme/rocket", "commit": "1a2b3c4d5e" } }));
3384 assert_eq!(notices(&by_pull, "acme", &Actor::default(), Some(&item), &nobody())[0].title, "acme#3 was closed by acme/rocket@1a2b3c4");
3385 let reopened = event("work_item.reopened", Some("usr_cy"), json!({ "number": 3 }));
3386 assert_eq!(told(&notices(&reopened, "acme", &actor("usr_cy", "cy"), Some(&item), &nobody())), vec![("bo", Reason::StateChange, Severity::Info)]);
3387 }
3388
3389 #[test]
3390 fn opening_tells_who_it_names_and_watchers_of_its_kind() {
3391 let opened = event("pull.opened", Some("usr_ana"), json!({ "number": 7 }));
3392 let subject = InboxSubject { reviewers: vec!["cy".into(), "g1t".into()], ..pull() };
3393 let audience = watched(&[("bo", WatchLevel::All, &[]), ("dee", WatchLevel::Custom, &["work_items"]), ("eve", WatchLevel::Custom, &["pulls"]), ("fay", WatchLevel::Participating, &[])]);
3394 let notices_ = notices(&opened, "acme/rocket", &actor("usr_ana", "ana"), Some(&subject), &audience);
3395 assert_eq!(
3396 told(&notices_),
3397 vec![
3398 ("bo", Reason::Assign, Severity::Info),
3399 ("cy", Reason::ReviewRequested, Severity::Warning),
3400 ("eve", Reason::Subscribed, Severity::Info),
3401 ]
3402 );
3403 assert_eq!(notices_[2].title, "ana opened acme/rocket#7");
3404 }
3405
3406 #[test]
3407 fn ignoring_a_repository_silences_it_and_unsubscribing_keeps_only_what_is_asked() {
3408 let created = event("comment.created", Some("usr_bo"), json!({ "number": 7, "commentId": "cmt_1" }));
3409 let on = comment(person("usr_bo", "bo"), "@cy have a look", &["cy"]);
3410 let audience = watched(&[("cy", WatchLevel::Ignore, &[]), ("ana", WatchLevel::All, &[])]);
3411 assert!(notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &audience).iter().all(|notice| notice.username != "cy"));
3412 let audience = subscribed(&[("cy", State::Unsubscribed, None), ("ana", State::Unsubscribed, None)]);
3413 let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &audience);
3414 // cy was mentioned, which is asked of them; ana unsubscribed from the conversation.
3415 assert_eq!(told(&notices_), vec![("cy", Reason::Mention, Severity::Info)]);
3416 }
3417
3418 #[test]
3419 fn an_agent_s_review_is_shown_by_its_name_and_marked_advisory() {
3420 // Margo (@margo), acting for bo, asks for changes on ana's pull request.
3421 let created = event("comment.created", Some("usr_bo"), json!({ "number": 7, "commentId": "cmt_1" }));
3422 let mut on = comment(person("agt_1", "ana"), "Trim the name.", &[]);
3423 if let Some(said) = on.comment.as_mut() {
3424 said.agent = Some(g1t_contracts::work::AgentRef {
3425 id: "agt_1".into(),
3426 handle: "ana".into(),
3427 display_name: "Margo".into(),
3428 avatar_seed: "margo".into(),
3429 });
3430 said.acting_for = Some(person("usr_bo", "bo"));
3431 said.verdict = Some("request_changes".into());
3432 said.advisory = true;
3433 }
3434 let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &nobody());
3435 // Its handle sharing ana's name leaves nobody out: ana owns it and hears.
3436 assert!(told(&notices_).contains(&("ana", Reason::Author, Severity::Info)));
3437 assert_eq!(notices_[0].title, "Margo (agent) asked for changes on acme/rocket#7 (advisory)");
3438 // An agent subscribes nobody by its handle.
3439 assert!(subscribes(&created, Some(&on)).iter().all(|(name, _)| name != "ana"));
3440 }
3441
3442 #[test]
3443 fn comments_tell_those_mentioned_then_everyone_subscribed_never_the_writer() {
3444 let created = event("comment.created", Some("usr_bo"), json!({ "number": 7, "commentId": "cmt_1" }));
3445 let on = comment(person("usr_bo", "bo"), "@cy @bo have a look", &["cy", "bo", "g1t"]);
3446 let audience = subscribed(&[("eve", State::Subscribed, Some(Reason::Comment)), ("fay", State::Subscribed, Some(Reason::Manual))]);
3447 let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &audience);
3448 assert_eq!(
3449 told(&notices_),
3450 vec![
3451 ("cy", Reason::Mention, Severity::Info),
3452 ("ana", Reason::Author, Severity::Info),
3453 ("eve", Reason::Comment, Severity::Info),
3454 ("fay", Reason::Manual, Severity::Info),
3455 ]
3456 );
3457 assert_eq!(notices_[0].title, "bo mentioned you on acme/rocket#7");
3458 assert_eq!(notices_[1].title, "bo commented on acme/rocket#7");
3459 assert_eq!(notices_[0].body, "@cy @bo have a look");
3460 // Mentioned and the owner: told once, as mentioned.
3461 let on = comment(person("usr_bo", "bo"), "@ana", &["ana"]);
3462 assert_eq!(
3463 told(&notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &nobody())),
3464 vec![("ana", Reason::Mention, Severity::Info)]
3465 );
3466 // The owner's own comment tells the assignee, not the owner.
3467 let on = comment(person("usr_ana", "ana"), "thanks", &[]);
3468 assert_eq!(
3469 told(&notices(&created, "acme/rocket", &actor("usr_ana", "ana"), Some(&on), &nobody())),
3470 vec![("bo", Reason::Assign, Severity::Info)]
3471 );
3472 // Something that happened, not something written, tells nobody.
3473 let mut on = comment(person("usr_bo", "bo"), "assigned cy", &[]);
3474 on.comment.as_mut().unwrap().event = true;
3475 assert!(notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &nobody()).is_empty());
3476 // An approval is good news.
3477 let mut on = comment(person("usr_cy", "cy"), "", &[]);
3478 on.comment.as_mut().unwrap().verdict = Some("approve".into());
3479 let notices_ = notices(&created, "acme/rocket", &actor("usr_cy", "cy"), Some(&on), &nobody());
3480 assert_eq!(told(&notices_)[0], ("ana", Reason::Author, Severity::Success));
3481 assert_eq!(notices_[0].body, "Add the inbox");
3482 // Writing and being mentioned subscribe, never g1t.
3483 let on = comment(person("usr_bo", "bo"), "@cy", &["cy"]);
3484 assert_eq!(subscribes(&created, Some(&on)), vec![("bo".into(), Reason::Comment), ("cy".into(), Reason::Mention)]);
3485 let on = comment(person(AGENT_ID, "g1t"), "done", &[]);
3486 assert!(subscribes(&created, Some(&on)).is_empty());
3487 }
3488
3489 #[test]
3490 fn a_commit_comment_tells_who_it_mentions_and_the_commit_s_author() {
3491 let created = event(
3492 "commit_comment.created",
3493 Some("usr_bo"),
3494 json!({
3495 "repoId": "rep_1", "commit": "abcdef1234567890", "link": "/acme/rocket/commit/abcdef1234567890",
3496 "excerpt": "@cy why this?", "mentions": ["cy", "bo"], "commitAuthor": "ana",
3497 "teamMentions": [{ "team": "acme/web", "members": ["dee", "cy"] }],
3498 }),
3499 );
3500 let wanted = wants(&created).unwrap();
3501 assert_eq!(wanted.number, None);
3502 let thread = thread_of(&created, &wanted, None);
3503 assert_eq!(thread.key, "rep_1/commit/abcdef1234567890");
3504 assert_eq!(url(Some("acme/rocket"), None, thread.kind, thread.number, None, thread.link.as_deref()), "/acme/rocket/commit/abcdef1234567890");
3505 let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody());
3506 assert_eq!(
3507 told(&notices_),
3508 vec![
3509 ("cy", Reason::Mention, Severity::Info),
3510 ("dee", Reason::TeamMention, Severity::Info),
3511 ("ana", Reason::Author, Severity::Info),
3512 ]
3513 );
3514 assert_eq!(notices_[0].title, "bo mentioned you on acme/rocket@abcdef1");
3515 assert_eq!(notices_[2].title, "bo commented on your commit acme/rocket@abcdef1");
3516 assert_eq!(notices_[0].body, "@cy why this?");
3517 }
3518
3519 #[test]
3520 fn pull_requests_are_one_thread_each() {
3521 let created = event("comment.created", Some("usr_bo"), json!({ "repoId": "rep_1", "number": 7, "commentId": "cmt_1" }));
3522 let wanted = wants(&created).unwrap();
3523 let thread = thread_of(&created, &wanted, Some(&pull()));
3524 assert_eq!(thread.key, "rep_1#7");
3525 assert_eq!(thread.kind, Some(SubjectKind::Pull));
3526 let merged = event("pull.merged", None, json!({ "repoId": "rep_1", "number": 7 }));
3527 assert_eq!(thread_of(&merged, &wants(&merged).unwrap(), Some(&pull())).key, thread.key);
3528 }
3529
3530 #[test]
3531 fn items_link_to_what_they_are_about() {
3532 assert_eq!(url(Some("acme/rocket"), None, Some(SubjectKind::Pull), Some(7), None, None), "/acme/rocket/pull/7");
3533 assert_eq!(url(Some("acme/rocket"), Some("acme"), Some(SubjectKind::Item), Some(3), None, None), "/acme/-/work/3");
3534 assert_eq!(url(None, Some("acme"), Some(SubjectKind::Item), Some(3), None, None), "/acme/-/work/3");
3535 assert_eq!(url(Some("acme/rocket"), None, Some(SubjectKind::Run), None, Some("run_1"), None), "/acme/rocket/actions/runs/run_1");
3536 assert_eq!(url(Some("acme/rocket"), None, Some(SubjectKind::Deploy), None, None, Some("/acme/site/deployments/dpl_1")), "/acme/site/deployments/dpl_1");
3537 assert_eq!(url(Some("acme/rocket"), None, None, None, None, None), "/acme/rocket");
3538 assert_eq!(url(None, None, None, None, None, None), "/inbox");
3539 }
3540
3541 #[test]
3542 fn long_titles_are_cut_to_a_line() {
3543 let long = "x".repeat(400);
3544 assert_eq!(clip(&long, MAX_TITLE).chars().count(), MAX_TITLE);
3545 assert_eq!(clip(" short ", MAX_TITLE), "short");
3546 }
3547
3548 #[test]
3549 fn marks_change_only_what_they_say() {
3550 assert_eq!(mark_change(InboxMark::Read), "read_at = COALESCE(read_at, ?1)");
3551 assert!(mark_change(InboxMark::Done).contains("done_at = ?1"));
3552 assert_eq!(mark_change(InboxMark::Unsnooze), "snoozed_until = NULL");
3553 assert!(ranked(&ListInboxArgs::default()));
3554 assert!(!ranked(&ListInboxArgs { reason: Some(Reason::Mention), ..ListInboxArgs::default() }));
3555 assert!(!ranked(&ListInboxArgs { participating: true, ..ListInboxArgs::default() }));
3556 }
3557
3558 #[test]
3559 fn filters_bind_in_order_after_what_is_bound() {
3560 let a = ListInboxArgs {
3561 reason: Some(Reason::Mention),
3562 repo_id: Some("rep_1".into()),
3563 participating: true,
3564 unread: true,
3565 ..ListInboxArgs::default()
3566 };
3567 // Two values come first: the username and now.
3568 let (conditions, values) = filters(&a, 2);
3569 assert_eq!(
3570 conditions,
3571 vec!["reason = ?3", "repo_id = ?4", "reason NOT IN ('manual', 'subscribed')", "read_at IS NULL"]
3572 );
3573 assert_eq!(values, vec!["mention", "rep_1"]);
3574 }
3575
3576 #[test]
3577 fn the_bump_keeps_whatever_is_most_urgent_while_unread() {
3578 assert!(BUMP.contains("WHERE NOT EXISTS (SELECT 1 FROM inbox_activity WHERE event_id = ?4 AND username = ?2)"));
3579 assert!(BUMP.contains("ON CONFLICT (username, thread) DO UPDATE"));
3580 assert!(BUMP.contains("activity = inbox_items.activity + 1"));
3581 // Its numbered parameters run from 1 to 19.
3582 for at in 1..=19 {
3583 assert!(BUMP.contains(&format!("?{at}")), "?{at}");
3584 }
3585 assert!(!BUMP.contains("?20"));
3586 }
3587
3588 #[test]
3589 fn live_notifications_say_what_and_where() {
3590 let notice = Notice {
3591 username: "ana".into(),
3592 reason: Reason::Agent,
3593 severity: Severity::Warning,
3594 title: "g1t needs you on acme/api#4".into(),
3595 body: "Which database?".into(),
3596 };
3597 let live = live_notification("evt_1", &notice, "/Acme/api/pull/4#c1", "2026-10-08T00:00:00Z", Some("rep_1#4"));
3598 assert_eq!(live["id"], "inbox:evt_1");
3599 // A stop that needs a person is an ordinary inbox item.
3600 assert_eq!(live["kind"], "inbox");
3601 assert_eq!(live["workspace"], "acme");
3602 assert_eq!(live["href"], "/Acme/api/pull/4#c1");
3603 assert_eq!(live["actor"]["name"], "acme/api");
3604 // Notify keeps the thread, to stop replaying it once it settles.
3605 assert_eq!(live["inbox_thread"], "rep_1#4");
3606 let mention = Notice { reason: Reason::TeamMention, ..notice };
3607 assert_eq!(live_notification("evt_2", &mention, "https://elsewhere", "t", None)["href"], "/inbox");
3608 assert_eq!(live_notification("evt_2", &mention, "/x", "t", None)["kind"], "mention");
3609 }
3610
3611 #[test]
3612 fn inbox_counts_are_told_as_they_are_by_username() {
3613 assert_eq!(inbox_count_update("Ana", 3), serde_json::json!({ "username": "ana", "unread": 3 }));
3614 assert_eq!(inbox_count_update("bo", 0)["unread"], 0);
3615 // What settled is told with the count, each thread once.
3616 let threads = vec!["rep_1#7".to_owned(), "rep_1/deploy/*/7".to_owned(), "rep_1#7".to_owned()];
3617 assert_eq!(
3618 inbox_settled_update("Ana", 2, &threads),
3619 serde_json::json!({ "username": "ana", "unread": 2, "settled": ["rep_1#7", "rep_1/deploy/*/7"] })
3620 );
3621 }
3622
3623 #[test]
3624 fn a_settle_says_why_and_who_and_clears_on_new_activity() {
3625 let settle = Settle::thread("rep_1#7").saying("Merged", Some("@Ana"));
3626 assert_eq!(settle.why.as_deref(), Some("Merged"));
3627 assert_eq!(settle.by.as_deref(), Some("ana"));
3628 assert_eq!(Settle::thread("rep_1#7").saying("Checks passed", Some(" ")).by, None);
3629 // New activity brings a thread back with no reason left on it, and so does marking it by hand.
3630 assert!(BUMP.contains("settled_reason = NULL"));
3631 assert!(mark_change(InboxMark::Done).contains("settled_reason = NULL"));
3632 assert!(mark_change(InboxMark::Undone).contains("settled_reason = NULL"));
3633 }
3634
3635 #[test]
3636 fn token_approvals_tell_the_people_named_about_no_repository() {
3637 let mut asked = event(
3638 "token.approval_requested",
3639 Some("usr_ana"),
3640 json!({ "workspace": "acme", "tokenId": "tok_1", "notify": ["Bo", "cy", "bo", "g1t"], "title": "ana asks", "body": "ci: contents: write", "link": "/acme/-/settings/tokens" }),
3641 );
3642 asked.repo_id = None;
3643 let (thread, told) = workspace_notices(&asked, None);
3644 assert_eq!(thread, "workspace:acme/token/tok_1");
3645 let mut names: Vec<&str> = told.iter().map(|notice| notice.username.as_str()).collect();
3646 names.sort();
3647 assert_eq!(names, ["bo", "cy"], "each once, lowercased, never g1t");
3648 assert!(told.iter().all(|notice| notice.reason == Reason::ReviewRequested && notice.severity == Severity::Warning));
3649 assert!(wants(&asked).is_none(), "about no repository");
3650 let reviewed = event("token.approval_reviewed", None, json!({ "workspace": "acme", "tokenId": "tok_1", "notify": ["ana"], "title": "approved" }));
3651 let (_, told) = workspace_notices(&reviewed, None);
3652 assert_eq!(told[0].reason, Reason::Author);
3653 assert!(WORKSPACE_EVENTS.contains(&"token.approval_reviewed"));
3654 }
3655
3656 #[test]
3657 fn a_workspace_invitation_asks_its_person_and_tells_whoever_sent_it_the_answer() {
3658 let mut sent = event(
3659 "workspace_invitation.created",
3660 Some("usr_owner"),
3661 json!({ "workspace": "flagon-io", "invitationId": "inv_1", "thread": "invitation:inv_1", "notify": ["daweazl"], "title": "@syntaqx invited you to join Flagon, Inc.", "link": "/invitations" }),
3662 );
3663 sent.repo_id = None;
3664 let (thread, told) = workspace_notices(&sent, None);
3665 assert_eq!(thread, "invitation:inv_1");
3666 assert_eq!(told.len(), 1);
3667 assert_eq!((told[0].username.as_str(), told[0].reason, told[0].severity), ("daweazl", Reason::ReviewRequested, Severity::Warning));
3668 let accepted = event("workspace_invitation.accepted", None, json!({ "workspace": "flagon-io", "thread": "invitation:inv_1", "notify": ["syntaqx"] }));
3669 let (thread, told) = workspace_notices(&accepted, None);
3670 assert_eq!(thread, "invitation:inv_1");
3671 assert_eq!((told[0].reason, told[0].severity), (Reason::Author, Severity::Success));
3672 let declined = event("workspace_invitation.declined", None, json!({ "workspace": "flagon-io", "thread": "invitation:inv_1", "notify": ["syntaqx"] }));
3673 assert_eq!(workspace_notices(&declined, None).1[0].severity, Severity::Info);
3674 // Answered or revoked, the person's item is done; a revocation tells nobody.
3675 for kind in ["workspace_invitation.accepted", "workspace_invitation.declined", "workspace_invitation.revoked"] {
3676 assert!(INVITATION_ANSWERED.contains(&kind));
3677 }
3678 assert!(!WORKSPACE_EVENTS.contains(&"workspace_invitation.revoked"));
3679 }
3680}