1use std::collections::{BTreeMap, BTreeSet};
23use std::fs::File;
24use std::path::{Path, PathBuf};
25use std::time::Duration;
26
27use serde::{Deserialize, Serialize};
28
29use crate::agent::Agent;
30use crate::checkin;
31use crate::clock;
32use crate::config::{Config, ListenCfg, OwnPr, ReviewOrIgnore};
33use crate::error::Result;
34use crate::model::{IssueRun, ItemKind, Login, PrSweepRow, Status};
35use crate::proc;
36use crate::repo::{ApiResponse, Repo};
37use crate::review_only::{self, RecheckMode, ReviewOpts};
38use crate::spend;
39use crate::{bail, log, logdim, logwarn, spar_err};
40
41const FLOOR_SECS: u64 = 15;
43const SEEN_FOR_SECS: u64 = 7 * 86_400;
45const MAX_PAGES: usize = 50;
48const MAX_FAILURES: u32 = 5;
50
51pub const STATE_VERSION: u32 = 1;
52
53#[derive(Debug, Clone, Default)]
55pub struct Opts {
56 pub items: Vec<i64>,
58 pub once: bool,
60 pub dry_run: bool,
62 pub since: Option<Duration>,
64 pub limit: usize,
66}
67
68#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
75pub struct Thread {
76 #[serde(default)]
77 pub id: String,
78 #[serde(default)]
79 pub reason: String,
80 #[serde(default)]
81 pub updated_at: String,
82 #[serde(default)]
83 pub subject: Subject,
84}
85
86#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
87pub struct Subject {
88 #[serde(default)]
89 pub title: String,
90 #[serde(default)]
93 pub url: Option<String>,
94 #[serde(default)]
95 pub latest_comment_url: Option<String>,
96 #[serde(default, rename = "type")]
98 pub kind: String,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum Trigger {
108 Pushed,
110 Replied,
112 Mentioned,
113 ReviewRequested,
114 Assigned,
115 Closed,
116}
117
118impl Trigger {
119 fn noun(self) -> &'static str {
121 match self {
122 Trigger::Pushed => "a push",
123 Trigger::Replied => "a reply",
124 Trigger::Mentioned => "a mention",
125 Trigger::ReviewRequested => "a review request",
126 Trigger::Assigned => "an assignment",
127 Trigger::Closed => "a close",
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct Event {
135 pub number: i64,
136 pub kind: ItemKind,
137 pub trigger: Trigger,
138 pub who: Option<String>,
140 pub head: Option<(String, String)>,
142 pub commits: Option<u64>,
144 pub from_thread: bool,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
154pub struct Pending {
155 pub kind: ItemKind,
156 pub first_seen: u64,
157 pub last_seen: u64,
158 pub triggers: BTreeSet<Trigger>,
159 #[serde(default)]
160 pub who: Vec<String>,
161 #[serde(default)]
162 pub head_from: Option<String>,
163 #[serde(default)]
164 pub head_to: Option<String>,
165}
166
167impl Pending {
168 pub fn because(&self) -> String {
170 let mut parts: Vec<String> = self.triggers.iter().map(|t| t.noun().to_string()).collect();
171 if parts.is_empty() {
172 parts.push("nothing in particular".into());
173 }
174 match parts.len() {
175 1 => parts.remove(0),
176 2 => format!("{} and {}", parts[0], parts[1]),
177 _ => {
178 let last = parts.pop().unwrap_or_default();
179 format!("{}, and {last}", parts.join(", "))
180 }
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187pub struct Watched {
188 pub kind: ItemKind,
189 #[serde(default)]
190 pub title: String,
191 #[serde(default)]
192 pub author: String,
193 #[serde(default)]
196 pub head: Option<String>,
197 #[serde(default)]
198 pub requested_noticed: bool,
199 #[serde(default)]
200 pub assigned_noticed: bool,
201 #[serde(default)]
202 pub failures: u32,
203 #[serde(default)]
204 pub retry_after: u64,
205 #[serde(default)]
206 pub said_capped: bool,
207 #[serde(default)]
208 pub said_resume: bool,
209 #[serde(default)]
210 pub last_dispatched: Option<u64>,
211 #[serde(default)]
216 pub held: BTreeSet<Trigger>,
217}
218
219impl Watched {
220 fn new(kind: ItemKind) -> Self {
221 Self {
222 kind,
223 title: String::new(),
224 author: String::new(),
225 head: None,
226 requested_noticed: false,
227 assigned_noticed: false,
228 failures: 0,
229 retry_after: 0,
230 said_capped: false,
231 said_resume: false,
232 last_dispatched: None,
233 held: BTreeSet::new(),
234 }
235 }
236}
237
238#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
241pub struct ListenState {
242 pub version: u32,
243 #[serde(default)]
245 pub watermark: String,
246 #[serde(default)]
249 pub last_modified: Option<String>,
250 #[serde(default)]
253 pub seen: BTreeMap<String, String>,
254 #[serde(default)]
255 pub items: BTreeMap<i64, Watched>,
256 #[serde(default)]
257 pub pending: BTreeMap<i64, Pending>,
258 #[serde(default)]
259 pub swept_once: bool,
260}
261
262impl ListenState {
263 fn watched(&mut self, number: i64, kind: ItemKind) -> &mut Watched {
264 self.items
265 .entry(number)
266 .or_insert_with(|| Watched::new(kind))
267 }
268
269 fn note(&mut self, event: &Event, now: u64) {
272 let held = std::mem::take(&mut self.watched(event.number, event.kind).held);
273 merge(&mut self.pending, event, now);
274 if let Some(p) = self.pending.get_mut(&event.number) {
275 p.triggers.extend(held);
276 }
277 }
278
279 fn give_up(&mut self, number: i64, kind: ItemKind) {
283 if let Some(p) = self.pending.remove(&number) {
284 self.watched(number, kind).held.extend(p.triggers);
285 }
286 }
287}
288
289pub fn parse_duration(text: &str) -> Result<Duration> {
295 let bad = || {
296 spar_err!(
297 "`--since` takes a number and a unit, like 30m, 2h or 1d, not {:?}",
298 text.trim()
299 )
300 };
301 let mut total = 0u64;
302 let mut digits = String::new();
303 let mut any = false;
304 for c in text.trim().chars() {
305 if c.is_ascii_digit() {
306 digits.push(c);
307 continue;
308 }
309 let n: u64 = digits.parse().map_err(|_| bad())?;
310 digits.clear();
311 let unit = match c {
312 's' => 1,
313 'm' => 60,
314 'h' => 3600,
315 'd' => 86_400,
316 _ => return Err(bad()),
317 };
318 total = total.saturating_add(n.saturating_mul(unit));
319 any = true;
320 }
321 if !digits.is_empty() || !any {
322 return Err(bad());
323 }
324 Ok(Duration::from_secs(total))
325}
326
327pub fn poll_interval(cfg: &ListenCfg, header: Option<&str>) -> u64 {
330 let asked = header
331 .and_then(|h| h.trim().parse::<u64>().ok())
332 .unwrap_or(0);
333 cfg.interval.max(asked).max(FLOOR_SECS)
334}
335
336pub fn rate_limit_wait(resp: &ApiResponse, now: u64) -> Option<u64> {
339 if resp.status != 403 && resp.status != 429 {
340 return None;
341 }
342 let retry_after = resp
343 .headers
344 .get("retry-after")
345 .and_then(|v| v.trim().parse::<u64>().ok());
346 let exhausted = resp
347 .headers
348 .get("x-ratelimit-remaining")
349 .is_some_and(|v| v.trim() == "0");
350 let reset = resp
351 .headers
352 .get("x-ratelimit-reset")
353 .and_then(|v| v.trim().parse::<u64>().ok())
354 .map(|at| at.saturating_sub(now));
355 let wait = match (retry_after, exhausted, reset) {
356 (Some(secs), _, _) => secs,
357 (None, true, Some(secs)) => secs,
358 _ => return None,
359 };
360 Some(wait.clamp(60, 3600))
361}
362
363fn status_message(resp: &ApiResponse) -> String {
366 serde_json::from_str::<serde_json::Value>(&resp.body)
367 .ok()
368 .and_then(|v| {
369 v.get("message")
370 .and_then(|m| m.as_str())
371 .map(|m| format!("HTTP {}: {m}", resp.status))
372 })
373 .unwrap_or_else(|| format!("HTTP {}", resp.status))
374}
375
376pub fn next_link(headers: &BTreeMap<String, String>) -> Option<String> {
378 let link = headers.get("link")?;
379 link.split(',').find_map(|part| {
380 let (url, rel) = part.split_once(';')?;
381 if !rel.contains("rel=\"next\"") {
382 return None;
383 }
384 let url = url.trim().trim_start_matches('<').trim_end_matches('>');
385 (!url.is_empty()).then(|| url.to_string())
386 })
387}
388
389pub fn number_from_url(url: &str) -> Option<i64> {
391 url.trim_end_matches('/')
392 .rsplit('/')
393 .next()
394 .and_then(|last| last.parse().ok())
395}
396
397pub fn events_from_threads(threads: &[Thread], state: &mut ListenState) -> Vec<Event> {
400 let mut out = Vec::new();
401 for thread in threads {
402 if state.seen.get(&thread.id) == Some(&thread.updated_at) {
403 continue;
404 }
405 if !thread.updated_at.is_empty() {
406 state
407 .seen
408 .insert(thread.id.clone(), thread.updated_at.clone());
409 if thread.updated_at > state.watermark {
410 state.watermark = thread.updated_at.clone();
411 }
412 }
413 let kind = match thread.subject.kind.as_str() {
414 "PullRequest" => ItemKind::Pr,
415 "Issue" => ItemKind::Issue,
416 _ => continue,
417 };
418 let Some(number) = thread.subject.url.as_deref().and_then(number_from_url) else {
419 continue;
420 };
421 let trigger = match thread.reason.as_str() {
422 "review_requested" => Trigger::ReviewRequested,
423 "assign" => Trigger::Assigned,
424 "mention" | "team_mention" => Trigger::Mentioned,
425 _ => Trigger::Replied,
429 };
430 out.push(Event {
431 number,
432 kind,
433 trigger,
434 who: None,
435 head: None,
436 commits: None,
437 from_thread: true,
438 });
439 }
440 out
441}
442
443pub fn events_from_sweep(
449 rows: &[PrSweepRow],
450 viewer: &str,
451 state: &mut ListenState,
452 named: &BTreeSet<i64>,
453 min_number: i64,
454 record_head: &dyn Fn(i64) -> Option<String>,
455 repo_mode: bool,
456) -> Vec<Event> {
457 let mut out = Vec::new();
458 let mut present: BTreeSet<i64> = BTreeSet::new();
459 for row in rows {
460 present.insert(row.number);
464 if row.number < min_number && !named.contains(&row.number) {
465 continue;
466 }
467 let watched = state.watched(row.number, ItemKind::Pr);
468 if !row.title.is_empty() {
469 watched.title = row.title.clone();
470 }
471 if let Some(author) = &row.author {
472 watched.author = author.login.clone();
473 }
474 if !row.state.eq_ignore_ascii_case("OPEN") {
475 out.push(Event {
476 number: row.number,
477 kind: ItemKind::Pr,
478 trigger: Trigger::Closed,
479 who: None,
480 head: None,
481 commits: None,
482 from_thread: false,
483 });
484 continue;
485 }
486 let from = match &watched.head {
487 Some(old) => Some(old.clone()),
488 None => record_head(row.number),
489 };
490 if let Some(old) = from {
491 if !row.head_ref_oid.is_empty() && !old.eq_ignore_ascii_case(&row.head_ref_oid) {
492 out.push(Event {
493 number: row.number,
494 kind: ItemKind::Pr,
495 trigger: Trigger::Pushed,
496 who: None,
497 head: Some((old, row.head_ref_oid.clone())),
498 commits: None,
499 from_thread: false,
500 });
501 }
502 }
503 if !row.head_ref_oid.is_empty() {
504 watched.head = Some(row.head_ref_oid.clone());
505 }
506 let asked = row
507 .review_requests
508 .iter()
509 .any(|l| crate::comments::same_login(&l.login, viewer));
510 if !asked {
513 watched.requested_noticed = false;
514 } else if !watched.requested_noticed {
515 watched.requested_noticed = true;
516 out.push(Event {
517 number: row.number,
518 kind: ItemKind::Pr,
519 trigger: Trigger::ReviewRequested,
520 who: None,
521 head: None,
522 commits: None,
523 from_thread: false,
524 });
525 }
526 let assigned = row
527 .assignees
528 .iter()
529 .any(|l| crate::comments::same_login(&l.login, viewer));
530 if assigned && !watched.assigned_noticed {
531 watched.assigned_noticed = true;
532 out.push(Event {
533 number: row.number,
534 kind: ItemKind::Pr,
535 trigger: Trigger::Assigned,
536 who: None,
537 head: None,
538 commits: None,
539 from_thread: false,
540 });
541 }
542 }
543 if repo_mode {
544 let gone: Vec<i64> = state
547 .items
548 .iter()
549 .filter(|(n, w)| w.kind == ItemKind::Pr && !present.contains(n))
550 .map(|(n, _)| *n)
551 .collect();
552 for number in gone {
553 out.push(Event {
554 number,
555 kind: ItemKind::Pr,
556 trigger: Trigger::Closed,
557 who: None,
558 head: None,
559 commits: None,
560 from_thread: false,
561 });
562 }
563 }
564 out
565}
566
567pub fn cap_first_sweep(events: Vec<Event>, limit: usize, first: bool) -> (Vec<Event>, usize) {
570 if !first {
571 return (events, 0);
572 }
573 let mut pushes: Vec<&Event> = events
574 .iter()
575 .filter(|e| e.trigger == Trigger::Pushed)
576 .collect();
577 pushes.sort_by_key(|e| e.number);
578 let allowed: BTreeSet<i64> = pushes.iter().take(limit).map(|e| e.number).collect();
579 let held = pushes.len().saturating_sub(allowed.len());
580 let kept = events
581 .into_iter()
582 .filter(|e| e.trigger != Trigger::Pushed || allowed.contains(&e.number))
583 .collect();
584 (kept, held)
585}
586
587pub fn merge(pending: &mut BTreeMap<i64, Pending>, event: &Event, now: u64) {
589 let p = pending.entry(event.number).or_insert_with(|| Pending {
590 kind: event.kind,
591 first_seen: now,
592 last_seen: now,
593 triggers: BTreeSet::new(),
594 who: Vec::new(),
595 head_from: None,
596 head_to: None,
597 });
598 p.last_seen = now;
599 p.triggers.insert(event.trigger);
600 if let Some(who) = &event.who {
601 if !p.who.contains(who) {
602 p.who.push(who.clone());
603 }
604 }
605 if let Some((from, to)) = &event.head {
606 if p.head_from.is_none() {
607 p.head_from = Some(from.clone());
608 }
609 p.head_to = Some(to.clone());
610 }
611}
612
613pub fn due(p: &Pending, now: u64, cfg: &ListenCfg, once: bool) -> bool {
616 once || now.saturating_sub(p.last_seen) >= cfg.settle
617 || now.saturating_sub(p.first_seen) >= cfg.max_wait
618}
619
620pub fn next_due(
624 state: &ListenState,
625 cfg: &ListenCfg,
626 acts_on: &dyn Fn(i64) -> bool,
627) -> Option<u64> {
628 state
629 .pending
630 .iter()
631 .filter(|(n, _)| acts_on(**n))
632 .map(|(n, p)| {
633 let settled = (p.last_seen + cfg.settle).min(p.first_seen + cfg.max_wait);
634 state
635 .items
636 .get(n)
637 .map_or(settled, |w| settled.max(w.retry_after))
638 })
639 .min()
640}
641
642#[derive(Debug, Clone, PartialEq, Eq)]
644pub enum Action {
645 Recheck(i64),
647 Review,
649 Checkin { reply_only: bool },
651 Capped(i64),
654 BelongsToResume,
656 Drop,
658 Ignore(&'static str),
660}
661
662impl Action {
663 pub fn phrase(&self, number: i64) -> String {
665 match self {
666 Action::Recheck(pr) if *pr == number => "take a second look".to_string(),
667 Action::Recheck(pr) => format!("take a second look at #{pr}"),
668 Action::Review => "review it".to_string(),
669 Action::Checkin { reply_only: false } => "check in on it".to_string(),
670 Action::Checkin { reply_only: true } => "answer the comments on it".to_string(),
671 Action::Capped(_) | Action::BelongsToResume => "leave it".to_string(),
672 Action::Drop => "drop it".to_string(),
673 Action::Ignore(_) => "ignore it".to_string(),
674 }
675 }
676
677 fn runs(&self) -> bool {
678 matches!(
679 self,
680 Action::Recheck(_) | Action::Review | Action::Checkin { .. }
681 )
682 }
683}
684
685#[derive(Debug, Clone, Default, PartialEq, Eq)]
688pub struct Facts {
689 pub number: i64,
690 pub named: bool,
691 pub open: bool,
692 pub author_is_viewer: bool,
695 pub has_posted_record: bool,
697 pub recheck_capped: bool,
698 pub has_custody_state: bool,
700 pub below_floor: bool,
701 pub linked_pr_with_record: Option<i64>,
703 pub review_pending: bool,
706}
707
708pub fn review_pending(
712 requests: &[Login],
713 viewer: &str,
714 in_team: &dyn Fn(&str) -> Option<bool>,
715) -> bool {
716 requests
717 .iter()
718 .any(|r| crate::comments::same_login(&r.login, viewer))
719 || requests
720 .iter()
721 .filter(|r| r.login.is_empty())
722 .any(|team| in_team(&team.slug).unwrap_or(true))
723}
724
725pub fn settle(p: &mut Pending, f: &Facts) -> bool {
731 let stale =
732 p.kind == ItemKind::Pr && !f.review_pending && p.triggers.remove(&Trigger::ReviewRequested);
733 if stale {
734 p.triggers.insert(Trigger::Replied);
735 }
736 stale
737}
738
739pub fn plan(p: &Pending, f: &Facts, cfg: &ListenCfg) -> Action {
741 use Trigger::*;
742 if !f.open || p.triggers.contains(&Closed) {
743 return Action::Drop;
744 }
745 if f.below_floor && !f.named {
746 return Action::Ignore("it is below min_number and was not named");
747 }
748 match p.kind {
749 ItemKind::Issue => {
750 let asked = p.triggers.contains(&Mentioned)
751 || p.triggers.contains(&Assigned)
752 || (f.named && p.triggers.contains(&Replied));
753 if !asked {
754 return Action::Ignore("nothing on the issue asked for spar");
755 }
756 match f.linked_pr_with_record {
759 None => Action::Ignore("an issue with no reviewed pull request"),
760 Some(_) if f.author_is_viewer => Action::Ignore("its pull request is your own"),
761 Some(pr) if f.recheck_capped => Action::Capped(pr),
762 Some(pr) => Action::Recheck(pr),
763 }
764 }
765 ItemKind::Pr => {
766 if f.author_is_viewer {
771 let spoken_to = p
772 .triggers
773 .iter()
774 .any(|t| matches!(t, Replied | Mentioned | ReviewRequested));
775 if !spoken_to {
776 return Action::Ignore("a push to your own pull request");
777 }
778 return match cfg.on_own_pr {
779 OwnPr::Checkin => Action::Checkin { reply_only: false },
780 OwnPr::Reply => Action::Checkin { reply_only: true },
781 OwnPr::Ignore => Action::Ignore("on_own_pr is ignore"),
782 };
783 }
784 if f.has_posted_record {
785 return if f.recheck_capped {
786 Action::Capped(f.number)
787 } else {
788 Action::Recheck(f.number)
789 };
790 }
791 if f.has_custody_state {
792 return Action::BelongsToResume;
793 }
794 if f.named {
795 return Action::Review;
796 }
797 if p.triggers.contains(&ReviewRequested) {
798 return match cfg.on_review_requested {
799 ReviewOrIgnore::Review => Action::Review,
800 ReviewOrIgnore::Ignore => Action::Ignore("on_review_requested is ignore"),
801 };
802 }
803 if p.triggers.contains(&Assigned) {
804 return match cfg.on_assigned {
805 ReviewOrIgnore::Review => Action::Review,
806 ReviewOrIgnore::Ignore => Action::Ignore("on_assigned is ignore"),
807 };
808 }
809 if p.triggers.contains(&Mentioned) {
810 return Action::Review;
811 }
812 Action::Ignore("a push or a comment on a pull request spar has not reviewed")
813 }
814 }
815}
816
817pub fn prune_seen(state: &mut ListenState, now: u64) {
819 state.seen.retain(|_, at| {
820 clock::parse_rfc3339(at).is_none_or(|t| now.saturating_sub(t) < SEEN_FOR_SECS)
821 });
822}
823
824pub fn backoff_secs(failures: u32) -> u64 {
826 (60u64 << failures.saturating_sub(1).min(10)).min(900)
827}
828
829pub fn dispatch_backoff_secs(failures: u32) -> u64 {
831 (900u64 << failures.saturating_sub(1).min(10)).min(6 * 3600)
832}
833
834pub fn retry_wait(moved: bool, failures: u32, cfg: &ListenCfg) -> u64 {
838 if moved {
839 cfg.settle.max(FLOOR_SECS)
840 } else {
841 dispatch_backoff_secs(failures)
842 }
843}
844
845#[derive(Debug, Default)]
847struct Backoff {
848 failures: u32,
849 said: bool,
850}
851
852impl Backoff {
853 fn failed(&mut self, what: &str, why: &str) -> u64 {
854 self.failures += 1;
855 let wait = backoff_secs(self.failures);
856 let line = format!(
857 "could not {what}: {why}. Trying again in {}.",
858 spend::clock(Duration::from_secs(wait))
859 );
860 if self.said {
861 logdim!("{line}");
862 } else {
863 logwarn!("{line}");
864 self.said = true;
865 }
866 wait
867 }
868
869 fn recovered(&mut self) {
870 if self.said {
871 log!("gh is answering again");
872 }
873 *self = Self::default();
874 }
875}
876
877#[derive(Debug, Serialize, Deserialize)]
884struct LockFile {
885 pid: u32,
886 started: String,
887}
888
889#[derive(Debug)]
898struct Lock {
899 path: PathBuf,
900 _file: File,
902}
903
904impl Lock {
905 fn acquire(repo: &Repo, now: u64) -> Result<Lock> {
906 Lock::at(repo.listen_lock_path(), now)
907 }
908
909 fn at(path: PathBuf, now: u64) -> Result<Lock> {
910 if let Some(parent) = path.parent() {
911 std::fs::create_dir_all(parent)?;
912 }
913 for _ in 0..3 {
918 let Some(mut file) = open_locked(&path)? else {
919 let pid = std::fs::read_to_string(&path)
920 .ok()
921 .and_then(|text| serde_json::from_str::<LockFile>(&text).ok())
922 .map(|h| h.pid.to_string())
923 .unwrap_or_else(|| "unknown".into());
924 bail!(
925 "another `spar listen` (pid {pid}) is already watching this checkout. \
926 Stop it first.{}",
927 if cfg!(unix) {
928 String::new()
929 } else {
930 format!(" If that process is gone, delete {}.", path.display())
931 }
932 );
933 };
934 if !same_file(&file, &path) {
935 continue;
936 }
937 file.set_len(0)?;
938 serde_json::to_writer_pretty(
939 &mut file,
940 &LockFile {
941 pid: std::process::id(),
942 started: clock::rfc3339(now),
943 },
944 )?;
945 return Ok(Lock { path, _file: file });
946 }
947 bail!("could not take the listen lock at {}", path.display())
948 }
949}
950
951impl Drop for Lock {
952 fn drop(&mut self) {
953 let _ = std::fs::remove_file(&self.path);
956 }
957}
958
959#[cfg(unix)]
961fn open_locked(path: &Path) -> Result<Option<File>> {
962 use std::os::unix::io::AsRawFd;
963 let file = std::fs::OpenOptions::new()
964 .read(true)
965 .write(true)
966 .create(true)
967 .truncate(false)
968 .open(path)?;
969 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
970 return Ok(Some(file));
971 }
972 let e = std::io::Error::last_os_error();
973 if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
974 Ok(None)
975 } else {
976 Err(e.into())
977 }
978}
979
980#[cfg(not(unix))]
983fn open_locked(path: &Path) -> Result<Option<File>> {
984 match std::fs::OpenOptions::new()
985 .write(true)
986 .create_new(true)
987 .open(path)
988 {
989 Ok(file) => Ok(Some(file)),
990 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
991 Err(e) => Err(e.into()),
992 }
993}
994
995#[cfg(unix)]
996fn same_file(file: &File, path: &Path) -> bool {
997 use std::os::unix::fs::MetadataExt;
998 match (file.metadata(), std::fs::metadata(path)) {
999 (Ok(open), Ok(named)) => open.dev() == named.dev() && open.ino() == named.ino(),
1000 _ => false,
1001 }
1002}
1003
1004#[cfg(not(unix))]
1005fn same_file(_file: &File, _path: &Path) -> bool {
1006 true
1007}
1008
1009enum Polled {
1014 Nothing(u64),
1016 Threads(Vec<Thread>, u64),
1017 RateLimited(u64),
1019 Failed(String),
1020}
1021
1022fn poll(repo: &Repo, cfg: &ListenCfg, state: &mut ListenState, now: u64) -> Polled {
1023 let endpoint = format!(
1024 "repos/{{owner}}/{{repo}}/notifications?participating=true&all=true&per_page=100&since={}",
1025 state.watermark
1026 );
1027 let last_modified = state.last_modified.clone();
1028 let headers: Vec<(&str, &str)> = last_modified
1029 .as_deref()
1030 .map(|lm| vec![("If-Modified-Since", lm)])
1031 .unwrap_or_default();
1032 let resp = match repo.gh_api_raw(&endpoint, &headers) {
1033 Ok(resp) => resp,
1034 Err(e) => return Polled::Failed(e.last_line().to_string()),
1035 };
1036 let interval = poll_interval(cfg, resp.headers.get("x-poll-interval").map(String::as_str));
1037 match resp.status {
1038 304 => Polled::Nothing(interval),
1039 200 => match every_page(repo, &resp, &state.watermark, now) {
1040 Ok(threads) => {
1041 state.last_modified = resp
1044 .headers
1045 .get("last-modified")
1046 .or_else(|| resp.headers.get("date"))
1047 .cloned();
1048 Polled::Threads(threads, interval)
1049 }
1050 Err(failed) => failed,
1051 },
1052 401 => Polled::Failed("gh is not authenticated; run `gh auth status`".into()),
1053 _ => refused(&resp, now),
1054 }
1055}
1056
1057fn refused(resp: &ApiResponse, now: u64) -> Polled {
1059 match rate_limit_wait(resp, now) {
1060 Some(wait) => Polled::RateLimited(wait),
1061 None => Polled::Failed(status_message(resp)),
1062 }
1063}
1064
1065fn every_page(
1072 repo: &Repo,
1073 first: &ApiResponse,
1074 watermark: &str,
1075 now: u64,
1076) -> std::result::Result<Vec<Thread>, Polled> {
1077 let unexpected =
1078 |e: serde_json::Error| Polled::Failed(format!("unexpected notification data: {e}"));
1079 let mut threads: Vec<Thread> = serde_json::from_str(&first.body).map_err(unexpected)?;
1080 let mut next = next_link(&first.headers);
1081 let mut pages = 1;
1082 while let Some(url) = next.take() {
1083 if pages >= MAX_PAGES {
1084 return Err(Polled::Failed(format!(
1085 "more than {MAX_PAGES} pages of notifications since {watermark}. Run once with \
1086 --since, like --since 1d, to start from later and skip the rest"
1087 )));
1088 }
1089 let more = repo
1090 .gh_api_raw(&url, &[])
1091 .map_err(|e| Polled::Failed(e.last_line().to_string()))?;
1092 if more.status != 200 {
1093 return Err(refused(&more, now));
1094 }
1095 threads.extend(serde_json::from_str::<Vec<Thread>>(&more.body).map_err(unexpected)?);
1096 next = next_link(&more.headers);
1097 pages += 1;
1098 }
1099 Ok(threads)
1100}
1101
1102#[derive(Default)]
1104struct Swept {
1105 rows: Vec<PrSweepRow>,
1106 closed_issues: Vec<i64>,
1108}
1109
1110fn sweep_rows(
1113 repo: &Repo,
1114 cfg: &Config,
1115 repo_mode: bool,
1116 named_prs: &[i64],
1117 named_issues: &[i64],
1118 state: &ListenState,
1119) -> Result<Swept> {
1120 if repo_mode {
1121 return Ok(Swept {
1122 rows: repo.sweep_open_prs()?,
1123 closed_issues: Vec::new(),
1124 });
1125 }
1126 let watched = |n: &&i64| state.items.contains_key(*n);
1127 let mut swept = Swept::default();
1128 for number in named_prs.iter().filter(watched) {
1129 swept.rows.push(repo.sweep_pr(*number)?);
1130 }
1131 for issue in named_issues.iter().filter(watched) {
1132 if !repo.read_issue(*issue)?.state.eq_ignore_ascii_case("OPEN") {
1136 swept.closed_issues.push(*issue);
1137 continue;
1138 }
1139 if let Ok(Some(pr)) = repo.try_open_pr_for_issue(*issue, cfg.base_branch()) {
1142 if repo.read_review_record(pr.number).is_some_and(|r| r.posted) {
1143 swept.rows.push(repo.sweep_pr(pr.number)?);
1144 }
1145 }
1146 }
1147 Ok(swept)
1148}
1149
1150fn facts(repo: &Repo, cfg: &Config, number: i64, kind: ItemKind, named: bool) -> Result<Facts> {
1151 let viewer = repo.viewer_login()?.to_string();
1152 let below_floor = number < cfg.loop_cfg.min_number;
1153 match kind {
1154 ItemKind::Pr => {
1155 let row = repo.sweep_pr(number)?;
1156 let record = repo.read_review_record(number);
1157 let posted = record.as_ref().is_some_and(|r| r.posted);
1158 Ok(Facts {
1159 number,
1160 named,
1161 open: row.state.eq_ignore_ascii_case("OPEN"),
1162 author_is_viewer: row
1163 .author
1164 .as_ref()
1165 .is_some_and(|a| crate::comments::same_login(&a.login, &viewer)),
1166 has_posted_record: posted,
1167 recheck_capped: posted
1168 && record
1169 .as_ref()
1170 .is_some_and(|r| r.rechecks >= cfg.listen.max_rechecks),
1171 has_custody_state: repo.state_path(number).exists(),
1172 below_floor,
1173 linked_pr_with_record: None,
1174 review_pending: review_pending(&row.review_requests, &viewer, &|team| {
1175 repo.team_has_member(team, &viewer)
1176 }),
1177 })
1178 }
1179 ItemKind::Issue => {
1180 let issue = repo.read_issue(number)?;
1181 let linked = repo
1182 .try_open_pr_for_issue(number, cfg.base_branch())
1183 .ok()
1184 .flatten()
1185 .and_then(|pr| {
1186 repo.read_review_record(pr.number)
1187 .filter(|r| r.posted)
1188 .map(|r| (pr.number, r))
1189 });
1190 let (author_is_viewer, recheck_capped) = match &linked {
1192 Some((pr, record)) => {
1193 let row = repo.sweep_pr(*pr)?;
1194 (
1195 row.author
1196 .as_ref()
1197 .is_some_and(|a| crate::comments::same_login(&a.login, &viewer)),
1198 record.rechecks >= cfg.listen.max_rechecks,
1199 )
1200 }
1201 None => (false, false),
1202 };
1203 Ok(Facts {
1204 number,
1205 named,
1206 open: issue.state.eq_ignore_ascii_case("OPEN"),
1207 author_is_viewer,
1208 has_posted_record: false,
1209 recheck_capped,
1210 has_custody_state: false,
1211 below_floor,
1212 linked_pr_with_record: linked.map(|(pr, _)| pr),
1213 review_pending: false,
1214 })
1215 }
1216 }
1217}
1218
1219fn dispatch(
1220 agents: &[Agent],
1221 cfg: &Config,
1222 repo: &Repo,
1223 action: &Action,
1224 number: i64,
1225 p: &Pending,
1226) -> IssueRun {
1227 match action {
1228 Action::Recheck(pr) => review_only::recheck_pr(
1229 agents,
1230 cfg,
1231 repo,
1232 *pr,
1233 RecheckMode {
1234 dry_run: false,
1235 requested: p.triggers.contains(&Trigger::ReviewRequested),
1236 verdict_on_fallback: cfg.listen.verdicts,
1237 },
1238 ),
1239 Action::Review => review_only::review_pr(
1240 agents,
1241 cfg,
1242 repo,
1243 number,
1244 ReviewOpts {
1245 dry_run: false,
1246 verdict: cfg.listen.verdicts,
1247 },
1248 ),
1249 Action::Checkin { reply_only } => checkin::checkin_pr(
1250 agents,
1251 cfg,
1252 repo,
1253 number,
1254 &checkin::Mode {
1255 dry_run: false,
1256 reply_only: *reply_only,
1257 trust: cfg.loop_cfg.checkin_trust,
1258 again: false,
1259 resolve: cfg.loop_cfg.checkin_resolve,
1260 posts: checkin::posts(cfg),
1261 },
1262 ),
1263 other => {
1264 let mut run = IssueRun::new(number, format!("#{number}"));
1265 run.status = Status::Error;
1266 run.notes
1267 .push(format!("nothing to run: {}", other.phrase(number)));
1268 run
1269 }
1270 }
1271}
1272
1273fn describe(repo: &Repo, event: &Event) -> String {
1275 let n = event.number;
1276 match event.trigger {
1277 Trigger::Pushed => {
1278 let (from, to) = event.head.clone().unwrap_or_default();
1279 let short = |s: &str| s.chars().take(7).collect::<String>();
1280 let commits = repo
1281 .commits_between(&from, &to)
1282 .map(|c| format!(", {c} new commit{} since the review", plural(c as usize)))
1283 .unwrap_or_default();
1284 format!(
1285 "#{n}: head moved {} to {}{commits}",
1286 short(&from),
1287 short(&to)
1288 )
1289 }
1290 Trigger::Replied => match &event.who {
1291 Some(who) => format!("#{n}: {who} commented"),
1292 None => format!("#{n}: somebody commented"),
1293 },
1294 Trigger::Mentioned => match &event.who {
1295 Some(who) => format!("#{n}: mentioned by {who}"),
1296 None => format!("#{n}: mentioned"),
1297 },
1298 Trigger::ReviewRequested if event.from_thread => {
1301 format!("#{n}: news on a thread that asked for your review")
1302 }
1303 Trigger::ReviewRequested => format!("#{n}: your review was requested"),
1304 Trigger::Assigned => format!("#{n}: assigned to you"),
1305 Trigger::Closed => format!("#{n}: closed, no longer watched"),
1306 }
1307}
1308
1309fn plural(n: usize) -> &'static str {
1310 if n == 1 {
1311 ""
1312 } else {
1313 "s"
1314 }
1315}
1316
1317enum Ending {
1322 Interrupted,
1323 Once { failed: bool },
1324 Done,
1325}
1326
1327pub fn run(agents: &[Agent], cfg: &Config, repo: &Repo, opts: Opts) -> Result<i32> {
1329 proc::stop_on_signal()?;
1330 if !cfg!(unix) {
1331 logdim!(
1332 "Ctrl-C ends spar at once on this platform; an agent call in flight is not stopped"
1333 );
1334 }
1335 let now = clock::now_secs();
1336 let lock = Lock::acquire(repo, now)?;
1337 let viewer = repo.viewer_login()?.to_string();
1338 let name = repo
1339 .repo_name()
1340 .unwrap_or_else(|| repo.root().display().to_string());
1341
1342 let named: BTreeSet<i64> = opts.items.iter().copied().collect();
1345 let mut named_prs = Vec::new();
1346 let mut named_issues = Vec::new();
1347 for number in &named {
1348 match repo.item_kind(*number)? {
1349 ItemKind::Pr => named_prs.push(*number),
1350 ItemKind::Issue => named_issues.push(*number),
1351 }
1352 }
1353 let repo_mode = named.is_empty();
1354 let mut watching: BTreeSet<i64> = named.clone();
1359 let save = |state: &ListenState| {
1362 if !opts.dry_run {
1363 write_state(repo, state);
1364 }
1365 };
1366
1367 let mut state = read_state(repo);
1368 if state.watermark.is_empty() || opts.since.is_some() {
1369 let back = opts.since.map(|d| d.as_secs()).unwrap_or(0);
1370 state.watermark = clock::rfc3339(now.saturating_sub(back));
1371 state.last_modified = None;
1372 log!("watching from {}", state.watermark);
1373 }
1374 for number in &named_prs {
1375 state.watched(*number, ItemKind::Pr);
1376 }
1377 for number in &named_issues {
1378 state.watched(*number, ItemKind::Issue);
1379 }
1380 log!(
1381 "listening to {name}{}: polling every {}s, sweeping {} every {}s. Ctrl-C to stop.",
1382 if repo_mode {
1383 String::new()
1384 } else {
1385 format!(
1386 " for {}",
1387 named
1388 .iter()
1389 .map(|n| format!("#{n}"))
1390 .collect::<Vec<_>>()
1391 .join(", ")
1392 )
1393 },
1394 cfg.listen.interval.max(FLOOR_SECS),
1395 if repo_mode {
1396 "open pull requests"
1397 } else {
1398 "the named items"
1399 },
1400 cfg.listen.sweep
1401 );
1402
1403 let started = now;
1404 let mut next_poll = now;
1405 let mut next_sweep = now;
1406 let mut backoff = Backoff::default();
1407 let mut events_seen = 0usize;
1408 let mut dispatches = 0usize;
1409 let mut tick_failed = false;
1410 let mut last_prune = now;
1411
1412 let ending = loop {
1413 if proc::interrupted() {
1414 break Ending::Interrupted;
1415 }
1416 let now = clock::now_secs();
1417 let mut events: Vec<Event> = Vec::new();
1418
1419 if now >= next_poll {
1420 match poll(repo, &cfg.listen, &mut state, now) {
1421 Polled::Nothing(interval) => {
1422 backoff.recovered();
1423 next_poll = now + interval;
1424 }
1425 Polled::Threads(threads, interval) => {
1426 backoff.recovered();
1427 events.extend(events_from_threads(&threads, &mut state));
1428 next_poll = now + interval;
1429 }
1430 Polled::RateLimited(wait) => {
1431 logwarn!(
1432 "rate limited by GitHub, waiting {} until it resets",
1433 spend::clock(Duration::from_secs(wait))
1434 );
1435 next_poll = now + wait;
1436 tick_failed = true;
1437 }
1438 Polled::Failed(why) => {
1439 next_poll = now + backoff.failed("read notifications", &why);
1440 tick_failed = true;
1441 }
1442 }
1443 }
1444
1445 if now >= next_sweep {
1446 match sweep_rows(repo, cfg, repo_mode, &named_prs, &named_issues, &state) {
1447 Ok(Swept {
1448 rows,
1449 closed_issues,
1450 }) => {
1451 backoff.recovered();
1452 if !repo_mode {
1453 watching.extend(rows.iter().map(|r| r.number));
1454 }
1455 events.extend(closed_issues.into_iter().map(|number| Event {
1456 number,
1457 kind: ItemKind::Issue,
1458 trigger: Trigger::Closed,
1459 who: None,
1460 head: None,
1461 commits: None,
1462 from_thread: false,
1463 }));
1464 let record_head = |n: i64| {
1465 repo.read_review_record(n)
1466 .filter(|r| r.posted)
1467 .map(|r| r.head)
1468 };
1469 let swept = events_from_sweep(
1470 &rows,
1471 &viewer,
1472 &mut state,
1473 &named,
1474 cfg.loop_cfg.min_number,
1475 &record_head,
1476 repo_mode,
1477 );
1478 let (kept, held) = cap_first_sweep(swept, opts.limit, !state.swept_once);
1479 if held > 0 {
1480 log!(
1481 "{held} more reviewed pull request{} moved since their review; raise \
1482 --limit or run `spar review` on them",
1483 plural(held)
1484 );
1485 }
1486 events.extend(kept);
1487 state.swept_once = true;
1488 next_sweep = now + cfg.listen.sweep;
1489 }
1490 Err(e) => {
1491 next_sweep = now + backoff.failed("sweep the pull requests", e.last_line());
1492 tick_failed = true;
1493 }
1494 }
1495 }
1496
1497 for event in &events {
1501 if !repo_mode && !watching.contains(&event.number) {
1502 continue;
1503 }
1504 if event.trigger != Trigger::Closed && event.who.is_none() {
1505 }
1509 events_seen += 1;
1510 log!("{}", describe(repo, event));
1511 if event.trigger == Trigger::Closed {
1512 state.items.remove(&event.number);
1513 state.pending.remove(&event.number);
1514 continue;
1515 }
1516 state.note(event, now);
1517 }
1518 save(&state);
1519
1520 let due_now: Vec<i64> = state
1521 .pending
1522 .iter()
1523 .filter(|(n, p)| {
1524 (repo_mode || watching.contains(n))
1525 && due(p, now, &cfg.listen, opts.once)
1526 && state.items.get(n).is_none_or(|w| w.retry_after <= now)
1527 })
1528 .map(|(n, _)| *n)
1529 .collect();
1530 for number in due_now {
1531 let Some(mut p) = state.pending.get(&number).cloned() else {
1532 continue;
1533 };
1534 let named_item = named.contains(&number);
1535 let f = match facts(repo, cfg, number, p.kind, named_item) {
1536 Ok(f) => f,
1537 Err(e) => {
1538 let w = state.watched(number, p.kind);
1539 w.failures += 1;
1540 w.retry_after = now + dispatch_backoff_secs(w.failures);
1541 logwarn!("#{number}: could not read it: {}", e.last_line());
1542 tick_failed = true;
1543 save(&state);
1544 continue;
1545 }
1546 };
1547 if settle(&mut p, &f) {
1548 log!("#{number}: no review is asked of you now, so that was only an update");
1549 }
1550 if p.triggers.contains(&Trigger::ReviewRequested) {
1551 state.watched(number, p.kind).requested_noticed = true;
1555 }
1556 let action = plan(&p, &f, &cfg.listen);
1557 match &action {
1558 Action::Ignore(why) => {
1559 logdim!("#{number}: ignoring {why}");
1560 state.pending.remove(&number);
1561 }
1562 Action::Drop => {
1563 log!("#{number}: closed, no longer watched");
1564 state.items.remove(&number);
1565 state.pending.remove(&number);
1566 }
1567 Action::Capped(pr) => {
1568 let w = state.watched(number, p.kind);
1569 if !w.said_capped {
1570 let what = if *pr == number {
1571 "rechecked".to_string()
1572 } else {
1573 format!("its pull request #{pr} was rechecked")
1574 };
1575 log!(
1576 "#{number}: {what} {} time{} already; run `spar review {pr}` to look \
1577 again",
1578 cfg.listen.max_rechecks,
1579 plural(cfg.listen.max_rechecks as usize)
1580 );
1581 w.said_capped = true;
1582 }
1583 state.pending.remove(&number);
1584 }
1585 Action::BelongsToResume => {
1586 let w = state.watched(number, p.kind);
1587 if !w.said_resume {
1588 log!("#{number}: in custody, `spar resume {number}` continues it");
1589 w.said_resume = true;
1590 }
1591 state.pending.remove(&number);
1592 }
1593 action if action.runs() => {
1594 if opts.dry_run {
1595 log!(
1596 "#{number}: would {} after {}, dry run so nothing runs",
1597 action.phrase(number),
1598 p.because()
1599 );
1600 state.pending.remove(&number);
1601 } else {
1602 log!(
1603 "#{number}: about to {} after {}",
1604 action.phrase(number),
1605 p.because()
1606 );
1607 let acted_on = match action {
1608 Action::Recheck(pr) => *pr,
1609 _ => number,
1610 };
1611 let record_before = repo.read_review_record(acted_on);
1612 let failed_before = repo.write_summary().failed;
1613 let run = dispatch(agents, cfg, repo, action, number, &p);
1614 dispatches += 1;
1615 let spent = spend::drain();
1616 if let Err(e) = repo.record_spend(&spent) {
1617 logdim!("could not record what this run spent: {e}");
1618 }
1619 crate::cli::print_outcome(&run, &spent);
1620 if proc::interrupted() {
1621 log!("interrupted while working on #{number}; its worktree was kept");
1622 save(&state);
1623 break;
1624 }
1625 let record_after = repo.read_review_record(acted_on);
1631 if record_after != record_before {
1632 if let Some(record) = record_after {
1633 state.watched(acted_on, ItemKind::Pr).head = Some(record.head);
1634 }
1635 }
1636 let unposted = repo.write_summary().failed > failed_before;
1640 let w = state.watched(number, p.kind);
1641 let moved = run.status == Status::Moved && !unposted;
1645 if run.status == Status::Error || moved || unposted {
1646 w.failures += 1;
1647 let wait = retry_wait(moved, w.failures, &cfg.listen);
1648 w.retry_after = clock::now_secs() + wait;
1651 let gave_up = w.failures >= MAX_FAILURES;
1652 if gave_up {
1653 logwarn!("#{number}: giving up on it until it changes again");
1654 state.give_up(number, p.kind);
1655 } else {
1656 if moved {
1657 log!(
1658 "#{number}: its head moved while spar read it, so it runs \
1659 again on the new head in {}",
1660 spend::clock(Duration::from_secs(wait))
1661 );
1662 } else if unposted {
1663 logwarn!(
1664 "#{number}: not everything it wrote reached GitHub, so it \
1665 runs again in {}",
1666 spend::clock(Duration::from_secs(wait))
1667 );
1668 }
1669 if let Some(p) = state.pending.get_mut(&number) {
1670 p.first_seen = now;
1671 }
1672 }
1673 if !moved || gave_up {
1676 tick_failed = true;
1677 }
1678 } else {
1679 w.failures = 0;
1680 w.retry_after = 0;
1681 w.last_dispatched = Some(now);
1682 state.pending.remove(&number);
1683 }
1684 }
1685 }
1686 _ => {}
1687 }
1688 save(&state);
1689 }
1690 if proc::interrupted() {
1691 break Ending::Interrupted;
1692 }
1693
1694 if opts.once {
1695 break Ending::Once {
1696 failed: tick_failed,
1697 };
1698 }
1699 if !repo_mode && named.iter().all(|n| !state.items.contains_key(n)) {
1700 log!("every named item is closed, nothing left to watch");
1701 break Ending::Done;
1702 }
1703 if now.saturating_sub(last_prune) >= 3600 {
1704 prune_seen(&mut state, now);
1705 last_prune = now;
1706 save(&state);
1707 }
1708
1709 let acts_on = |n: i64| repo_mode || watching.contains(&n);
1710 let next = [next_poll, next_sweep]
1711 .into_iter()
1712 .chain(next_due(&state, &cfg.listen, &acts_on))
1713 .min()
1714 .unwrap_or(now + FLOOR_SECS);
1715 let wait = next.saturating_sub(clock::now_secs()).clamp(1, 3600);
1716 if proc::sleep_unless_interrupted(Duration::from_secs(wait)) {
1717 break Ending::Interrupted;
1718 }
1719 };
1720
1721 save(&state);
1722 drop(lock);
1723 log!(
1724 "listened for {}: {events_seen} event{}, {dispatches} dispatch{}",
1725 spend::clock(Duration::from_secs(
1726 clock::now_secs().saturating_sub(started)
1727 )),
1728 plural(events_seen),
1729 if dispatches == 1 { "" } else { "es" }
1730 );
1731 let writes = crate::cli::report_writes(repo);
1732 Ok(match ending {
1733 Ending::Interrupted => 130.max(writes),
1734 Ending::Once { failed: true } => 1.max(writes),
1735 Ending::Once { failed: false } | Ending::Done => writes,
1736 })
1737}
1738
1739fn read_state(repo: &Repo) -> ListenState {
1740 let path = repo.listen_state_path();
1741 match std::fs::read_to_string(&path) {
1742 Ok(text) => match serde_json::from_str::<ListenState>(&text) {
1743 Ok(state) => state,
1744 Err(e) => {
1745 logdim!("could not read {}: {e}. Starting fresh.", path.display());
1746 ListenState::default()
1747 }
1748 },
1749 Err(_) => ListenState::default(),
1750 }
1751}
1752
1753fn write_state(repo: &Repo, state: &ListenState) {
1754 let mut state = state.clone();
1755 state.version = STATE_VERSION;
1756 if let Err(e) = crate::repo::write_json_atomic(&repo.listen_state_path(), &state) {
1757 logdim!("could not write the listen state: {e}");
1758 }
1759}
1760
1761#[cfg(test)]
1762mod tests {
1763 use super::*;
1764 use crate::model::Author;
1765
1766 fn cfg() -> ListenCfg {
1767 ListenCfg::default()
1768 }
1769
1770 fn user(login: &str) -> Login {
1771 Login {
1772 login: login.into(),
1773 ..Login::default()
1774 }
1775 }
1776
1777 fn team(slug: &str) -> Login {
1778 Login {
1779 slug: slug.into(),
1780 ..Login::default()
1781 }
1782 }
1783
1784 fn thread(id: &str, reason: &str, kind: &str, number: i64, at: &str) -> Thread {
1785 Thread {
1786 id: id.into(),
1787 reason: reason.into(),
1788 updated_at: at.into(),
1789 subject: Subject {
1790 title: format!("thing {number}"),
1791 url: Some(format!(
1792 "https://api.github.com/repos/o/r/{}/{number}",
1793 if kind == "PullRequest" {
1794 "pulls"
1795 } else {
1796 "issues"
1797 }
1798 )),
1799 latest_comment_url: None,
1800 kind: kind.into(),
1801 },
1802 }
1803 }
1804
1805 fn row(number: i64, head: &str, author: &str, state: &str) -> PrSweepRow {
1806 PrSweepRow {
1807 number,
1808 title: format!("PR {number}"),
1809 head_ref_oid: head.into(),
1810 author: Some(Author {
1811 login: author.into(),
1812 }),
1813 state: state.into(),
1814 ..Default::default()
1815 }
1816 }
1817
1818 fn pending(kind: ItemKind, triggers: &[Trigger], now: u64) -> Pending {
1819 Pending {
1820 kind,
1821 first_seen: now,
1822 last_seen: now,
1823 triggers: triggers.iter().copied().collect(),
1824 who: Vec::new(),
1825 head_from: None,
1826 head_to: None,
1827 }
1828 }
1829
1830 fn open_pr() -> Facts {
1831 Facts {
1832 number: 7,
1833 open: true,
1834 ..Default::default()
1835 }
1836 }
1837
1838 fn response(status: u16, headers: &[(&str, &str)]) -> ApiResponse {
1839 ApiResponse {
1840 status,
1841 headers: headers
1842 .iter()
1843 .map(|(k, v)| (k.to_string(), v.to_string()))
1844 .collect(),
1845 body: String::new(),
1846 }
1847 }
1848
1849 #[test]
1852 fn the_poll_interval_is_the_largest_of_the_config_the_header_and_the_floor() {
1853 let mut c = cfg();
1854 assert_eq!(60, poll_interval(&c, None));
1855 assert_eq!(90, poll_interval(&c, Some("90")));
1856 assert_eq!(60, poll_interval(&c, Some("30")));
1857 assert_eq!(60, poll_interval(&c, Some("soon")));
1858 c.interval = 5;
1859 assert_eq!(15, poll_interval(&c, None));
1860 }
1861
1862 #[test]
1863 fn a_rate_limit_answer_says_how_long_to_wait_and_caps_it_at_an_hour() {
1864 let now = 1_000_000;
1865 assert_eq!(
1866 Some(120),
1867 rate_limit_wait(&response(429, &[("retry-after", "120")]), now)
1868 );
1869 assert_eq!(
1870 Some(600),
1871 rate_limit_wait(
1872 &response(
1873 403,
1874 &[
1875 ("x-ratelimit-remaining", "0"),
1876 ("x-ratelimit-reset", "1000600")
1877 ]
1878 ),
1879 now
1880 )
1881 );
1882 assert_eq!(
1883 Some(3600),
1884 rate_limit_wait(&response(403, &[("retry-after", "99999")]), now)
1885 );
1886 assert_eq!(
1887 Some(60),
1888 rate_limit_wait(&response(429, &[("retry-after", "1")]), now)
1889 );
1890 }
1891
1892 #[test]
1893 fn a_forbidden_answer_without_a_rate_limit_header_is_a_failure_not_a_wait() {
1894 assert_eq!(None, rate_limit_wait(&response(403, &[]), 0));
1895 assert_eq!(
1896 None,
1897 rate_limit_wait(&response(403, &[("x-ratelimit-remaining", "40")]), 0)
1898 );
1899 assert_eq!(
1900 None,
1901 rate_limit_wait(&response(200, &[("retry-after", "5")]), 0)
1902 );
1903 }
1904
1905 #[test]
1906 fn next_link_reads_the_next_page_and_nothing_else() {
1907 let mut headers = BTreeMap::new();
1908 headers.insert(
1909 "link".to_string(),
1910 "<https://api.github.com/x?page=2>; rel=\"next\", <https://api.github.com/x?page=9>; rel=\"last\"".to_string(),
1911 );
1912 assert_eq!(
1913 Some("https://api.github.com/x?page=2".to_string()),
1914 next_link(&headers)
1915 );
1916 headers.insert(
1917 "link".to_string(),
1918 "<https://api.github.com/x?page=1>; rel=\"prev\"".to_string(),
1919 );
1920 assert_eq!(None, next_link(&headers));
1921 assert_eq!(None, next_link(&BTreeMap::new()));
1922 }
1923
1924 #[test]
1927 fn a_thread_becomes_an_event_keyed_by_its_number_and_reason() {
1928 let mut state = ListenState::default();
1929 let threads = vec![
1930 thread(
1931 "1",
1932 "review_requested",
1933 "PullRequest",
1934 7,
1935 "2026-10-20T10:00:00Z",
1936 ),
1937 thread("2", "mention", "Issue", 42, "2026-10-20T10:01:00Z"),
1938 thread("3", "comment", "PullRequest", 8, "2026-10-20T10:02:00Z"),
1939 thread("4", "assign", "PullRequest", 9, "2026-10-20T10:03:00Z"),
1940 thread("5", "team_mention", "Issue", 43, "2026-10-20T10:04:00Z"),
1941 thread(
1942 "6",
1943 "state_change",
1944 "PullRequest",
1945 10,
1946 "2026-10-20T10:05:00Z",
1947 ),
1948 ];
1949 let events = events_from_threads(&threads, &mut state);
1950 let got: Vec<(i64, ItemKind, Trigger)> = events
1951 .iter()
1952 .map(|e| (e.number, e.kind, e.trigger))
1953 .collect();
1954 assert_eq!(
1955 vec![
1956 (7, ItemKind::Pr, Trigger::ReviewRequested),
1957 (42, ItemKind::Issue, Trigger::Mentioned),
1958 (8, ItemKind::Pr, Trigger::Replied),
1959 (9, ItemKind::Pr, Trigger::Assigned),
1960 (43, ItemKind::Issue, Trigger::Mentioned),
1961 (10, ItemKind::Pr, Trigger::Replied),
1962 ],
1963 got
1964 );
1965 }
1966
1967 #[test]
1968 fn a_thread_seen_at_the_same_update_time_is_not_an_event_twice() {
1969 let mut state = ListenState::default();
1970 let same = vec![thread(
1971 "1",
1972 "comment",
1973 "PullRequest",
1974 7,
1975 "2026-10-20T10:00:00Z",
1976 )];
1977 assert_eq!(1, events_from_threads(&same, &mut state).len());
1978 assert_eq!(0, events_from_threads(&same, &mut state).len());
1979 let moved = vec![thread(
1980 "1",
1981 "comment",
1982 "PullRequest",
1983 7,
1984 "2026-10-20T10:05:00Z",
1985 )];
1986 assert_eq!(1, events_from_threads(&moved, &mut state).len());
1987 }
1988
1989 #[test]
1990 fn a_thread_about_a_release_or_discussion_is_ignored() {
1991 let mut state = ListenState::default();
1992 let threads = vec![
1993 thread("1", "subscribed", "Release", 1, "2026-10-20T10:00:00Z"),
1994 thread("2", "mention", "Discussion", 2, "2026-10-20T10:01:00Z"),
1995 Thread {
1996 subject: Subject {
1997 url: None,
1998 kind: "PullRequest".into(),
1999 ..Default::default()
2000 },
2001 ..thread("3", "comment", "PullRequest", 3, "2026-10-20T10:02:00Z")
2002 },
2003 ];
2004 assert!(events_from_threads(&threads, &mut state).is_empty());
2005 assert_eq!(3, state.seen.len());
2008 }
2009
2010 #[test]
2011 fn the_watermark_advances_to_the_newest_update_seen() {
2012 let mut state = ListenState {
2013 watermark: "2026-10-20T09:00:00Z".into(),
2014 ..Default::default()
2015 };
2016 let threads = vec![
2017 thread("1", "comment", "PullRequest", 7, "2026-10-20T10:02:00Z"),
2018 thread("2", "comment", "PullRequest", 8, "2026-10-20T10:01:00Z"),
2019 ];
2020 events_from_threads(&threads, &mut state);
2021 assert_eq!("2026-10-20T10:02:00Z", state.watermark);
2022 }
2023
2024 fn no_record(_: i64) -> Option<String> {
2027 None
2028 }
2029
2030 #[test]
2031 fn a_sweep_notices_a_head_that_moved_and_says_it_once() {
2032 let mut state = ListenState::default();
2033 let named = BTreeSet::new();
2034 let first = events_from_sweep(
2035 &[row(7, "aaa", "alice", "OPEN")],
2036 "me",
2037 &mut state,
2038 &named,
2039 0,
2040 &no_record,
2041 true,
2042 );
2043 assert!(first.is_empty(), "{first:?}");
2044 let moved = events_from_sweep(
2045 &[row(7, "bbb", "alice", "OPEN")],
2046 "me",
2047 &mut state,
2048 &named,
2049 0,
2050 &no_record,
2051 true,
2052 );
2053 assert_eq!(1, moved.len());
2054 assert_eq!(Trigger::Pushed, moved[0].trigger);
2055 assert_eq!(Some(("aaa".to_string(), "bbb".to_string())), moved[0].head);
2056 let again = events_from_sweep(
2057 &[row(7, "bbb", "alice", "OPEN")],
2058 "me",
2059 &mut state,
2060 &named,
2061 0,
2062 &no_record,
2063 true,
2064 );
2065 assert!(again.is_empty(), "{again:?}");
2066 assert_eq!("alice", state.items[&7].author);
2067 }
2068
2069 #[test]
2070 fn the_first_sweep_treats_a_reviewed_pull_request_that_already_moved_as_due() {
2071 let mut state = ListenState::default();
2072 let record = |n: i64| (n == 7).then(|| "aaa".to_string());
2073 let events = events_from_sweep(
2074 &[row(7, "bbb", "alice", "OPEN"), row(8, "ccc", "bob", "OPEN")],
2075 "me",
2076 &mut state,
2077 &BTreeSet::new(),
2078 0,
2079 &record,
2080 true,
2081 );
2082 assert_eq!(1, events.len());
2083 assert_eq!(7, events[0].number);
2084 assert_eq!(Trigger::Pushed, events[0].trigger);
2085 }
2086
2087 #[test]
2088 fn a_sweep_notices_a_review_request_once_not_every_five_minutes() {
2089 let mut state = ListenState::default();
2090 let mut asked = row(7, "aaa", "alice", "OPEN");
2091 asked.review_requests = vec![user("ME")];
2092 asked.assignees = vec![user("me")];
2093 let rows = [asked];
2094 let first = events_from_sweep(
2095 &rows,
2096 "me",
2097 &mut state,
2098 &BTreeSet::new(),
2099 0,
2100 &no_record,
2101 true,
2102 );
2103 let triggers: BTreeSet<Trigger> = first.iter().map(|e| e.trigger).collect();
2104 assert_eq!(
2105 [Trigger::ReviewRequested, Trigger::Assigned]
2106 .into_iter()
2107 .collect::<BTreeSet<_>>(),
2108 triggers
2109 );
2110 let second = events_from_sweep(
2111 &rows,
2112 "me",
2113 &mut state,
2114 &BTreeSet::new(),
2115 0,
2116 &no_record,
2117 true,
2118 );
2119 assert!(second.is_empty(), "{second:?}");
2120 }
2121
2122 #[test]
2123 fn a_sweep_notices_a_review_requested_again_after_a_review() {
2124 let mut state = ListenState::default();
2125 let mut asked = row(7, "aaa", "alice", "OPEN");
2126 asked.review_requests = vec![user("me")];
2127 let asked = [asked];
2128 let reviewed = [row(7, "aaa", "alice", "OPEN")];
2129 let mut sweep = |rows: &[PrSweepRow]| -> Vec<Trigger> {
2130 events_from_sweep(
2131 rows,
2132 "me",
2133 &mut state,
2134 &BTreeSet::new(),
2135 0,
2136 &no_record,
2137 true,
2138 )
2139 .iter()
2140 .map(|e| e.trigger)
2141 .collect()
2142 };
2143 assert_eq!(vec![Trigger::ReviewRequested], sweep(&asked));
2144 assert!(sweep(&reviewed).is_empty());
2146 assert!(sweep(&reviewed).is_empty());
2147 assert_eq!(vec![Trigger::ReviewRequested], sweep(&asked));
2149 assert!(sweep(&asked).is_empty());
2150 }
2151
2152 #[test]
2153 fn a_sweep_drops_a_pull_request_that_closed_or_vanished() {
2154 let mut state = ListenState::default();
2155 let named = BTreeSet::new();
2156 events_from_sweep(
2157 &[row(7, "aaa", "alice", "OPEN"), row(8, "bbb", "bob", "OPEN")],
2158 "me",
2159 &mut state,
2160 &named,
2161 0,
2162 &no_record,
2163 true,
2164 );
2165 let events = events_from_sweep(
2166 &[row(7, "aaa", "alice", "MERGED")],
2167 "me",
2168 &mut state,
2169 &named,
2170 0,
2171 &no_record,
2172 true,
2173 );
2174 let closed: Vec<i64> = events
2175 .iter()
2176 .filter(|e| e.trigger == Trigger::Closed)
2177 .map(|e| e.number)
2178 .collect();
2179 assert_eq!(vec![7, 8], closed);
2180
2181 let mut state = ListenState::default();
2184 let named: BTreeSet<i64> = [7, 8].into_iter().collect();
2185 events_from_sweep(
2186 &[row(7, "aaa", "alice", "OPEN"), row(8, "bbb", "bob", "OPEN")],
2187 "me",
2188 &mut state,
2189 &named,
2190 0,
2191 &no_record,
2192 false,
2193 );
2194 let events = events_from_sweep(
2195 &[row(7, "aaa", "alice", "OPEN")],
2196 "me",
2197 &mut state,
2198 &named,
2199 0,
2200 &no_record,
2201 false,
2202 );
2203 assert!(events.is_empty(), "{events:?}");
2204 }
2205
2206 #[test]
2207 fn a_sweep_ignores_a_number_below_the_floor_unless_it_was_named() {
2208 let mut state = ListenState::default();
2209 let named: BTreeSet<i64> = [3].into_iter().collect();
2210 let record = |_: i64| Some("old".to_string());
2211 let events = events_from_sweep(
2212 &[
2213 row(3, "new", "alice", "OPEN"),
2214 row(4, "new", "alice", "OPEN"),
2215 row(500, "new", "alice", "OPEN"),
2216 ],
2217 "me",
2218 &mut state,
2219 &named,
2220 480,
2221 &record,
2222 true,
2223 );
2224 let numbers: Vec<i64> = events.iter().map(|e| e.number).collect();
2225 assert_eq!(vec![3, 500], numbers);
2226 assert!(!state.items.contains_key(&4));
2227 }
2228
2229 #[test]
2232 fn a_pull_request_below_the_floor_that_is_still_open_is_not_called_closed() {
2233 let mut state = ListenState::default();
2234 state.watched(4, ItemKind::Pr);
2235 let events = events_from_sweep(
2236 &[
2237 row(4, "new", "alice", "OPEN"),
2238 row(500, "new", "alice", "OPEN"),
2239 ],
2240 "me",
2241 &mut state,
2242 &BTreeSet::new(),
2243 480,
2244 &no_record,
2245 true,
2246 );
2247 assert!(
2248 events.iter().all(|e| e.trigger != Trigger::Closed),
2249 "{events:?}"
2250 );
2251 let gone = events_from_sweep(
2252 &[row(500, "new", "alice", "OPEN")],
2253 "me",
2254 &mut state,
2255 &BTreeSet::new(),
2256 480,
2257 &no_record,
2258 true,
2259 );
2260 assert_eq!(
2261 vec![(4, Trigger::Closed)],
2262 gone.iter()
2263 .map(|e| (e.number, e.trigger))
2264 .collect::<Vec<_>>()
2265 );
2266 }
2267
2268 #[test]
2269 fn the_first_sweep_caps_pending_rechecks_at_the_limit_and_counts_the_rest() {
2270 let push = |n: i64| Event {
2271 number: n,
2272 kind: ItemKind::Pr,
2273 trigger: Trigger::Pushed,
2274 who: None,
2275 head: None,
2276 commits: None,
2277 from_thread: false,
2278 };
2279 let asked = Event {
2280 trigger: Trigger::ReviewRequested,
2281 ..push(50)
2282 };
2283 let events = vec![push(9), push(3), push(7), asked.clone()];
2284 let (kept, held) = cap_first_sweep(events.clone(), 2, true);
2285 let numbers: Vec<i64> = kept.iter().map(|e| e.number).collect();
2286 assert_eq!(vec![3, 7, 50], numbers, "{kept:?}");
2287 assert_eq!(1, held);
2288 let (kept, held) = cap_first_sweep(events, 2, false);
2289 assert_eq!(4, kept.len());
2290 assert_eq!(0, held);
2291 }
2292
2293 #[test]
2296 fn a_review_request_nobody_is_still_making_is_only_an_update() {
2297 let threads = [thread(
2300 "1",
2301 "review_requested",
2302 "PullRequest",
2303 7,
2304 "2026-10-21T14:51:16Z",
2305 )];
2306 let events = events_from_threads(&threads, &mut ListenState::default());
2307 let mut waiting = BTreeMap::new();
2308 for e in &events {
2309 merge(&mut waiting, e, 0);
2310 }
2311 let reviewed = Facts {
2312 has_posted_record: true,
2313 ..open_pr()
2314 };
2315 let mut p = waiting.remove(&7).expect("pending");
2316 assert!(settle(&mut p, &reviewed));
2317 assert_eq!(
2318 [Trigger::Replied].into_iter().collect::<BTreeSet<_>>(),
2319 p.triggers
2320 );
2321 assert_eq!(Action::Recheck(7), plan(&p, &reviewed, &cfg()));
2322
2323 let mut p = pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0);
2325 assert!(settle(&mut p, &open_pr()));
2326 assert!(matches!(plan(&p, &open_pr(), &cfg()), Action::Ignore(_)));
2327
2328 let asked = Facts {
2330 review_pending: true,
2331 ..open_pr()
2332 };
2333 let mut p = pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0);
2334 assert!(!settle(&mut p, &asked));
2335 assert!(p.triggers.contains(&Trigger::ReviewRequested));
2336 assert_eq!(Action::Review, plan(&p, &asked, &cfg()));
2337
2338 let mut p = pending(ItemKind::Pr, &[Trigger::Pushed], 0);
2340 assert!(!settle(&mut p, &open_pr()));
2341 assert_eq!(
2342 [Trigger::Pushed].into_iter().collect::<BTreeSet<_>>(),
2343 p.triggers
2344 );
2345 }
2346
2347 #[test]
2348 fn a_review_request_counts_for_the_viewer_or_a_team_the_viewer_is_in() {
2349 let teams = |team: &str| match team {
2350 "example/ours" => Some(true),
2351 "example/theirs" => Some(false),
2352 _ => None,
2353 };
2354 let pending = |requests: &[Login]| review_pending(requests, "me", &teams);
2355 assert!(!pending(&[]));
2356 assert!(pending(&[user("ME")]));
2357 assert!(!pending(&[user("alice")]));
2358 assert!(pending(&[team("example/ours")]));
2359 assert!(!pending(&[team("example/theirs"), user("alice")]));
2362 assert!(pending(&[team("example/theirs"), team("example/ours")]));
2363 assert!(pending(&[team("example/unknown")]));
2365 let never = |_: &str| -> Option<bool> { panic!("looked a team up") };
2367 assert!(review_pending(
2368 &[team("example/ours"), user("me")],
2369 "me",
2370 &never
2371 ));
2372 }
2373
2374 #[test]
2375 fn a_push_or_a_reply_on_a_reviewed_pull_request_is_a_recheck() {
2376 let f = Facts {
2377 has_posted_record: true,
2378 ..open_pr()
2379 };
2380 for t in [
2381 Trigger::Pushed,
2382 Trigger::Replied,
2383 Trigger::Mentioned,
2384 Trigger::ReviewRequested,
2385 ] {
2386 assert_eq!(
2387 Action::Recheck(7),
2388 plan(&pending(ItemKind::Pr, &[t], 0), &f, &cfg()),
2389 "{t:?}"
2390 );
2391 }
2392 }
2393
2394 #[test]
2395 fn a_review_request_or_an_assignment_on_an_unreviewed_pull_request_follows_the_config() {
2396 let f = open_pr();
2397 let mut c = cfg();
2398 assert_eq!(
2399 Action::Review,
2400 plan(
2401 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2402 &f,
2403 &c
2404 )
2405 );
2406 assert_eq!(
2407 Action::Review,
2408 plan(&pending(ItemKind::Pr, &[Trigger::Assigned], 0), &f, &c)
2409 );
2410 assert_eq!(
2411 Action::Review,
2412 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2413 );
2414 c.on_review_requested = ReviewOrIgnore::Ignore;
2415 c.on_assigned = ReviewOrIgnore::Ignore;
2416 assert!(matches!(
2417 plan(
2418 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2419 &f,
2420 &c
2421 ),
2422 Action::Ignore(_)
2423 ));
2424 assert!(matches!(
2425 plan(&pending(ItemKind::Pr, &[Trigger::Assigned], 0), &f, &c),
2426 Action::Ignore(_)
2427 ));
2428 assert_eq!(
2430 Action::Review,
2431 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2432 );
2433 }
2434
2435 #[test]
2436 fn a_push_to_an_unreviewed_pull_request_is_ignored_unless_it_was_named() {
2437 let f = open_pr();
2438 assert!(matches!(
2439 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &f, &cfg()),
2440 Action::Ignore(_)
2441 ));
2442 assert!(matches!(
2443 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &cfg()),
2444 Action::Ignore(_)
2445 ));
2446 let named = Facts {
2447 named: true,
2448 ..open_pr()
2449 };
2450 assert_eq!(
2451 Action::Review,
2452 plan(
2453 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2454 &named,
2455 &cfg()
2456 )
2457 );
2458 assert_eq!(
2459 Action::Review,
2460 plan(
2461 &pending(ItemKind::Pr, &[Trigger::Replied], 0),
2462 &named,
2463 &cfg()
2464 )
2465 );
2466 }
2467
2468 #[test]
2469 fn your_own_pull_request_follows_on_own_pr_and_a_push_to_it_is_ignored() {
2470 let f = Facts {
2471 author_is_viewer: true,
2472 has_posted_record: true,
2473 ..open_pr()
2474 };
2475 let mut c = cfg();
2476 assert_eq!(
2477 Action::Checkin { reply_only: false },
2478 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &c)
2479 );
2480 assert_eq!(
2481 Action::Checkin { reply_only: false },
2482 plan(
2483 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2484 &f,
2485 &c
2486 )
2487 );
2488 assert!(matches!(
2489 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &f, &c),
2490 Action::Ignore(_)
2491 ));
2492 c.on_own_pr = OwnPr::Reply;
2493 assert_eq!(
2494 Action::Checkin { reply_only: true },
2495 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2496 );
2497 c.on_own_pr = OwnPr::Ignore;
2498 assert!(matches!(
2499 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &c),
2500 Action::Ignore(_)
2501 ));
2502 }
2503
2504 #[test]
2505 fn a_pull_request_in_custody_belongs_to_resume_and_a_capped_one_waits_for_a_person() {
2506 let custody = Facts {
2507 has_custody_state: true,
2508 ..open_pr()
2509 };
2510 assert_eq!(
2511 Action::BelongsToResume,
2512 plan(
2513 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2514 &custody,
2515 &cfg()
2516 )
2517 );
2518 let capped = Facts {
2519 has_posted_record: true,
2520 recheck_capped: true,
2521 ..open_pr()
2522 };
2523 assert_eq!(
2524 Action::Capped(7),
2525 plan(
2526 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2527 &capped,
2528 &cfg()
2529 )
2530 );
2531 }
2532
2533 #[test]
2537 fn an_issue_mention_keeps_to_its_pull_requests_cap_and_owner() {
2538 let linked = Facts {
2539 number: 42,
2540 open: true,
2541 linked_pr_with_record: Some(108),
2542 ..Default::default()
2543 };
2544 let capped = Facts {
2545 recheck_capped: true,
2546 ..linked.clone()
2547 };
2548 assert_eq!(
2549 Action::Capped(108),
2550 plan(
2551 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2552 &capped,
2553 &cfg()
2554 )
2555 );
2556 let own = Facts {
2557 author_is_viewer: true,
2558 ..linked
2559 };
2560 assert!(matches!(
2561 plan(
2562 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2563 &own,
2564 &cfg()
2565 ),
2566 Action::Ignore(_)
2567 ));
2568 }
2569
2570 #[test]
2571 fn an_issue_mention_without_a_pull_request_is_ignored_and_with_one_rechecks_it() {
2572 let issue = Facts {
2573 number: 42,
2574 open: true,
2575 ..Default::default()
2576 };
2577 assert!(matches!(
2578 plan(
2579 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2580 &issue,
2581 &cfg()
2582 ),
2583 Action::Ignore(_)
2584 ));
2585 assert!(matches!(
2586 plan(
2587 &pending(ItemKind::Issue, &[Trigger::Replied], 0),
2588 &issue,
2589 &cfg()
2590 ),
2591 Action::Ignore(_)
2592 ));
2593 let linked = Facts {
2594 linked_pr_with_record: Some(108),
2595 ..issue.clone()
2596 };
2597 assert_eq!(
2598 Action::Recheck(108),
2599 plan(
2600 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2601 &linked,
2602 &cfg()
2603 )
2604 );
2605 assert_eq!(
2606 Action::Recheck(108),
2607 plan(
2608 &pending(ItemKind::Issue, &[Trigger::Assigned], 0),
2609 &linked,
2610 &cfg()
2611 )
2612 );
2613 let named = Facts {
2615 named: true,
2616 ..linked
2617 };
2618 assert_eq!(
2619 Action::Recheck(108),
2620 plan(
2621 &pending(ItemKind::Issue, &[Trigger::Replied], 0),
2622 &named,
2623 &cfg()
2624 )
2625 );
2626 }
2627
2628 #[test]
2629 fn a_closed_item_is_dropped_and_one_below_the_floor_is_ignored_unless_named() {
2630 let closed = Facts {
2631 open: false,
2632 has_posted_record: true,
2633 ..open_pr()
2634 };
2635 assert_eq!(
2636 Action::Drop,
2637 plan(
2638 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2639 &closed,
2640 &cfg()
2641 )
2642 );
2643 let f = Facts {
2644 has_posted_record: true,
2645 ..open_pr()
2646 };
2647 assert_eq!(
2648 Action::Drop,
2649 plan(
2650 &pending(ItemKind::Pr, &[Trigger::Pushed, Trigger::Closed], 0),
2651 &f,
2652 &cfg()
2653 )
2654 );
2655 let low = Facts {
2656 below_floor: true,
2657 has_posted_record: true,
2658 ..open_pr()
2659 };
2660 assert!(matches!(
2661 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &low, &cfg()),
2662 Action::Ignore(_)
2663 ));
2664 let named_low = Facts { named: true, ..low };
2665 assert_eq!(
2666 Action::Recheck(7),
2667 plan(
2668 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2669 &named_low,
2670 &cfg()
2671 )
2672 );
2673 }
2674
2675 #[test]
2678 fn an_item_is_due_once_quiet_for_the_settle_time_or_at_max_wait_anyway() {
2679 let c = cfg();
2680 let mut p = pending(ItemKind::Pr, &[Trigger::Pushed], 1000);
2681 assert!(!due(&p, 1000, &c, false));
2682 assert!(!due(&p, 1000 + c.settle - 1, &c, false));
2683 assert!(due(&p, 1000 + c.settle, &c, false));
2684 p.last_seen = 1000 + c.max_wait - 30;
2686 assert!(!due(&p, 1000 + c.max_wait - 1, &c, false));
2687 assert!(due(&p, 1000 + c.max_wait, &c, false));
2688 assert!(due(&p, 1000, &c, true), "--once waits for nothing");
2689 }
2690
2691 #[test]
2692 fn events_merge_into_one_pending_record_per_item_and_the_next_due_is_the_earliest() {
2693 let c = cfg();
2694 let mut pending = BTreeMap::new();
2695 let push = Event {
2696 number: 7,
2697 kind: ItemKind::Pr,
2698 trigger: Trigger::Pushed,
2699 who: None,
2700 head: Some(("aaa".into(), "bbb".into())),
2701 commits: None,
2702 from_thread: false,
2703 };
2704 merge(&mut pending, &push, 1000);
2705 let reply = Event {
2706 trigger: Trigger::Replied,
2707 who: Some("alice".into()),
2708 head: Some(("bbb".into(), "ccc".into())),
2709 ..push.clone()
2710 };
2711 merge(&mut pending, &reply, 1060);
2712 let other = Event {
2713 number: 8,
2714 ..push.clone()
2715 };
2716 merge(&mut pending, &other, 1100);
2717 assert_eq!(2, pending.len());
2718 let p = &pending[&7];
2719 assert_eq!(1000, p.first_seen);
2720 assert_eq!(1060, p.last_seen);
2721 assert_eq!(Some("aaa".to_string()), p.head_from);
2722 assert_eq!(Some("ccc".to_string()), p.head_to);
2723 assert_eq!(vec!["alice".to_string()], p.who);
2724 assert_eq!("a push and a reply", p.because());
2725 let mut state = ListenState {
2726 pending,
2727 ..Default::default()
2728 };
2729 let all = |_: i64| true;
2730 assert_eq!(Some(1060 + c.settle), next_due(&state, &c, &all));
2731 assert_eq!(None, next_due(&ListenState::default(), &c, &all));
2732
2733 state.watched(7, ItemKind::Pr).retry_after = 5000;
2736 assert_eq!(Some(1100 + c.settle), next_due(&state, &c, &all));
2737 state.watched(8, ItemKind::Pr).retry_after = 6000;
2738 assert_eq!(Some(5000), next_due(&state, &c, &all));
2739 assert_eq!(Some(6000), next_due(&state, &c, &|n| n == 8));
2741 }
2742
2743 #[test]
2746 fn since_accepts_minutes_hours_days_and_combinations() {
2747 assert_eq!(Duration::from_secs(1800), parse_duration("30m").unwrap());
2748 assert_eq!(Duration::from_secs(7200), parse_duration("2h").unwrap());
2749 assert_eq!(Duration::from_secs(86_400), parse_duration("1d").unwrap());
2750 assert_eq!(
2751 Duration::from_secs(5400),
2752 parse_duration(" 1h30m ").unwrap()
2753 );
2754 assert_eq!(Duration::from_secs(45), parse_duration("45s").unwrap());
2755 }
2756
2757 #[test]
2758 fn since_rejects_a_bare_number_and_says_what_it_takes() {
2759 for bad in ["", "30", "2 hours", "h", "1w", "1h30"] {
2760 let err = parse_duration(bad).unwrap_err().to_string();
2761 assert!(err.contains("like 30m, 2h or 1d"), "{bad:?}: {err}");
2762 }
2763 }
2764
2765 #[test]
2766 fn listen_state_round_trips_through_json_and_old_seen_entries_are_pruned() {
2767 let mut state = ListenState {
2768 version: STATE_VERSION,
2769 watermark: "2026-10-20T10:00:00Z".into(),
2770 last_modified: Some("Tue, 20 Oct 2026 10:00:00 GMT".into()),
2771 ..Default::default()
2772 };
2773 state
2774 .seen
2775 .insert("old".into(), "2026-10-01T10:00:00Z".into());
2776 state
2777 .seen
2778 .insert("new".into(), "2026-10-20T09:00:00Z".into());
2779 state.seen.insert("junk".into(), "yesterday".into());
2780 state.watched(7, ItemKind::Pr).head = Some("aaa".into());
2781 state
2782 .pending
2783 .insert(7, pending(ItemKind::Pr, &[Trigger::Pushed], 5));
2784 let text = serde_json::to_string_pretty(&state).unwrap();
2785 assert!(text.contains("\"pushed\""), "{text}");
2786 assert!(text.contains("\"kind\": \"pr\""), "{text}");
2787 let back: ListenState = serde_json::from_str(&text).unwrap();
2788 assert_eq!(state, back);
2789
2790 let now = clock::parse_rfc3339("2026-10-20T10:00:00Z").unwrap();
2791 prune_seen(&mut state, now);
2792 let left: Vec<&String> = state.seen.keys().collect();
2793 assert_eq!(vec!["junk", "new"], left);
2794 }
2795
2796 #[test]
2797 fn gh_failures_back_off_from_a_minute_to_fifteen_and_dispatches_from_fifteen_to_six_hours() {
2798 assert_eq!(60, backoff_secs(1));
2799 assert_eq!(120, backoff_secs(2));
2800 assert_eq!(480, backoff_secs(4));
2801 assert_eq!(900, backoff_secs(5));
2802 assert_eq!(900, backoff_secs(40));
2803 assert_eq!(900, dispatch_backoff_secs(1));
2804 assert_eq!(3600, dispatch_backoff_secs(3));
2805 assert_eq!(6 * 3600, dispatch_backoff_secs(9));
2806 }
2807
2808 #[test]
2812 fn a_head_that_moved_runs_again_once_it_settles_and_a_failure_backs_off() {
2813 let c = cfg();
2814 for failures in [1, 2, 4] {
2815 assert_eq!(c.settle, retry_wait(true, failures, &c));
2816 assert_eq!(
2817 dispatch_backoff_secs(failures),
2818 retry_wait(false, failures, &c)
2819 );
2820 }
2821 let quick = ListenCfg { settle: 1, ..cfg() };
2822 assert_eq!(FLOOR_SECS, retry_wait(true, 1, &quick));
2823 }
2824
2825 #[test]
2829 fn giving_up_holds_what_the_item_was_waiting_on_until_it_changes() {
2830 let mut state = ListenState::default();
2831 let asked = Event {
2832 number: 7,
2833 kind: ItemKind::Pr,
2834 trigger: Trigger::ReviewRequested,
2835 who: None,
2836 head: None,
2837 commits: None,
2838 from_thread: true,
2839 };
2840 state.note(&asked, 0);
2841 state.give_up(7, ItemKind::Pr);
2842 assert!(state.pending.is_empty(), "{state:?}");
2843 assert_eq!(
2844 [Trigger::ReviewRequested]
2845 .into_iter()
2846 .collect::<BTreeSet<_>>(),
2847 state.items[&7].held
2848 );
2849
2850 let shown = Event {
2851 trigger: Trigger::Pushed,
2852 head: Some(("aaa".into(), "bbb".into())),
2853 from_thread: false,
2854 ..asked.clone()
2855 };
2856 state.note(&shown, 600);
2857 let p = &state.pending[&7];
2858 assert_eq!(
2859 [Trigger::Pushed, Trigger::ReviewRequested]
2860 .into_iter()
2861 .collect::<BTreeSet<_>>(),
2862 p.triggers
2863 );
2864 assert!(state.items[&7].held.is_empty(), "{state:?}");
2865 let asked_now = Facts {
2866 review_pending: true,
2867 ..open_pr()
2868 };
2869 assert_eq!(Action::Review, plan(p, &asked_now, &cfg()));
2870 state.pending.clear();
2872 state.note(&shown, 700);
2873 assert_eq!(
2874 [Trigger::Pushed].into_iter().collect::<BTreeSet<_>>(),
2875 state.pending[&7].triggers
2876 );
2877 }
2878
2879 #[test]
2880 fn number_from_url_reads_the_last_segment() {
2881 assert_eq!(
2882 Some(108),
2883 number_from_url("https://api.github.com/repos/o/r/pulls/108")
2884 );
2885 assert_eq!(
2886 Some(42),
2887 number_from_url("https://api.github.com/repos/o/r/issues/42/")
2888 );
2889 assert_eq!(
2890 None,
2891 number_from_url("https://api.github.com/repos/o/r/pulls")
2892 );
2893 }
2894
2895 #[cfg(unix)]
2896 #[test]
2897 fn a_held_lock_refuses_a_second_listener_however_old_and_a_left_file_is_taken_over() {
2898 let dir = std::env::temp_dir().join(format!(
2899 "spar-listen-lock-{}-{}",
2900 std::process::id(),
2901 clock::now_secs()
2902 ));
2903 std::fs::create_dir_all(&dir).unwrap();
2904 let path = dir.join("listen.lock");
2905
2906 let held = Lock::at(path.clone(), 1000).expect("first lock");
2909 for now in [1000, 1000 + 86_400] {
2910 let err = Lock::at(path.clone(), now).unwrap_err().to_string();
2911 assert!(err.contains("already watching"), "{err}");
2912 assert!(err.contains(&std::process::id().to_string()), "{err}");
2913 }
2914 assert!(path.exists(), "a refused listener leaves the lock alone");
2915 drop(held);
2916 assert!(!path.exists(), "dropping the lock removes the file");
2917
2918 std::fs::write(
2921 &path,
2922 serde_json::to_string(&LockFile {
2923 pid: std::process::id(),
2924 started: "x".into(),
2925 })
2926 .unwrap(),
2927 )
2928 .unwrap();
2929 let taken = Lock::at(path.clone(), 1000).expect("a left file is taken over");
2930 drop(taken);
2931 let _ = std::fs::remove_dir_all(&dir);
2932 }
2933}