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 watching | 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 | ||
| 12 | use std::collections::HashMap; | |
| 13 | ||
| 14 | use g1t_contracts::events::Event; | |
| 15 | use g1t_contracts::inbox::*; | |
| 16 | use g1t_kit::now_ms; | |
| 17 | use g1t_contracts::time::rfc3339; | |
| 18 | use serde::Deserialize; | |
| 19 | use worker::wasm_bindgen::JsValue; | |
| 20 | use worker::{D1Database, D1PreparedStatement, Fetcher, Result}; | |
| 21 | ||
| 22 | use crate::inbox::{is_g1t, is_g1t_id, numbered_thread}; | |
| 23 | ||
| 24 | /// The most watchers read for one repository's event. | |
| 25 | const MAX_WATCHERS: u32 = 5000; | |
| 26 | ||
| 27 | /// Where a person stands on one thread, by their own row. | |
| 28 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 29 | pub enum State { | |
| 30 | Subscribed, | |
| 31 | Unsubscribed, | |
| 32 | Ignored, | |
| 33 | } | |
| 34 | ||
| 35 | impl 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)] | |
| 52 | pub struct Subscription { | |
| 53 | pub username: String, | |
| 54 | pub state: State, | |
| 55 | pub reason: Option<Reason>, | |
| 56 | } | |
| 57 | ||
| 58 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 59 | pub 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)] | |
| 68 | pub struct Audience { | |
| 69 | pub subscriptions: Vec<Subscription>, | |
| 70 | pub watchers: Vec<Watcher>, | |
| 71 | } | |
| 72 | ||
| 73 | impl 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)] | |
| 115 | struct SubscriptionRow { | |
| 116 | username: String, | |
| 117 | state: String, | |
| 118 | reason: Option<String>, | |
| 119 | chosen_at: Option<String>, | |
| 120 | } | |
| 121 | ||
| 122 | impl 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. | |
| 133 | pub 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)] | |
| 146 | struct WatcherRow { | |
| 147 | username: String, | |
| 148 | level: String, | |
| 149 | events: Option<String>, | |
| 150 | } | |
| 151 | ||
| 152 | fn 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-a69aeabc4b0deeb97 | 156 | /// How many watch a repository: all of it, or some of it. |
| 157 | pub 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 watching | 170 | /// Everyone who watches a repository other than the default way. |
| 171 | pub 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. | |
| 192 | pub 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). | |
| 224 | pub 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)] | |
| 259 | struct 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. | |
| 269 | struct Target { | |
| 270 | thread: String, | |
| 271 | repo_id: String, | |
| 272 | number: u32, | |
| 273 | repo: Option<String>, | |
| 274 | } | |
| 275 | ||
| 276 | async 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. | |
| 309 | fn 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. | |
| 323 | fn 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. | |
| 334 | pub 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). | |
| 375 | pub 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)] | |
| 442 | struct WatchingRow { | |
| 443 | repo_id: String, | |
| 444 | repo: Option<String>, | |
| 445 | level: String, | |
| 446 | events: Option<String>, | |
| 447 | updated_at: Option<String>, | |
| 448 | } | |
| 449 | ||
| 450 | impl 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. | |
| 463 | pub 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. | |
| 476 | pub 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. | |
| 486 | pub 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. | |
| 527 | pub 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)] | |
| 545 | struct 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 watching | 553 | } |
| 554 | ||
| 555 | impl 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 watching | 566 | } |
| 567 | } | |
| 568 | } | |
| 569 | ||
| 570 | /// Reasons as given, known ones only, each once, in their order. | |
| 571 | pub 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. | |
| 579 | pub 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 watching | 587 | .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 | ||
| 596 | pub 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 | ||
| 604 | pub async fn update_settings(db: &D1Database, a: UpdateInboxSettingsArgs) -> Result<InboxSettings> { | |
| 605 | let username = a.username.to_lowercase(); | |
| 606 | let email = a | |
| 607 | ||
| 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 watching | 615 | 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 watching | 620 | 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 watching | 628 | rfc3339(now_ms()).into(), |
| 629 | ])? | |
| 630 | .run() | |
| 631 | .await?; | |
| 632 | settings(db, InboxCountsArgs { username }).await | |
| 633 | } | |
| 634 | ||
| 635 | #[cfg(test)] | |
| 636 | mod 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 watching | 704 | 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 watching | 710 | 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 watching | 716 | } |
| 717 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.