Skip to content

g1t/services/events/src/subscriptions.rs

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