Skip to content

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

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