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.

751 linesCodeBlameRaw
1//! Who follows what, for the inbox (inbox.rs): subscriptions to work items and
2//! pull requests, how people watch repositories, and what each person
3//! chose about being told. See `g1t_contracts::inbox`.
4//!
5//! A work item'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, HashSet};
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::{ITEM_THREAD, is_g1t, is_g1t_id, item_thread, numbered_thread, subscribable};
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: `work_items`,
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/// A work item'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
156/// 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
170/// 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/// The watchers of a work item: its origin's (`own`) as they watch it,
190/// then those of the other repositories it is linked to who do not watch
191/// the origin, each once; ignoring another repository says nothing about
192/// it. Also who was found only through another repository.
193pub fn linked_watchers(own: Vec<Watcher>, others: &[Vec<Watcher>]) -> (Vec<Watcher>, HashSet<String>) {
194 let mut watchers = own;
195 let mut elsewhere = HashSet::new();
196 for watcher in others.iter().flatten() {
197 if watcher.level == WatchLevel::Ignore || watchers.iter().any(|had| had.username.eq_ignore_ascii_case(&watcher.username)) {
198 continue;
199 }
200 elsewhere.insert(watcher.username.to_lowercase());
201 watchers.push(watcher.clone());
202 }
203 (watchers, elsewhere)
204}
205
206/// Subscribes the people an event names to its work item or pull request:
207/// anyone without a row, and anyone who had unsubscribed. Someone who
208/// ignores it stays ignoring it. `repo_id` is the pull request's
209/// repository, or the work item's origin.
210pub fn auto_subscribe(
211 db: &D1Database,
212 thread: &str,
213 repo_id: Option<&str>,
214 people: &[(String, Reason)],
215 at: &str,
216) -> Result<Vec<D1PreparedStatement>> {
217 if !subscribable(thread) {
218 return Ok(Vec::new());
219 }
220 people
221 .iter()
222 .map(|(username, reason)| {
223 db.prepare(
224 "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, reason, created_at)
225 VALUES (?1, ?2, ?3, 'subscribed', ?4, ?5)
226 ON CONFLICT (thread, username) DO UPDATE SET state = 'subscribed', reason = excluded.reason
227 WHERE inbox_subscriptions.state = 'unsubscribed'",
228 )
229 .bind(&[
230 username.as_str().into(),
231 thread.into(),
232 repo_id.map_or(JsValue::NULL, JsValue::from),
233 reason.as_str().into(),
234 at.into(),
235 ])
236 })
237 .collect()
238}
239
240/// Whoever made a repository watches it as they chose for new ones (all of
241/// its activity, unless they said otherwise).
242pub async fn watch_created(db: &D1Database, event: &Event, names: &HashMap<String, String>) -> Result<()> {
243 let Some(id) = event.actor.as_deref().filter(|id| !is_g1t_id(id)) else {
244 return Ok(());
245 };
246 let (Some(username), Some(repo_id)) = (
247 names.get(id).map(|name| name.to_lowercase()).filter(|name| !is_g1t(name)),
248 event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone()),
249 ) else {
250 return Ok(());
251 };
252 let repo = format!(
253 "{}/{}",
254 event.data["namespace"].as_str().unwrap_or_default(),
255 event.data["name"].as_str().unwrap_or_default()
256 )
257 .to_lowercase();
258 db.prepare(
259 "INSERT OR IGNORE INTO inbox_watching (username, repo_id, repo, level, events, updated_at)
260 SELECT ?1, ?2, ?3, COALESCE((SELECT default_watch FROM inbox_settings WHERE username = ?1), ?4), '[]', ?5",
261 )
262 .bind(&[
263 username.into(),
264 repo_id.into(),
265 repo.into(),
266 InboxSettings::default().default_watch.as_str().into(),
267 event.time.as_str().into(),
268 ])?
269 .run()
270 .await?;
271 Ok(())
272}
273
274// --- One person's subscriptions ----------------------------------------------
275
276#[derive(Deserialize)]
277struct ItemRow {
278 thread: Option<String>,
279 repo_id: Option<String>,
280 repo: Option<String>,
281 workspace: Option<String>,
282 number: Option<f64>,
283 subject: Option<String>,
284}
285
286/// The work item or pull request asked about: its thread, its repository
287/// (a work item's origin), its id when a work item, its number, and where
288/// it is (`owner/name`, or a work item's workspace) when known.
289struct Target {
290 thread: String,
291 repo_id: Option<String>,
292 item_id: Option<String>,
293 number: u32,
294 repo: Option<String>,
295}
296
297impl Target {
298 /// What the work service is asked, to know it is there and who takes part.
299 fn subject_args(&self) -> InboxSubjectArgs {
300 InboxSubjectArgs {
301 repo_id: self.repo_id.clone(),
302 number: self.number,
303 item_id: self.item_id.clone(),
304 comment_id: None,
305 }
306 }
307}
308
309async fn target(db: &D1Database, username: &str, a: &SubscriptionArgs) -> Result<Option<Target>> {
310 if let Some(id) = &a.id {
311 let row = db
312 .prepare("SELECT thread, repo_id, repo, workspace, number, subject FROM inbox_items WHERE id = ? AND username = ?")
313 .bind(&[id.as_str().into(), username.into()])?
314 .first::<ItemRow>(None)
315 .await?;
316 return Ok(row.and_then(|row| {
317 let thread = row.thread?;
318 let number = row.number? as u32;
319 match row.subject.as_deref().and_then(SubjectKind::parse) {
320 Some(SubjectKind::Item) => Some(Target {
321 item_id: Some(thread.strip_prefix(ITEM_THREAD)?.to_owned()),
322 thread,
323 repo_id: row.repo_id,
324 number,
325 repo: row.workspace,
326 }),
327 Some(SubjectKind::Pull) => Some(Target {
328 thread,
329 repo_id: Some(row.repo_id?),
330 item_id: None,
331 number,
332 repo: row.repo,
333 }),
334 _ => None,
335 }
336 }));
337 }
338 if let (Some(item_id), Some(number)) = (&a.item_id, a.number) {
339 return Ok(Some(Target {
340 thread: item_thread(item_id),
341 repo_id: None,
342 item_id: Some(item_id.clone()),
343 number,
344 repo: None,
345 }));
346 }
347 let (Some(repo_id), Some(number)) = (&a.repo_id, a.number) else {
348 return Ok(None);
349 };
350 Ok(Some(Target {
351 thread: numbered_thread(repo_id, number),
352 repo_id: Some(repo_id.clone()),
353 item_id: None,
354 number,
355 repo: None,
356 }))
357}
358
359/// Why the person is subscribed without a row: they own it, wrote it, are
360/// assigned or were asked to review.
361fn implicit(subject: &InboxSubject, username: &str) -> Option<Reason> {
362 let is = |name: &str| name.eq_ignore_ascii_case(username);
363 if is(&subject.owner().username) || is(&subject.author.username) {
364 Some(Reason::Author)
365 } else if subject.assignees.iter().any(|name| is(name)) {
366 Some(Reason::Assign)
367 } else if subject.reviewers.iter().any(|name| is(name)) {
368 Some(Reason::ReviewRequested)
369 } else {
370 None
371 }
372}
373
374/// What a row, or the lack of one, says.
375fn standing(row: Option<&SubscriptionRow>, implicit: Option<Reason>) -> (bool, bool, Option<Reason>) {
376 match row.and_then(|row| State::parse(&row.state).map(|state| (state, row))) {
377 Some((State::Ignored, _)) => (false, true, None),
378 Some((State::Unsubscribed, _)) => (false, false, None),
379 Some((State::Subscribed, row)) => (true, false, row.reason.as_deref().and_then(Reason::parse).or(Some(Reason::Manual))),
380 None => (implicit.is_some(), false, implicit),
381 }
382}
383
384/// The person's subscription to a work item or pull request. None when there
385/// is no such work item or pull request, or it is a thread nobody subscribes to.
386pub async fn subscription(db: &D1Database, work: &Fetcher, a: SubscriptionArgs) -> Result<Option<ThreadSubscription>> {
387 let Some(viewer) = &a.viewer else {
388 return Ok(None);
389 };
390 let username = viewer.username.to_lowercase();
391 let Some(target) = target(db, &username, &a).await? else {
392 return Ok(None);
393 };
394 let row = db
395 .prepare("SELECT username, state, reason, chosen_at FROM inbox_subscriptions WHERE thread = ? AND username = ?")
396 .bind(&[target.thread.as_str().into(), username.as_str().into()])?
397 .first::<SubscriptionRow>(None)
398 .await?;
399 // Without a row, whether they take part is the work item's or pull
400 // request's to say; and one that is not there has no subscription.
401 let subject: Option<InboxSubject> = g1t_kit::call(work, "inbox_subject", &target.subject_args()).await?;
402 let Some(subject) = subject else {
403 return Ok(None);
404 };
405 let (subscribed, ignored, reason) = standing(row.as_ref(), implicit(&subject, &username));
406 Ok(Some(ThreadSubscription {
407 subscribed,
408 ignored,
409 reason,
410 repo: target.repo,
411 number: Some(target.number),
412 updated_at: row.and_then(|row| row.chosen_at),
413 }))
414}
415
416/// Subscribes, unsubscribes or ignores by hand, or goes back to the
417/// default (subscribed only while taking part).
418pub async fn subscribe(db: &D1Database, work: &Fetcher, a: SubscribeArgs) -> Result<Option<ThreadSubscription>> {
419 let Some(viewer) = &a.on.viewer else {
420 return Ok(None);
421 };
422 let username = viewer.username.to_lowercase();
423 if is_g1t(&username) {
424 return Ok(None);
425 }
426 let Some(target) = target(db, &username, &a.on).await? else {
427 return Ok(None);
428 };
429 // Only a work item or pull request that is there.
430 let found: Option<InboxSubject> = g1t_kit::call(work, "inbox_subject", &target.subject_args()).await?;
431 if found.is_none() {
432 return Ok(None);
433 }
434 let now = rfc3339(now_ms());
435 let state = match (a.ignored, a.subscribed) {
436 (true, _) => Some(State::Ignored),
437 (false, Some(true)) => Some(State::Subscribed),
438 (false, Some(false)) => Some(State::Unsubscribed),
439 (false, None) => None,
440 };
441 match state {
442 None => {
443 db.prepare("DELETE FROM inbox_subscriptions WHERE thread = ? AND username = ?")
444 .bind(&[target.thread.as_str().into(), username.as_str().into()])?
445 .run()
446 .await?;
447 }
448 Some(state) => {
449 db.prepare(
450 "INSERT INTO inbox_subscriptions (username, thread, repo_id, state, reason, created_at, chosen_at)
451 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
452 ON CONFLICT (thread, username) DO UPDATE SET
453 state = excluded.state,
454 reason = CASE WHEN excluded.state = 'subscribed' AND inbox_subscriptions.state = 'subscribed'
455 THEN inbox_subscriptions.reason ELSE excluded.reason END,
456 chosen_at = excluded.chosen_at",
457 )
458 .bind(&[
459 username.as_str().into(),
460 target.thread.as_str().into(),
461 target.repo_id.as_deref().map_or(JsValue::NULL, JsValue::from),
462 state.as_str().into(),
463 if state == State::Subscribed { Reason::Manual.as_str().into() } else { JsValue::NULL },
464 now.as_str().into(),
465 ])?
466 .run()
467 .await?;
468 }
469 }
470 subscription(db, work, a.on).await
471}
472
473// --- Watching ------------------------------------------------------------------
474
475#[derive(Deserialize)]
476struct WatchingRow {
477 repo_id: String,
478 repo: Option<String>,
479 level: String,
480 events: Option<String>,
481 updated_at: Option<String>,
482}
483
484impl WatchingRow {
485 fn into_watching(self) -> Watching {
486 Watching {
487 level: WatchLevel::parse(&self.level).unwrap_or_default(),
488 events: events_of(self.events.as_deref()),
489 repo_id: self.repo_id,
490 repo: self.repo,
491 updated_at: self.updated_at,
492 }
493 }
494}
495
496/// How the person watches one repository: participating unless they chose.
497pub async fn watching(db: &D1Database, a: WatchingArgs) -> Result<Watching> {
498 let row = db
499 .prepare("SELECT repo_id, repo, level, events, updated_at FROM inbox_watching WHERE repo_id = ? AND username = ?")
500 .bind(&[a.repo_id.as_str().into(), a.username.to_lowercase().into()])?
501 .first::<WatchingRow>(None)
502 .await?;
503 Ok(row.map(WatchingRow::into_watching).unwrap_or(Watching {
504 repo_id: a.repo_id,
505 ..Watching::default()
506 }))
507}
508
509/// The kinds a custom watch follows: known ones, each once, in their order.
510pub fn custom_events(events: &[String]) -> Vec<String> {
511 WATCH_EVENTS
512 .into_iter()
513 .filter(|kind| events.iter().any(|event| event.trim().eq_ignore_ascii_case(kind)))
514 .map(str::to_owned)
515 .collect()
516}
517
518/// Sets how the person watches a repository; no level goes back to the
519/// default.
520pub async fn watch(db: &D1Database, a: WatchArgs) -> Result<Watching> {
521 let username = a.username.to_lowercase();
522 match a.level {
523 None => {
524 db.prepare("DELETE FROM inbox_watching WHERE repo_id = ? AND username = ?")
525 .bind(&[a.repo_id.as_str().into(), username.as_str().into()])?
526 .run()
527 .await?;
528 }
529 Some(level) => {
530 let events = if level == WatchLevel::Custom { custom_events(&a.events) } else { Vec::new() };
531 db.prepare(
532 "INSERT INTO inbox_watching (username, repo_id, repo, level, events, updated_at)
533 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
534 ON CONFLICT (repo_id, username) DO UPDATE SET
535 repo = COALESCE(excluded.repo, inbox_watching.repo), level = excluded.level,
536 events = excluded.events, updated_at = excluded.updated_at",
537 )
538 .bind(&[
539 username.as_str().into(),
540 a.repo_id.as_str().into(),
541 a.repo.as_deref().map(str::to_lowercase).map_or(JsValue::NULL, JsValue::from),
542 level.as_str().into(),
543 serde_json::to_string(&events)?.into(),
544 rfc3339(now_ms()).into(),
545 ])?
546 .run()
547 .await?;
548 }
549 }
550 watching(
551 db,
552 WatchingArgs {
553 username,
554 repo_id: a.repo_id,
555 },
556 )
557 .await
558}
559
560/// The repositories the person watches other than the default way.
561pub async fn watched(db: &D1Database, a: InboxCountsArgs) -> Result<Vec<Watching>> {
562 Ok(db
563 .prepare(
564 "SELECT repo_id, repo, level, events, updated_at FROM inbox_watching
565 WHERE username = ? AND level != 'participating' ORDER BY repo LIMIT 500",
566 )
567 .bind(&[a.username.to_lowercase().into()])?
568 .all()
569 .await?
570 .results::<WatchingRow>()?
571 .into_iter()
572 .map(WatchingRow::into_watching)
573 .collect())
574}
575
576// --- Settings ------------------------------------------------------------------
577
578#[derive(Deserialize)]
579struct SettingsRow {
580 username: String,
581 email: Option<String>,
582 default_watch: Option<String>,
583 #[serde(default)]
584 security_alerts: Option<f64>,
585 #[serde(default)]
586 security_digest: Option<String>,
587}
588
589impl SettingsRow {
590 fn into_settings(self) -> InboxSettings {
591 let defaults = InboxSettings::default();
592 InboxSettings {
593 email: match self.email.as_deref().and_then(|text| serde_json::from_str::<Vec<String>>(text).ok()) {
594 Some(reasons) => email_reasons(&reasons),
595 None => defaults.email,
596 },
597 default_watch: self.default_watch.as_deref().and_then(WatchLevel::parse).unwrap_or(defaults.default_watch),
598 security_alerts: self.security_alerts.map_or(defaults.security_alerts, |on| on != 0.0),
599 security_digest: self.security_digest.as_deref().and_then(DigestCadence::parse).unwrap_or(defaults.security_digest),
600 }
601 }
602}
603
604/// Reasons as given, known ones only, each once, in their order.
605pub fn email_reasons(reasons: &[String]) -> Vec<Reason> {
606 Reason::ALL
607 .into_iter()
608 .filter(|reason| reasons.iter().any(|given| given == reason.as_str()))
609 .collect()
610}
611
612/// Each person's settings, by username; the defaults for those who never chose.
613pub async fn settings_of(db: &D1Database, usernames: &[String]) -> Result<HashMap<String, InboxSettings>> {
614 if usernames.is_empty() {
615 return Ok(HashMap::new());
616 }
617 let marks = vec!["?"; usernames.len()].join(", ");
618 let values: Vec<JsValue> = usernames.iter().map(|name| JsValue::from(name.as_str())).collect();
619 Ok(db
620 .prepare(format!("SELECT username, email, default_watch, security_alerts, security_digest FROM inbox_settings WHERE username IN ({marks})"))
621 .bind(&values)?
622 .all()
623 .await?
624 .results::<SettingsRow>()?
625 .into_iter()
626 .map(|row| (row.username.clone(), row.into_settings()))
627 .collect())
628}
629
630pub async fn settings(db: &D1Database, a: InboxCountsArgs) -> Result<InboxSettings> {
631 let username = a.username.to_lowercase();
632 Ok(settings_of(db, std::slice::from_ref(&username))
633 .await?
634 .remove(&username)
635 .unwrap_or_default())
636}
637
638pub async fn update_settings(db: &D1Database, a: UpdateInboxSettingsArgs) -> Result<InboxSettings> {
639 let username = a.username.to_lowercase();
640 let email = a
641 .email
642 .map(|reasons| {
643 let names: Vec<String> = reasons.iter().map(|reason| reason.as_str().to_owned()).collect();
644 serde_json::to_string(&email_reasons(&names).iter().map(|reason| reason.as_str()).collect::<Vec<_>>())
645 })
646 .transpose()?;
647 db.prepare(
648 "INSERT INTO inbox_settings (username, email, default_watch, security_alerts, security_digest, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
649 ON CONFLICT (username) DO UPDATE SET
650 email = COALESCE(excluded.email, inbox_settings.email),
651 default_watch = COALESCE(excluded.default_watch, inbox_settings.default_watch),
652 security_alerts = COALESCE(excluded.security_alerts, inbox_settings.security_alerts),
653 security_digest = COALESCE(excluded.security_digest, inbox_settings.security_digest),
654 updated_at = excluded.updated_at",
655 )
656 .bind(&[
657 username.as_str().into(),
658 email.map_or(JsValue::NULL, JsValue::from),
659 a.default_watch.map_or(JsValue::NULL, |level| level.as_str().into()),
660 a.security_alerts.map_or(JsValue::NULL, |on| i32::from(on).into()),
661 a.security_digest.map_or(JsValue::NULL, |cadence| cadence.as_str().into()),
662 rfc3339(now_ms()).into(),
663 ])?
664 .run()
665 .await?;
666 settings(db, InboxCountsArgs { username }).await
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use g1t_contracts::credentials::Principal;
673
674 fn row(state: &str, reason: Option<&str>) -> SubscriptionRow {
675 SubscriptionRow {
676 username: "ana".into(),
677 state: state.into(),
678 reason: reason.map(str::to_owned),
679 chosen_at: None,
680 }
681 }
682
683 #[test]
684 fn a_row_says_more_than_taking_part() {
685 assert_eq!(standing(None, Some(Reason::Author)), (true, false, Some(Reason::Author)));
686 assert_eq!(standing(None, None), (false, false, None));
687 assert_eq!(standing(Some(&row("unsubscribed", None)), Some(Reason::Author)), (false, false, None));
688 assert_eq!(standing(Some(&row("ignored", None)), Some(Reason::Author)), (false, true, None));
689 assert_eq!(standing(Some(&row("subscribed", Some("comment"))), None), (true, false, Some(Reason::Comment)));
690 assert_eq!(standing(Some(&row("subscribed", None)), None), (true, false, Some(Reason::Manual)));
691 }
692
693 #[test]
694 fn taking_part_is_owning_writing_being_assigned_or_reviewing() {
695 let subject = InboxSubject {
696 author: Principal { id: "usr_g1t_agent".into(), username: "g1t".into() },
697 requested_by: Some(Principal { id: "usr_ana".into(), username: "ana".into() }),
698 assignees: vec!["bo".into()],
699 reviewers: vec!["cy".into()],
700 ..InboxSubject::default()
701 };
702 assert_eq!(implicit(&subject, "ANA"), Some(Reason::Author));
703 assert_eq!(implicit(&subject, "bo"), Some(Reason::Assign));
704 assert_eq!(implicit(&subject, "cy"), Some(Reason::ReviewRequested));
705 assert_eq!(implicit(&subject, "dee"), None);
706 }
707
708 #[test]
709 fn an_audience_knows_who_ignores_unsubscribed_and_watches() {
710 let audience = Audience {
711 subscriptions: vec![
712 Subscription { username: "ana".into(), state: State::Ignored, reason: None },
713 Subscription { username: "bo".into(), state: State::Unsubscribed, reason: None },
714 Subscription { username: "cy".into(), state: State::Subscribed, reason: Some(Reason::Mention) },
715 Subscription { username: "dee".into(), state: State::Subscribed, reason: None },
716 ],
717 watchers: vec![
718 Watcher { username: "eve".into(), level: WatchLevel::Ignore, events: Vec::new() },
719 Watcher { username: "fay".into(), level: WatchLevel::Custom, events: vec!["deployments".into()] },
720 Watcher { username: "gus".into(), level: WatchLevel::All, events: Vec::new() },
721 ],
722 };
723 assert!(audience.ignores("ana") && audience.ignores("eve") && !audience.ignores("bo"));
724 assert!(audience.unsubscribed("bo") && !audience.unsubscribed("cy"));
725 assert_eq!(
726 audience.subscribed().collect::<Vec<_>>(),
727 vec![("cy".to_owned(), Reason::Mention), ("dee".to_owned(), Reason::Manual)]
728 );
729 assert_eq!(audience.watching("deployments").collect::<Vec<_>>(), vec!["fay", "gus"]);
730 assert_eq!(audience.watching("work_items").collect::<Vec<_>>(), vec!["gus"]);
731 }
732
733 #[test]
734 fn choices_keep_only_what_is_known() {
735 assert_eq!(custom_events(&["Pulls".into(), "releases".into(), "issues".into(), "work_items".into(), "pulls".into()]), vec!["work_items", "pulls"]);
736 assert_eq!(email_reasons(&["mention".into(), "nope".into(), "agent".into()]), vec![Reason::Agent, Reason::Mention]);
737 let settings = SettingsRow { username: "ana".into(), email: None, default_watch: Some("participating".into()), security_alerts: None, security_digest: None }.into_settings();
738 assert_eq!(settings.email, DEFAULT_EMAIL.to_vec());
739 assert_eq!(settings.default_watch, WatchLevel::Participating);
740 // Until chosen: urgent alerts at once, the rest daily.
741 assert!(settings.security_alerts);
742 assert_eq!(settings.security_digest, DigestCadence::Daily);
743 let settings = SettingsRow { username: "ana".into(), email: Some("[]".into()), default_watch: None, security_alerts: Some(0.0), security_digest: Some("weekly".into()) }.into_settings();
744 assert!(settings.email.is_empty());
745 assert_eq!(settings.default_watch, WatchLevel::All);
746 assert!(!settings.security_alerts);
747 assert_eq!(settings.security_digest, DigestCadence::Weekly);
748 let settings = SettingsRow { username: "ana".into(), email: None, default_watch: None, security_alerts: None, security_digest: Some("hourly".into()) }.into_settings();
749 assert_eq!(settings.security_digest, DigestCadence::Daily);
750 }
751}