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