Skip to content

g1t/services/events/src/subscriptions.rs

700 lines25,868 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/// 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/// 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>,
549}
550
551impl SettingsRow {
552 fn into_settings(self) -> InboxSettings {
553 let defaults = InboxSettings::default();
554 InboxSettings {
555 email: match self.email.as_deref().and_then(|text| serde_json::from_str::<Vec<String>>(text).ok()) {
556 Some(reasons) => email_reasons(&reasons),
557 None => defaults.email,
558 },
559 default_watch: self.default_watch.as_deref().and_then(WatchLevel::parse).unwrap_or(defaults.default_watch),
560 }
561 }
562}
563
564/// Reasons as given, known ones only, each once, in their order.
565pub fn email_reasons(reasons: &[String]) -> Vec<Reason> {
566 Reason::ALL
567 .into_iter()
568 .filter(|reason| reasons.iter().any(|given| given == reason.as_str()))
569 .collect()
570}
571
572/// Each person's settings, by username; the defaults for those who never chose.
573pub async fn settings_of(db: &D1Database, usernames: &[String]) -> Result<HashMap<String, InboxSettings>> {
574 if usernames.is_empty() {
575 return Ok(HashMap::new());
576 }
577 let marks = vec!["?"; usernames.len()].join(", ");
578 let values: Vec<JsValue> = usernames.iter().map(|name| JsValue::from(name.as_str())).collect();
579 Ok(db
580 .prepare(format!("SELECT username, email, default_watch FROM inbox_settings WHERE username IN ({marks})"))
581 .bind(&values)?
582 .all()
583 .await?
584 .results::<SettingsRow>()?
585 .into_iter()
586 .map(|row| (row.username.clone(), row.into_settings()))
587 .collect())
588}
589
590pub async fn settings(db: &D1Database, a: InboxCountsArgs) -> Result<InboxSettings> {
591 let username = a.username.to_lowercase();
592 Ok(settings_of(db, std::slice::from_ref(&username))
593 .await?
594 .remove(&username)
595 .unwrap_or_default())
596}
597
598pub async fn update_settings(db: &D1Database, a: UpdateInboxSettingsArgs) -> Result<InboxSettings> {
599 let username = a.username.to_lowercase();
600 let email = a
601 .email
602 .map(|reasons| {
603 let names: Vec<String> = reasons.iter().map(|reason| reason.as_str().to_owned()).collect();
604 serde_json::to_string(&email_reasons(&names).iter().map(|reason| reason.as_str()).collect::<Vec<_>>())
605 })
606 .transpose()?;
607 db.prepare(
608 "INSERT INTO inbox_settings (username, email, default_watch, updated_at) VALUES (?1, ?2, ?3, ?4)
609 ON CONFLICT (username) DO UPDATE SET
610 email = COALESCE(excluded.email, inbox_settings.email),
611 default_watch = COALESCE(excluded.default_watch, inbox_settings.default_watch),
612 updated_at = excluded.updated_at",
613 )
614 .bind(&[
615 username.as_str().into(),
616 email.map_or(JsValue::NULL, JsValue::from),
617 a.default_watch.map_or(JsValue::NULL, |level| level.as_str().into()),
618 rfc3339(now_ms()).into(),
619 ])?
620 .run()
621 .await?;
622 settings(db, InboxCountsArgs { username }).await
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use g1t_contracts::credentials::Principal;
629
630 fn row(state: &str, reason: Option<&str>) -> SubscriptionRow {
631 SubscriptionRow {
632 username: "ana".into(),
633 state: state.into(),
634 reason: reason.map(str::to_owned),
635 chosen_at: None,
636 }
637 }
638
639 #[test]
640 fn a_row_says_more_than_taking_part() {
641 assert_eq!(standing(None, Some(Reason::Author)), (true, false, Some(Reason::Author)));
642 assert_eq!(standing(None, None), (false, false, None));
643 assert_eq!(standing(Some(&row("unsubscribed", None)), Some(Reason::Author)), (false, false, None));
644 assert_eq!(standing(Some(&row("ignored", None)), Some(Reason::Author)), (false, true, None));
645 assert_eq!(standing(Some(&row("subscribed", Some("comment"))), None), (true, false, Some(Reason::Comment)));
646 assert_eq!(standing(Some(&row("subscribed", None)), None), (true, false, Some(Reason::Manual)));
647 }
648
649 #[test]
650 fn taking_part_is_owning_writing_being_assigned_or_reviewing() {
651 let subject = InboxSubject {
652 author: Principal { id: "usr_g1t_agent".into(), username: "g1t".into() },
653 requested_by: Some(Principal { id: "usr_ana".into(), username: "ana".into() }),
654 assignees: vec!["bo".into()],
655 reviewers: vec!["cy".into()],
656 ..InboxSubject::default()
657 };
658 assert_eq!(implicit(&subject, "ANA"), Some(Reason::Author));
659 assert_eq!(implicit(&subject, "bo"), Some(Reason::Assign));
660 assert_eq!(implicit(&subject, "cy"), Some(Reason::ReviewRequested));
661 assert_eq!(implicit(&subject, "dee"), None);
662 }
663
664 #[test]
665 fn an_audience_knows_who_ignores_unsubscribed_and_watches() {
666 let audience = Audience {
667 subscriptions: vec![
668 Subscription { username: "ana".into(), state: State::Ignored, reason: None },
669 Subscription { username: "bo".into(), state: State::Unsubscribed, reason: None },
670 Subscription { username: "cy".into(), state: State::Subscribed, reason: Some(Reason::Mention) },
671 Subscription { username: "dee".into(), state: State::Subscribed, reason: None },
672 ],
673 watchers: vec![
674 Watcher { username: "eve".into(), level: WatchLevel::Ignore, events: Vec::new() },
675 Watcher { username: "fay".into(), level: WatchLevel::Custom, events: vec!["deployments".into()] },
676 Watcher { username: "gus".into(), level: WatchLevel::All, events: Vec::new() },
677 ],
678 };
679 assert!(audience.ignores("ana") && audience.ignores("eve") && !audience.ignores("bo"));
680 assert!(audience.unsubscribed("bo") && !audience.unsubscribed("cy"));
681 assert_eq!(
682 audience.subscribed().collect::<Vec<_>>(),
683 vec![("cy".to_owned(), Reason::Mention), ("dee".to_owned(), Reason::Manual)]
684 );
685 assert_eq!(audience.watching("deployments").collect::<Vec<_>>(), vec!["fay", "gus"]);
686 assert_eq!(audience.watching("issues").collect::<Vec<_>>(), vec!["gus"]);
687 }
688
689 #[test]
690 fn choices_keep_only_what_is_known() {
691 assert_eq!(custom_events(&["Pulls".into(), "releases".into(), "issues".into(), "pulls".into()]), vec!["issues", "pulls"]);
692 assert_eq!(email_reasons(&["mention".into(), "nope".into(), "agent".into()]), vec![Reason::Agent, Reason::Mention]);
693 let settings = SettingsRow { username: "ana".into(), email: None, default_watch: Some("participating".into()) }.into_settings();
694 assert_eq!(settings.email, DEFAULT_EMAIL.to_vec());
695 assert_eq!(settings.default_watch, WatchLevel::Participating);
696 let settings = SettingsRow { username: "ana".into(), email: Some("[]".into()), default_watch: None }.into_settings();
697 assert!(settings.email.is_empty());
698 assert_eq!(settings.default_watch, WatchLevel::All);
699 }
700}