Skip to content
717 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Inbox: threads, reasons, subscriptions and watching1//! Who follows what, for the inbox (inbox.rs): subscriptions to issues and
2//! pull requests, how people watch repositories, and what each person
3//! chose about being told. See `g1t_contracts::inbox`.
4//!
5//! An issue's or pull request's author (or whoever asked g1t for it), its
6//! assignees and its reviewers are subscribed without a row. A row records
7//! the rest: someone who commented or was mentioned (subscribed as they
8//! did), or who subscribed, unsubscribed or ignored it by hand. Commenting
9//! or being mentioned subscribes again someone who unsubscribed; nothing
10//! but their own choice undoes ignoring.
11
12use std::collections::HashMap;
13
14use g1t_contracts::events::Event;
15use g1t_contracts::inbox::*;
16use g1t_kit::now_ms;
17use g1t_contracts::time::rfc3339;
18use serde::Deserialize;
19use worker::wasm_bindgen::JsValue;
20use worker::{D1Database, D1PreparedStatement, Fetcher, Result};
21
22use crate::inbox::{is_g1t, is_g1t_id, numbered_thread};
23
24/// The most watchers read for one repository's event.
25const MAX_WATCHERS: u32 = 5000;
26
27/// Where a person stands on one thread, by their own row.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum State {
30 Subscribed,
31 Unsubscribed,
32 Ignored,
33}
34
35impl State {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 State::Subscribed => "subscribed",
39 State::Unsubscribed => "unsubscribed",
40 State::Ignored => "ignored",
41 }
42 }
43
44 pub fn parse(value: &str) -> Option<State> {
45 [State::Subscribed, State::Unsubscribed, State::Ignored]
46 .into_iter()
47 .find(|state| state.as_str() == value)
48 }
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct Subscription {
53 pub username: String,
54 pub state: State,
55 pub reason: Option<Reason>,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct Watcher {
60 pub username: String,
61 pub level: WatchLevel,
62 pub events: Vec<String>,
63}
64
65/// Who follows the thread an event is on, beyond its own people: its
66/// subscription rows, and the repository's watchers.
67#[derive(Clone, Debug, Default)]
68pub struct Audience {
69 pub subscriptions: Vec<Subscription>,
70 pub watchers: Vec<Watcher>,
71}
72
73impl Audience {
74 fn row(&self, username: &str) -> Option<&Subscription> {
75 self.subscriptions.iter().find(|row| row.username.eq_ignore_ascii_case(username))
76 }
77
78 /// Hears of nothing here: they ignore the thread or the repository.
79 pub fn ignores(&self, username: &str) -> bool {
80 self.row(username).is_some_and(|row| row.state == State::Ignored)
81 || self
82 .watchers
83 .iter()
84 .any(|watcher| watcher.level == WatchLevel::Ignore && watcher.username.eq_ignore_ascii_case(username))
85 }
86
87 /// Hears only of what is asked of them.
88 pub fn unsubscribed(&self, username: &str) -> bool {
89 self.row(username).is_some_and(|row| row.state == State::Unsubscribed)
90 }
91
92 /// Everyone with a row saying they are subscribed, and why.
93 pub fn subscribed(&self) -> impl Iterator<Item = (String, Reason)> + '_ {
94 self.subscriptions
95 .iter()
96 .filter(|row| row.state == State::Subscribed)
97 .map(|row| (row.username.clone(), row.reason.unwrap_or(Reason::Manual)))
98 }
99
100 /// Everyone watching the repository for `kind` of activity: `issues`,
101 /// `pulls`, `deployments` or `security`.
102 pub fn watching<'a>(&'a self, kind: &'a str) -> impl Iterator<Item = String> + 'a {
103 self.watchers
104 .iter()
105 .filter(move |watcher| match watcher.level {
106 WatchLevel::All => true,
107 WatchLevel::Custom => watcher.events.iter().any(|event| event == kind),
108 WatchLevel::Participating | WatchLevel::Ignore => false,
109 })
110 .map(|watcher| watcher.username.clone())
111 }
112}
113
114#[derive(Deserialize)]
115struct SubscriptionRow {
116 username: String,
117 state: String,
118 reason: Option<String>,
119 chosen_at: Option<String>,
120}
121
122impl SubscriptionRow {
123 fn into_subscription(self) -> Option<Subscription> {
124 Some(Subscription {
125 username: self.username,
126 state: State::parse(&self.state)?,
127 reason: self.reason.as_deref().and_then(Reason::parse),
128 })
129 }
130}
131
132/// An issue's or pull request's subscription rows.
133pub async fn of_thread(db: &D1Database, thread: &str) -> Result<Vec<Subscription>> {
134 Ok(db
135 .prepare("SELECT username, state, reason, chosen_at FROM inbox_subscriptions WHERE thread = ?")
136 .bind(&[thread.into()])?
137 .all()
138 .await?
139 .results::<SubscriptionRow>()?
140 .into_iter()
141 .filter_map(SubscriptionRow::into_subscription)
142 .collect())
143}
144
145#[derive(Deserialize)]
146struct WatcherRow {
147 username: String,
148 level: String,
149 events: Option<String>,
150}
151
152fn events_of(text: Option<&str>) -> Vec<String> {
153 text.and_then(|text| serde_json::from_str::<Vec<String>>(text).ok()).unwrap_or_default()
154}
155
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97156/// How many watch a repository: all of it, or some of it.
157pub async fn watchers_count(db: &D1Database, a: WatchersArgs) -> Result<u64> {
158 #[derive(Deserialize)]
159 struct Row {
160 count: f64,
161 }
162 Ok(db
163 .prepare("SELECT COUNT(*) AS count FROM inbox_watching WHERE repo_id = ? AND level IN ('all', 'custom')")
164 .bind(&[a.repo_id.into()])?
165 .first::<Row>(None)
166 .await?
167 .map_or(0, |row| row.count as u64))
168}
169
Inbox: threads, reasons, subscriptions and watching170/// Everyone who watches a repository other than the default way.
171pub async fn watchers(db: &D1Database, repo_id: &str) -> Result<Vec<Watcher>> {
172 Ok(db
173 .prepare("SELECT username, level, events FROM inbox_watching WHERE repo_id = ? AND level != 'participating' LIMIT ?")
174 .bind(&[repo_id.into(), MAX_WATCHERS.into()])?
175 .all()
176 .await?
177 .results::<WatcherRow>()?
178 .into_iter()
179 .filter_map(|row| {
180 Some(Watcher {
181 level: WatchLevel::parse(&row.level)?,
182 events: events_of(row.events.as_deref()),
183 username: row.username,
184 })
185 })
186 .collect())
187}
188
189/// Subscribes the people an event names to its issue or pull request:
190/// anyone without a row, and anyone who had unsubscribed. Someone who
191/// ignores it stays ignoring it.
192pub fn auto_subscribe(
193 db: &D1Database,
194 thread: &str,
195 repo_id: &str,
196 people: &[(String, Reason)],
197 at: &str,
198) -> Result<Vec<D1PreparedStatement>> {
199 if !thread.contains('#') {
200 return Ok(Vec::new());
201 }
202 people
203 .iter()
204 .map(|(username, reason)| {
205 db.prepare(
206 "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, reason, created_at)
207 VALUES (?1, ?2, ?3, 'subscribed', ?4, ?5)
208 ON CONFLICT (thread, username) DO UPDATE SET state = 'subscribed', reason = excluded.reason
209 WHERE inbox_subscriptions.state = 'unsubscribed'",
210 )
211 .bind(&[
212 username.as_str().into(),
213 thread.into(),
214 repo_id.into(),
215 reason.as_str().into(),
216 at.into(),
217 ])
218 })
219 .collect()
220}
221
222/// Whoever made a repository watches it as they chose for new ones (all of
223/// its activity, unless they said otherwise).
224pub async fn watch_created(db: &D1Database, event: &Event, names: &HashMap<String, String>) -> Result<()> {
225 let Some(id) = event.actor.as_deref().filter(|id| !is_g1t_id(id)) else {
226 return Ok(());
227 };
228 let (Some(username), Some(repo_id)) = (
229 names.get(id).map(|name| name.to_lowercase()).filter(|name| !is_g1t(name)),
230 event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone()),
231 ) else {
232 return Ok(());
233 };
234 let repo = format!(
235 "{}/{}",
236 event.data["namespace"].as_str().unwrap_or_default(),
237 event.data["name"].as_str().unwrap_or_default()
238 )
239 .to_lowercase();
240 db.prepare(
241 "INSERT OR IGNORE INTO inbox_watching (username, repo_id, repo, level, events, updated_at)
242 SELECT ?1, ?2, ?3, COALESCE((SELECT default_watch FROM inbox_settings WHERE username = ?1), ?4), '[]', ?5",
243 )
244 .bind(&[
245 username.into(),
246 repo_id.into(),
247 repo.into(),
248 InboxSettings::default().default_watch.as_str().into(),
249 event.time.as_str().into(),
250 ])?
251 .run()
252 .await?;
253 Ok(())
254}
255
256// --- One person's subscriptions ----------------------------------------------
257
258#[derive(Deserialize)]
259struct ItemRow {
260 thread: Option<String>,
261 repo_id: Option<String>,
262 repo: Option<String>,
263 number: Option<f64>,
264 subject: Option<String>,
265}
266
267/// The issue or pull request asked about: its thread, repository and
268/// number, and its `owner/name` when known.
269struct Target {
270 thread: String,
271 repo_id: String,
272 number: u32,
273 repo: Option<String>,
274}
275
276async fn target(db: &D1Database, username: &str, a: &SubscriptionArgs) -> Result<Option<Target>> {
277 if let Some(id) = &a.id {
278 let row = db
279 .prepare("SELECT thread, repo_id, repo, number, subject FROM inbox_items WHERE id = ? AND username = ?")
280 .bind(&[id.as_str().into(), username.into()])?
281 .first::<ItemRow>(None)
282 .await?;
283 return Ok(row.and_then(|row| {
284 let kind = row.subject.as_deref().and_then(SubjectKind::parse);
285 if !matches!(kind, Some(SubjectKind::Issue | SubjectKind::Pull)) {
286 return None;
287 }
288 Some(Target {
289 thread: row.thread?,
290 repo_id: row.repo_id?,
291 number: row.number? as u32,
292 repo: row.repo,
293 })
294 }));
295 }
296 let (Some(repo_id), Some(number)) = (&a.repo_id, a.number) else {
297 return Ok(None);
298 };
299 Ok(Some(Target {
300 thread: numbered_thread(repo_id, number),
301 repo_id: repo_id.clone(),
302 number,
303 repo: None,
304 }))
305}
306
307/// Why the person is subscribed without a row: they own it, wrote it, are
308/// assigned or were asked to review.
309fn implicit(subject: &InboxSubject, username: &str) -> Option<Reason> {
310 let is = |name: &str| name.eq_ignore_ascii_case(username);
311 if is(&subject.owner().username) || is(&subject.author.username) {
312 Some(Reason::Author)
313 } else if subject.assignees.iter().any(|name| is(name)) {
314 Some(Reason::Assign)
315 } else if subject.reviewers.iter().any(|name| is(name)) {
316 Some(Reason::ReviewRequested)
317 } else {
318 None
319 }
320}
321
322/// What a row, or the lack of one, says.
323fn standing(row: Option<&SubscriptionRow>, implicit: Option<Reason>) -> (bool, bool, Option<Reason>) {
324 match row.and_then(|row| State::parse(&row.state).map(|state| (state, row))) {
325 Some((State::Ignored, _)) => (false, true, None),
326 Some((State::Unsubscribed, _)) => (false, false, None),
327 Some((State::Subscribed, row)) => (true, false, row.reason.as_deref().and_then(Reason::parse).or(Some(Reason::Manual))),
328 None => (implicit.is_some(), false, implicit),
329 }
330}
331
332/// The person's subscription to an issue or pull request. None when there
333/// is no such issue or pull request, or it is a thread nobody subscribes to.
334pub async fn subscription(db: &D1Database, work: &Fetcher, a: SubscriptionArgs) -> Result<Option<ThreadSubscription>> {
335 let Some(viewer) = &a.viewer else {
336 return Ok(None);
337 };
338 let username = viewer.username.to_lowercase();
339 let Some(target) = target(db, &username, &a).await? else {
340 return Ok(None);
341 };
342 let row = db
343 .prepare("SELECT username, state, reason, chosen_at FROM inbox_subscriptions WHERE thread = ? AND username = ?")
344 .bind(&[target.thread.as_str().into(), username.as_str().into()])?
345 .first::<SubscriptionRow>(None)
346 .await?;
347 // Without a row, whether they take part is the issue's to say; and an
348 // issue that is not there has no subscription.
349 let subject: Option<InboxSubject> = g1t_kit::call(
350 work,
351 "inbox_subject",
352 &InboxSubjectArgs {
353 repo_id: target.repo_id.clone(),
354 number: target.number,
355 comment_id: None,
356 },
357 )
358 .await?;
359 let Some(subject) = subject else {
360 return Ok(None);
361 };
362 let (subscribed, ignored, reason) = standing(row.as_ref(), implicit(&subject, &username));
363 Ok(Some(ThreadSubscription {
364 subscribed,
365 ignored,
366 reason,
367 repo: target.repo,
368 number: Some(target.number),
369 updated_at: row.and_then(|row| row.chosen_at),
370 }))
371}
372
373/// Subscribes, unsubscribes or ignores by hand, or goes back to the
374/// default (subscribed only while taking part).
375pub async fn subscribe(db: &D1Database, work: &Fetcher, a: SubscribeArgs) -> Result<Option<ThreadSubscription>> {
376 let Some(viewer) = &a.on.viewer else {
377 return Ok(None);
378 };
379 let username = viewer.username.to_lowercase();
380 if is_g1t(&username) {
381 return Ok(None);
382 }
383 let Some(target) = target(db, &username, &a.on).await? else {
384 return Ok(None);
385 };
386 // Only an issue or pull request that is there.
387 let found: Option<InboxSubject> = g1t_kit::call(
388 work,
389 "inbox_subject",
390 &InboxSubjectArgs {
391 repo_id: target.repo_id.clone(),
392 number: target.number,
393 comment_id: None,
394 },
395 )
396 .await?;
397 if found.is_none() {
398 return Ok(None);
399 }
400 let now = rfc3339(now_ms());
401 let state = match (a.ignored, a.subscribed) {
402 (true, _) => Some(State::Ignored),
403 (false, Some(true)) => Some(State::Subscribed),
404 (false, Some(false)) => Some(State::Unsubscribed),
405 (false, None) => None,
406 };
407 match state {
408 None => {
409 db.prepare("DELETE FROM inbox_subscriptions WHERE thread = ? AND username = ?")
410 .bind(&[target.thread.as_str().into(), username.as_str().into()])?
411 .run()
412 .await?;
413 }
414 Some(state) => {
415 db.prepare(
416 "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, reason, created_at, chosen_at)
417 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
418 ON CONFLICT (thread, username) DO UPDATE SET
419 state = excluded.state,
420 reason = CASE WHEN excluded.state = 'subscribed' AND inbox_subscriptions.state = 'subscribed'
421 THEN inbox_subscriptions.reason ELSE excluded.reason END,
422 chosen_at = excluded.chosen_at",
423 )
424 .bind(&[
425 username.as_str().into(),
426 target.thread.as_str().into(),
427 target.repo_id.as_str().into(),
428 state.as_str().into(),
429 if state == State::Subscribed { Reason::Manual.as_str().into() } else { JsValue::NULL },
430 now.as_str().into(),
431 ])?
432 .run()
433 .await?;
434 }
435 }
436 subscription(db, work, a.on).await
437}
438
439// --- Watching ------------------------------------------------------------------
440
441#[derive(Deserialize)]
442struct WatchingRow {
443 repo_id: String,
444 repo: Option<String>,
445 level: String,
446 events: Option<String>,
447 updated_at: Option<String>,
448}
449
450impl WatchingRow {
451 fn into_watching(self) -> Watching {
452 Watching {
453 level: WatchLevel::parse(&self.level).unwrap_or_default(),
454 events: events_of(self.events.as_deref()),
455 repo_id: self.repo_id,
456 repo: self.repo,
457 updated_at: self.updated_at,
458 }
459 }
460}
461
462/// How the person watches one repository: participating unless they chose.
463pub async fn watching(db: &D1Database, a: WatchingArgs) -> Result<Watching> {
464 let row = db
465 .prepare("SELECT repo_id, repo, level, events, updated_at FROM inbox_watching WHERE repo_id = ? AND username = ?")
466 .bind(&[a.repo_id.as_str().into(), a.username.to_lowercase().into()])?
467 .first::<WatchingRow>(None)
468 .await?;
469 Ok(row.map(WatchingRow::into_watching).unwrap_or(Watching {
470 repo_id: a.repo_id,
471 ..Watching::default()
472 }))
473}
474
475/// The kinds a custom watch follows: known ones, each once, in their order.
476pub fn custom_events(events: &[String]) -> Vec<String> {
477 WATCH_EVENTS
478 .into_iter()
479 .filter(|kind| events.iter().any(|event| event.trim().eq_ignore_ascii_case(kind)))
480 .map(str::to_owned)
481 .collect()
482}
483
484/// Sets how the person watches a repository; no level goes back to the
485/// default.
486pub async fn watch(db: &D1Database, a: WatchArgs) -> Result<Watching> {
487 let username = a.username.to_lowercase();
488 match a.level {
489 None => {
490 db.prepare("DELETE FROM inbox_watching WHERE repo_id = ? AND username = ?")
491 .bind(&[a.repo_id.as_str().into(), username.as_str().into()])?
492 .run()
493 .await?;
494 }
495 Some(level) => {
496 let events = if level == WatchLevel::Custom { custom_events(&a.events) } else { Vec::new() };
497 db.prepare(
498 "INSERT INTO inbox_watching (username, repo_id, repo, level, events, updated_at)
499 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
500 ON CONFLICT (repo_id, username) DO UPDATE SET
501 repo = COALESCE(excluded.repo, inbox_watching.repo), level = excluded.level,
502 events = excluded.events, updated_at = excluded.updated_at",
503 )
504 .bind(&[
505 username.as_str().into(),
506 a.repo_id.as_str().into(),
507 a.repo.as_deref().map(str::to_lowercase).map_or(JsValue::NULL, JsValue::from),
508 level.as_str().into(),
509 serde_json::to_string(&events)?.into(),
510 rfc3339(now_ms()).into(),
511 ])?
512 .run()
513 .await?;
514 }
515 }
516 watching(
517 db,
518 WatchingArgs {
519 username,
520 repo_id: a.repo_id,
521 },
522 )
523 .await
524}
525
526/// The repositories the person watches other than the default way.
527pub async fn watched(db: &D1Database, a: InboxCountsArgs) -> Result<Vec<Watching>> {
528 Ok(db
529 .prepare(
530 "SELECT repo_id, repo, level, events, updated_at FROM inbox_watching
531 WHERE username = ? AND level != 'participating' ORDER BY repo LIMIT 500",
532 )
533 .bind(&[a.username.to_lowercase().into()])?
534 .all()
535 .await?
536 .results::<WatchingRow>()?
537 .into_iter()
538 .map(WatchingRow::into_watching)
539 .collect())
540}
541
542// --- Settings ------------------------------------------------------------------
543
544#[derive(Deserialize)]
545struct SettingsRow {
546 username: String,
547 email: Option<String>,
548 default_watch: Option<String>,
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.549 #[serde(default)]
550 security_alerts: Option<f64>,
551 #[serde(default)]
552 security_digest: Option<String>,
Inbox: threads, reasons, subscriptions and watching553}
554
555impl SettingsRow {
556 fn into_settings(self) -> InboxSettings {
557 let defaults = InboxSettings::default();
558 InboxSettings {
559 email: match self.email.as_deref().and_then(|text| serde_json::from_str::<Vec<String>>(text).ok()) {
560 Some(reasons) => email_reasons(&reasons),
561 None => defaults.email,
562 },
563 default_watch: self.default_watch.as_deref().and_then(WatchLevel::parse).unwrap_or(defaults.default_watch),
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.564 security_alerts: self.security_alerts.map_or(defaults.security_alerts, |on| on != 0.0),
565 security_digest: self.security_digest.as_deref().and_then(DigestCadence::parse).unwrap_or(defaults.security_digest),
Inbox: threads, reasons, subscriptions and watching566 }
567 }
568}
569
570/// Reasons as given, known ones only, each once, in their order.
571pub fn email_reasons(reasons: &[String]) -> Vec<Reason> {
572 Reason::ALL
573 .into_iter()
574 .filter(|reason| reasons.iter().any(|given| given == reason.as_str()))
575 .collect()
576}
577
578/// Each person's settings, by username; the defaults for those who never chose.
579pub async fn settings_of(db: &D1Database, usernames: &[String]) -> Result<HashMap<String, InboxSettings>> {
580 if usernames.is_empty() {
581 return Ok(HashMap::new());
582 }
583 let marks = vec!["?"; usernames.len()].join(", ");
584 let values: Vec<JsValue> = usernames.iter().map(|name| JsValue::from(name.as_str())).collect();
585 Ok(db
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.586 .prepare(format!("SELECT username, email, default_watch, security_alerts, security_digest FROM inbox_settings WHERE username IN ({marks})"))
Inbox: threads, reasons, subscriptions and watching587 .bind(&values)?
588 .all()
589 .await?
590 .results::<SettingsRow>()?
591 .into_iter()
592 .map(|row| (row.username.clone(), row.into_settings()))
593 .collect())
594}
595
596pub async fn settings(db: &D1Database, a: InboxCountsArgs) -> Result<InboxSettings> {
597 let username = a.username.to_lowercase();
598 Ok(settings_of(db, std::slice::from_ref(&username))
599 .await?
600 .remove(&username)
601 .unwrap_or_default())
602}
603
604pub async fn update_settings(db: &D1Database, a: UpdateInboxSettingsArgs) -> Result<InboxSettings> {
605 let username = a.username.to_lowercase();
606 let email = a
607 .email
608 .map(|reasons| {
609 let names: Vec<String> = reasons.iter().map(|reason| reason.as_str().to_owned()).collect();
610 serde_json::to_string(&email_reasons(&names).iter().map(|reason| reason.as_str()).collect::<Vec<_>>())
611 })
612 .transpose()?;
613 db.prepare(
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.614 "INSERT INTO inbox_settings (username, email, default_watch, security_alerts, security_digest, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
Inbox: threads, reasons, subscriptions and watching615 ON CONFLICT (username) DO UPDATE SET
616 email = COALESCE(excluded.email, inbox_settings.email),
617 default_watch = COALESCE(excluded.default_watch, inbox_settings.default_watch),
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.618 security_alerts = COALESCE(excluded.security_alerts, inbox_settings.security_alerts),
619 security_digest = COALESCE(excluded.security_digest, inbox_settings.security_digest),
Inbox: threads, reasons, subscriptions and watching620 updated_at = excluded.updated_at",
621 )
622 .bind(&[
623 username.as_str().into(),
624 email.map_or(JsValue::NULL, JsValue::from),
625 a.default_watch.map_or(JsValue::NULL, |level| level.as_str().into()),
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.626 a.security_alerts.map_or(JsValue::NULL, |on| i32::from(on).into()),
627 a.security_digest.map_or(JsValue::NULL, |cadence| cadence.as_str().into()),
Inbox: threads, reasons, subscriptions and watching628 rfc3339(now_ms()).into(),
629 ])?
630 .run()
631 .await?;
632 settings(db, InboxCountsArgs { username }).await
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use g1t_contracts::credentials::Principal;
639
640 fn row(state: &str, reason: Option<&str>) -> SubscriptionRow {
641 SubscriptionRow {
642 username: "ana".into(),
643 state: state.into(),
644 reason: reason.map(str::to_owned),
645 chosen_at: None,
646 }
647 }
648
649 #[test]
650 fn a_row_says_more_than_taking_part() {
651 assert_eq!(standing(None, Some(Reason::Author)), (true, false, Some(Reason::Author)));
652 assert_eq!(standing(None, None), (false, false, None));
653 assert_eq!(standing(Some(&row("unsubscribed", None)), Some(Reason::Author)), (false, false, None));
654 assert_eq!(standing(Some(&row("ignored", None)), Some(Reason::Author)), (false, true, None));
655 assert_eq!(standing(Some(&row("subscribed", Some("comment"))), None), (true, false, Some(Reason::Comment)));
656 assert_eq!(standing(Some(&row("subscribed", None)), None), (true, false, Some(Reason::Manual)));
657 }
658
659 #[test]
660 fn taking_part_is_owning_writing_being_assigned_or_reviewing() {
661 let subject = InboxSubject {
662 author: Principal { id: "usr_g1t_agent".into(), username: "g1t".into() },
663 requested_by: Some(Principal { id: "usr_ana".into(), username: "ana".into() }),
664 assignees: vec!["bo".into()],
665 reviewers: vec!["cy".into()],
666 ..InboxSubject::default()
667 };
668 assert_eq!(implicit(&subject, "ANA"), Some(Reason::Author));
669 assert_eq!(implicit(&subject, "bo"), Some(Reason::Assign));
670 assert_eq!(implicit(&subject, "cy"), Some(Reason::ReviewRequested));
671 assert_eq!(implicit(&subject, "dee"), None);
672 }
673
674 #[test]
675 fn an_audience_knows_who_ignores_unsubscribed_and_watches() {
676 let audience = Audience {
677 subscriptions: vec![
678 Subscription { username: "ana".into(), state: State::Ignored, reason: None },
679 Subscription { username: "bo".into(), state: State::Unsubscribed, reason: None },
680 Subscription { username: "cy".into(), state: State::Subscribed, reason: Some(Reason::Mention) },
681 Subscription { username: "dee".into(), state: State::Subscribed, reason: None },
682 ],
683 watchers: vec![
684 Watcher { username: "eve".into(), level: WatchLevel::Ignore, events: Vec::new() },
685 Watcher { username: "fay".into(), level: WatchLevel::Custom, events: vec!["deployments".into()] },
686 Watcher { username: "gus".into(), level: WatchLevel::All, events: Vec::new() },
687 ],
688 };
689 assert!(audience.ignores("ana") && audience.ignores("eve") && !audience.ignores("bo"));
690 assert!(audience.unsubscribed("bo") && !audience.unsubscribed("cy"));
691 assert_eq!(
692 audience.subscribed().collect::<Vec<_>>(),
693 vec![("cy".to_owned(), Reason::Mention), ("dee".to_owned(), Reason::Manual)]
694 );
695 assert_eq!(audience.watching("deployments").collect::<Vec<_>>(), vec!["fay", "gus"]);
696 assert_eq!(audience.watching("issues").collect::<Vec<_>>(), vec!["gus"]);
697 }
698
699 #[test]
700 fn choices_keep_only_what_is_known() {
701 assert_eq!(custom_events(&["Pulls".into(), "releases".into(), "issues".into(), "pulls".into()]), vec!["issues", "pulls"]);
702 assert_eq!(email_reasons(&["mention".into(), "nope".into(), "agent".into()]), vec![Reason::Agent, Reason::Mention]);
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.703 let settings = SettingsRow { username: "ana".into(), email: None, default_watch: Some("participating".into()), security_alerts: None, security_digest: None }.into_settings();
Inbox: threads, reasons, subscriptions and watching704 assert_eq!(settings.email, DEFAULT_EMAIL.to_vec());
705 assert_eq!(settings.default_watch, WatchLevel::Participating);
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.706 // Until chosen: urgent alerts at once, the rest daily.
707 assert!(settings.security_alerts);
708 assert_eq!(settings.security_digest, DigestCadence::Daily);
709 let settings = SettingsRow { username: "ana".into(), email: Some("[]".into()), default_watch: None, security_alerts: Some(0.0), security_digest: Some("weekly".into()) }.into_settings();
Inbox: threads, reasons, subscriptions and watching710 assert!(settings.email.is_empty());
711 assert_eq!(settings.default_watch, WatchLevel::All);
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.712 assert!(!settings.security_alerts);
713 assert_eq!(settings.security_digest, DigestCadence::Weekly);
714 let settings = SettingsRow { username: "ana".into(), email: None, default_watch: None, security_alerts: None, security_digest: Some("hourly".into()) }.into_settings();
715 assert_eq!(settings.security_digest, DigestCadence::Daily);
Inbox: threads, reasons, subscriptions and watching716 }
717}

This file's history is long; its oldest lines are credited to the oldest commit read.