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, 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}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150pub struct Pending {
151 pub kind: ItemKind,
152 pub first_seen: u64,
153 pub last_seen: u64,
154 pub triggers: BTreeSet<Trigger>,
155 #[serde(default)]
156 pub who: Vec<String>,
157 #[serde(default)]
158 pub head_from: Option<String>,
159 #[serde(default)]
160 pub head_to: Option<String>,
161}
162
163impl Pending {
164 pub fn because(&self) -> String {
166 let mut parts: Vec<String> = self.triggers.iter().map(|t| t.noun().to_string()).collect();
167 if parts.is_empty() {
168 parts.push("nothing in particular".into());
169 }
170 match parts.len() {
171 1 => parts.remove(0),
172 2 => format!("{} and {}", parts[0], parts[1]),
173 _ => {
174 let last = parts.pop().unwrap_or_default();
175 format!("{}, and {last}", parts.join(", "))
176 }
177 }
178 }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
183pub struct Watched {
184 pub kind: ItemKind,
185 #[serde(default)]
186 pub title: String,
187 #[serde(default)]
188 pub author: String,
189 #[serde(default)]
192 pub head: Option<String>,
193 #[serde(default)]
194 pub requested_noticed: bool,
195 #[serde(default)]
196 pub assigned_noticed: bool,
197 #[serde(default)]
198 pub failures: u32,
199 #[serde(default)]
200 pub retry_after: u64,
201 #[serde(default)]
202 pub said_capped: bool,
203 #[serde(default)]
204 pub said_resume: bool,
205 #[serde(default)]
206 pub last_dispatched: Option<u64>,
207}
208
209impl Watched {
210 fn new(kind: ItemKind) -> Self {
211 Self {
212 kind,
213 title: String::new(),
214 author: String::new(),
215 head: None,
216 requested_noticed: false,
217 assigned_noticed: false,
218 failures: 0,
219 retry_after: 0,
220 said_capped: false,
221 said_resume: false,
222 last_dispatched: None,
223 }
224 }
225}
226
227#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
230pub struct ListenState {
231 pub version: u32,
232 #[serde(default)]
234 pub watermark: String,
235 #[serde(default)]
238 pub last_modified: Option<String>,
239 #[serde(default)]
242 pub seen: BTreeMap<String, String>,
243 #[serde(default)]
244 pub items: BTreeMap<i64, Watched>,
245 #[serde(default)]
246 pub pending: BTreeMap<i64, Pending>,
247 #[serde(default)]
248 pub swept_once: bool,
249}
250
251impl ListenState {
252 fn watched(&mut self, number: i64, kind: ItemKind) -> &mut Watched {
253 self.items
254 .entry(number)
255 .or_insert_with(|| Watched::new(kind))
256 }
257}
258
259pub fn parse_duration(text: &str) -> Result<Duration> {
265 let bad = || {
266 spar_err!(
267 "`--since` takes a number and a unit, like 30m, 2h or 1d, not {:?}",
268 text.trim()
269 )
270 };
271 let mut total = 0u64;
272 let mut digits = String::new();
273 let mut any = false;
274 for c in text.trim().chars() {
275 if c.is_ascii_digit() {
276 digits.push(c);
277 continue;
278 }
279 let n: u64 = digits.parse().map_err(|_| bad())?;
280 digits.clear();
281 let unit = match c {
282 's' => 1,
283 'm' => 60,
284 'h' => 3600,
285 'd' => 86_400,
286 _ => return Err(bad()),
287 };
288 total = total.saturating_add(n.saturating_mul(unit));
289 any = true;
290 }
291 if !digits.is_empty() || !any {
292 return Err(bad());
293 }
294 Ok(Duration::from_secs(total))
295}
296
297pub fn poll_interval(cfg: &ListenCfg, header: Option<&str>) -> u64 {
300 let asked = header
301 .and_then(|h| h.trim().parse::<u64>().ok())
302 .unwrap_or(0);
303 cfg.interval.max(asked).max(FLOOR_SECS)
304}
305
306pub fn rate_limit_wait(resp: &ApiResponse, now: u64) -> Option<u64> {
309 if resp.status != 403 && resp.status != 429 {
310 return None;
311 }
312 let retry_after = resp
313 .headers
314 .get("retry-after")
315 .and_then(|v| v.trim().parse::<u64>().ok());
316 let exhausted = resp
317 .headers
318 .get("x-ratelimit-remaining")
319 .is_some_and(|v| v.trim() == "0");
320 let reset = resp
321 .headers
322 .get("x-ratelimit-reset")
323 .and_then(|v| v.trim().parse::<u64>().ok())
324 .map(|at| at.saturating_sub(now));
325 let wait = match (retry_after, exhausted, reset) {
326 (Some(secs), _, _) => secs,
327 (None, true, Some(secs)) => secs,
328 _ => return None,
329 };
330 Some(wait.clamp(60, 3600))
331}
332
333fn status_message(resp: &ApiResponse) -> String {
336 serde_json::from_str::<serde_json::Value>(&resp.body)
337 .ok()
338 .and_then(|v| {
339 v.get("message")
340 .and_then(|m| m.as_str())
341 .map(|m| format!("HTTP {}: {m}", resp.status))
342 })
343 .unwrap_or_else(|| format!("HTTP {}", resp.status))
344}
345
346pub fn next_link(headers: &BTreeMap<String, String>) -> Option<String> {
348 let link = headers.get("link")?;
349 link.split(',').find_map(|part| {
350 let (url, rel) = part.split_once(';')?;
351 if !rel.contains("rel=\"next\"") {
352 return None;
353 }
354 let url = url.trim().trim_start_matches('<').trim_end_matches('>');
355 (!url.is_empty()).then(|| url.to_string())
356 })
357}
358
359pub fn number_from_url(url: &str) -> Option<i64> {
361 url.trim_end_matches('/')
362 .rsplit('/')
363 .next()
364 .and_then(|last| last.parse().ok())
365}
366
367pub fn events_from_threads(threads: &[Thread], state: &mut ListenState) -> Vec<Event> {
370 let mut out = Vec::new();
371 for thread in threads {
372 if state.seen.get(&thread.id) == Some(&thread.updated_at) {
373 continue;
374 }
375 if !thread.updated_at.is_empty() {
376 state
377 .seen
378 .insert(thread.id.clone(), thread.updated_at.clone());
379 if thread.updated_at > state.watermark {
380 state.watermark = thread.updated_at.clone();
381 }
382 }
383 let kind = match thread.subject.kind.as_str() {
384 "PullRequest" => ItemKind::Pr,
385 "Issue" => ItemKind::Issue,
386 _ => continue,
387 };
388 let Some(number) = thread.subject.url.as_deref().and_then(number_from_url) else {
389 continue;
390 };
391 let trigger = match thread.reason.as_str() {
392 "review_requested" => Trigger::ReviewRequested,
393 "assign" => Trigger::Assigned,
394 "mention" | "team_mention" => Trigger::Mentioned,
395 _ => Trigger::Replied,
399 };
400 out.push(Event {
401 number,
402 kind,
403 trigger,
404 who: None,
405 head: None,
406 commits: None,
407 });
408 }
409 out
410}
411
412pub fn events_from_sweep(
418 rows: &[PrSweepRow],
419 viewer: &str,
420 state: &mut ListenState,
421 named: &BTreeSet<i64>,
422 min_number: i64,
423 record_head: &dyn Fn(i64) -> Option<String>,
424 repo_mode: bool,
425) -> Vec<Event> {
426 let mut out = Vec::new();
427 let mut present: BTreeSet<i64> = BTreeSet::new();
428 for row in rows {
429 present.insert(row.number);
433 if row.number < min_number && !named.contains(&row.number) {
434 continue;
435 }
436 let watched = state.watched(row.number, ItemKind::Pr);
437 if !row.title.is_empty() {
438 watched.title = row.title.clone();
439 }
440 if let Some(author) = &row.author {
441 watched.author = author.login.clone();
442 }
443 if !row.state.eq_ignore_ascii_case("OPEN") {
444 out.push(Event {
445 number: row.number,
446 kind: ItemKind::Pr,
447 trigger: Trigger::Closed,
448 who: None,
449 head: None,
450 commits: None,
451 });
452 continue;
453 }
454 let from = match &watched.head {
455 Some(old) => Some(old.clone()),
456 None => record_head(row.number),
457 };
458 if let Some(old) = from {
459 if !row.head_ref_oid.is_empty() && !old.eq_ignore_ascii_case(&row.head_ref_oid) {
460 out.push(Event {
461 number: row.number,
462 kind: ItemKind::Pr,
463 trigger: Trigger::Pushed,
464 who: None,
465 head: Some((old, row.head_ref_oid.clone())),
466 commits: None,
467 });
468 }
469 }
470 if !row.head_ref_oid.is_empty() {
471 watched.head = Some(row.head_ref_oid.clone());
472 }
473 let asked = row
474 .review_requests
475 .iter()
476 .any(|l| crate::comments::same_login(&l.login, viewer));
477 if asked && !watched.requested_noticed {
478 watched.requested_noticed = true;
479 out.push(Event {
480 number: row.number,
481 kind: ItemKind::Pr,
482 trigger: Trigger::ReviewRequested,
483 who: None,
484 head: None,
485 commits: None,
486 });
487 }
488 let assigned = row
489 .assignees
490 .iter()
491 .any(|l| crate::comments::same_login(&l.login, viewer));
492 if assigned && !watched.assigned_noticed {
493 watched.assigned_noticed = true;
494 out.push(Event {
495 number: row.number,
496 kind: ItemKind::Pr,
497 trigger: Trigger::Assigned,
498 who: None,
499 head: None,
500 commits: None,
501 });
502 }
503 }
504 if repo_mode {
505 let gone: Vec<i64> = state
508 .items
509 .iter()
510 .filter(|(n, w)| w.kind == ItemKind::Pr && !present.contains(n))
511 .map(|(n, _)| *n)
512 .collect();
513 for number in gone {
514 out.push(Event {
515 number,
516 kind: ItemKind::Pr,
517 trigger: Trigger::Closed,
518 who: None,
519 head: None,
520 commits: None,
521 });
522 }
523 }
524 out
525}
526
527pub fn cap_first_sweep(events: Vec<Event>, limit: usize, first: bool) -> (Vec<Event>, usize) {
530 if !first {
531 return (events, 0);
532 }
533 let mut pushes: Vec<&Event> = events
534 .iter()
535 .filter(|e| e.trigger == Trigger::Pushed)
536 .collect();
537 pushes.sort_by_key(|e| e.number);
538 let allowed: BTreeSet<i64> = pushes.iter().take(limit).map(|e| e.number).collect();
539 let held = pushes.len().saturating_sub(allowed.len());
540 let kept = events
541 .into_iter()
542 .filter(|e| e.trigger != Trigger::Pushed || allowed.contains(&e.number))
543 .collect();
544 (kept, held)
545}
546
547pub fn merge(pending: &mut BTreeMap<i64, Pending>, event: &Event, now: u64) {
549 let p = pending.entry(event.number).or_insert_with(|| Pending {
550 kind: event.kind,
551 first_seen: now,
552 last_seen: now,
553 triggers: BTreeSet::new(),
554 who: Vec::new(),
555 head_from: None,
556 head_to: None,
557 });
558 p.last_seen = now;
559 p.triggers.insert(event.trigger);
560 if let Some(who) = &event.who {
561 if !p.who.contains(who) {
562 p.who.push(who.clone());
563 }
564 }
565 if let Some((from, to)) = &event.head {
566 if p.head_from.is_none() {
567 p.head_from = Some(from.clone());
568 }
569 p.head_to = Some(to.clone());
570 }
571}
572
573pub fn due(p: &Pending, now: u64, cfg: &ListenCfg, once: bool) -> bool {
576 once || now.saturating_sub(p.last_seen) >= cfg.settle
577 || now.saturating_sub(p.first_seen) >= cfg.max_wait
578}
579
580pub fn next_due(
584 state: &ListenState,
585 cfg: &ListenCfg,
586 acts_on: &dyn Fn(i64) -> bool,
587) -> Option<u64> {
588 state
589 .pending
590 .iter()
591 .filter(|(n, _)| acts_on(**n))
592 .map(|(n, p)| {
593 let settled = (p.last_seen + cfg.settle).min(p.first_seen + cfg.max_wait);
594 state
595 .items
596 .get(n)
597 .map_or(settled, |w| settled.max(w.retry_after))
598 })
599 .min()
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum Action {
605 Recheck(i64),
607 Review,
609 Checkin { reply_only: bool },
611 Capped(i64),
614 BelongsToResume,
616 Drop,
618 Ignore(&'static str),
620}
621
622impl Action {
623 pub fn phrase(&self, number: i64) -> String {
625 match self {
626 Action::Recheck(pr) if *pr == number => "take a second look".to_string(),
627 Action::Recheck(pr) => format!("take a second look at #{pr}"),
628 Action::Review => "review it".to_string(),
629 Action::Checkin { reply_only: false } => "check in on it".to_string(),
630 Action::Checkin { reply_only: true } => "answer the comments on it".to_string(),
631 Action::Capped(_) | Action::BelongsToResume => "leave it".to_string(),
632 Action::Drop => "drop it".to_string(),
633 Action::Ignore(_) => "ignore it".to_string(),
634 }
635 }
636
637 fn runs(&self) -> bool {
638 matches!(
639 self,
640 Action::Recheck(_) | Action::Review | Action::Checkin { .. }
641 )
642 }
643}
644
645#[derive(Debug, Clone, Default, PartialEq, Eq)]
648pub struct Facts {
649 pub number: i64,
650 pub named: bool,
651 pub open: bool,
652 pub author_is_viewer: bool,
655 pub has_posted_record: bool,
657 pub recheck_capped: bool,
658 pub has_custody_state: bool,
660 pub below_floor: bool,
661 pub linked_pr_with_record: Option<i64>,
663}
664
665pub fn plan(p: &Pending, f: &Facts, cfg: &ListenCfg) -> Action {
667 use Trigger::*;
668 if !f.open || p.triggers.contains(&Closed) {
669 return Action::Drop;
670 }
671 if f.below_floor && !f.named {
672 return Action::Ignore("it is below min_number and was not named");
673 }
674 match p.kind {
675 ItemKind::Issue => {
676 let asked = p.triggers.contains(&Mentioned)
677 || p.triggers.contains(&Assigned)
678 || (f.named && p.triggers.contains(&Replied));
679 if !asked {
680 return Action::Ignore("nothing on the issue asked for spar");
681 }
682 match f.linked_pr_with_record {
685 None => Action::Ignore("an issue with no reviewed pull request"),
686 Some(_) if f.author_is_viewer => Action::Ignore("its pull request is your own"),
687 Some(pr) if f.recheck_capped => Action::Capped(pr),
688 Some(pr) => Action::Recheck(pr),
689 }
690 }
691 ItemKind::Pr => {
692 if f.author_is_viewer {
697 let spoken_to = p
698 .triggers
699 .iter()
700 .any(|t| matches!(t, Replied | Mentioned | ReviewRequested));
701 if !spoken_to {
702 return Action::Ignore("a push to your own pull request");
703 }
704 return match cfg.on_own_pr {
705 OwnPr::Checkin => Action::Checkin { reply_only: false },
706 OwnPr::Reply => Action::Checkin { reply_only: true },
707 OwnPr::Ignore => Action::Ignore("on_own_pr is ignore"),
708 };
709 }
710 if f.has_posted_record {
711 return if f.recheck_capped {
712 Action::Capped(f.number)
713 } else {
714 Action::Recheck(f.number)
715 };
716 }
717 if f.has_custody_state {
718 return Action::BelongsToResume;
719 }
720 if f.named {
721 return Action::Review;
722 }
723 if p.triggers.contains(&ReviewRequested) {
724 return match cfg.on_review_requested {
725 ReviewOrIgnore::Review => Action::Review,
726 ReviewOrIgnore::Ignore => Action::Ignore("on_review_requested is ignore"),
727 };
728 }
729 if p.triggers.contains(&Assigned) {
730 return match cfg.on_assigned {
731 ReviewOrIgnore::Review => Action::Review,
732 ReviewOrIgnore::Ignore => Action::Ignore("on_assigned is ignore"),
733 };
734 }
735 if p.triggers.contains(&Mentioned) {
736 return Action::Review;
737 }
738 Action::Ignore("a push or a comment on a pull request spar has not reviewed")
739 }
740 }
741}
742
743pub fn prune_seen(state: &mut ListenState, now: u64) {
745 state.seen.retain(|_, at| {
746 clock::parse_rfc3339(at).is_none_or(|t| now.saturating_sub(t) < SEEN_FOR_SECS)
747 });
748}
749
750pub fn backoff_secs(failures: u32) -> u64 {
752 (60u64 << failures.saturating_sub(1).min(10)).min(900)
753}
754
755pub fn dispatch_backoff_secs(failures: u32) -> u64 {
757 (900u64 << failures.saturating_sub(1).min(10)).min(6 * 3600)
758}
759
760#[derive(Debug, Default)]
762struct Backoff {
763 failures: u32,
764 said: bool,
765}
766
767impl Backoff {
768 fn failed(&mut self, what: &str, why: &str) -> u64 {
769 self.failures += 1;
770 let wait = backoff_secs(self.failures);
771 let line = format!(
772 "could not {what}: {why}. Trying again in {}.",
773 spend::clock(Duration::from_secs(wait))
774 );
775 if self.said {
776 logdim!("{line}");
777 } else {
778 logwarn!("{line}");
779 self.said = true;
780 }
781 wait
782 }
783
784 fn recovered(&mut self) {
785 if self.said {
786 log!("gh is answering again");
787 }
788 *self = Self::default();
789 }
790}
791
792#[derive(Debug, Serialize, Deserialize)]
799struct LockFile {
800 pid: u32,
801 started: String,
802}
803
804#[derive(Debug)]
813struct Lock {
814 path: PathBuf,
815 _file: File,
817}
818
819impl Lock {
820 fn acquire(repo: &Repo, now: u64) -> Result<Lock> {
821 Lock::at(repo.listen_lock_path(), now)
822 }
823
824 fn at(path: PathBuf, now: u64) -> Result<Lock> {
825 if let Some(parent) = path.parent() {
826 std::fs::create_dir_all(parent)?;
827 }
828 for _ in 0..3 {
833 let Some(mut file) = open_locked(&path)? else {
834 let pid = std::fs::read_to_string(&path)
835 .ok()
836 .and_then(|text| serde_json::from_str::<LockFile>(&text).ok())
837 .map(|h| h.pid.to_string())
838 .unwrap_or_else(|| "unknown".into());
839 bail!(
840 "another `spar listen` (pid {pid}) is already watching this checkout. \
841 Stop it first.{}",
842 if cfg!(unix) {
843 String::new()
844 } else {
845 format!(" If that process is gone, delete {}.", path.display())
846 }
847 );
848 };
849 if !same_file(&file, &path) {
850 continue;
851 }
852 file.set_len(0)?;
853 serde_json::to_writer_pretty(
854 &mut file,
855 &LockFile {
856 pid: std::process::id(),
857 started: clock::rfc3339(now),
858 },
859 )?;
860 return Ok(Lock { path, _file: file });
861 }
862 bail!("could not take the listen lock at {}", path.display())
863 }
864}
865
866impl Drop for Lock {
867 fn drop(&mut self) {
868 let _ = std::fs::remove_file(&self.path);
871 }
872}
873
874#[cfg(unix)]
876fn open_locked(path: &Path) -> Result<Option<File>> {
877 use std::os::unix::io::AsRawFd;
878 let file = std::fs::OpenOptions::new()
879 .read(true)
880 .write(true)
881 .create(true)
882 .truncate(false)
883 .open(path)?;
884 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
885 return Ok(Some(file));
886 }
887 let e = std::io::Error::last_os_error();
888 if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
889 Ok(None)
890 } else {
891 Err(e.into())
892 }
893}
894
895#[cfg(not(unix))]
898fn open_locked(path: &Path) -> Result<Option<File>> {
899 match std::fs::OpenOptions::new()
900 .write(true)
901 .create_new(true)
902 .open(path)
903 {
904 Ok(file) => Ok(Some(file)),
905 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
906 Err(e) => Err(e.into()),
907 }
908}
909
910#[cfg(unix)]
911fn same_file(file: &File, path: &Path) -> bool {
912 use std::os::unix::fs::MetadataExt;
913 match (file.metadata(), std::fs::metadata(path)) {
914 (Ok(open), Ok(named)) => open.dev() == named.dev() && open.ino() == named.ino(),
915 _ => false,
916 }
917}
918
919#[cfg(not(unix))]
920fn same_file(_file: &File, _path: &Path) -> bool {
921 true
922}
923
924enum Polled {
929 Nothing(u64),
931 Threads(Vec<Thread>, u64),
932 RateLimited(u64),
934 Failed(String),
935}
936
937fn poll(repo: &Repo, cfg: &ListenCfg, state: &mut ListenState, now: u64) -> Polled {
938 let endpoint = format!(
939 "repos/{{owner}}/{{repo}}/notifications?participating=true&all=true&per_page=100&since={}",
940 state.watermark
941 );
942 let last_modified = state.last_modified.clone();
943 let headers: Vec<(&str, &str)> = last_modified
944 .as_deref()
945 .map(|lm| vec![("If-Modified-Since", lm)])
946 .unwrap_or_default();
947 let resp = match repo.gh_api_raw(&endpoint, &headers) {
948 Ok(resp) => resp,
949 Err(e) => return Polled::Failed(e.last_line().to_string()),
950 };
951 let interval = poll_interval(cfg, resp.headers.get("x-poll-interval").map(String::as_str));
952 match resp.status {
953 304 => Polled::Nothing(interval),
954 200 => match every_page(repo, &resp, &state.watermark, now) {
955 Ok(threads) => {
956 state.last_modified = resp
959 .headers
960 .get("last-modified")
961 .or_else(|| resp.headers.get("date"))
962 .cloned();
963 Polled::Threads(threads, interval)
964 }
965 Err(failed) => failed,
966 },
967 401 => Polled::Failed("gh is not authenticated; run `gh auth status`".into()),
968 _ => refused(&resp, now),
969 }
970}
971
972fn refused(resp: &ApiResponse, now: u64) -> Polled {
974 match rate_limit_wait(resp, now) {
975 Some(wait) => Polled::RateLimited(wait),
976 None => Polled::Failed(status_message(resp)),
977 }
978}
979
980fn every_page(
987 repo: &Repo,
988 first: &ApiResponse,
989 watermark: &str,
990 now: u64,
991) -> std::result::Result<Vec<Thread>, Polled> {
992 let unexpected =
993 |e: serde_json::Error| Polled::Failed(format!("unexpected notification data: {e}"));
994 let mut threads: Vec<Thread> = serde_json::from_str(&first.body).map_err(unexpected)?;
995 let mut next = next_link(&first.headers);
996 let mut pages = 1;
997 while let Some(url) = next.take() {
998 if pages >= MAX_PAGES {
999 return Err(Polled::Failed(format!(
1000 "more than {MAX_PAGES} pages of notifications since {watermark}. Run once with \
1001 --since, like --since 1d, to start from later and skip the rest"
1002 )));
1003 }
1004 let more = repo
1005 .gh_api_raw(&url, &[])
1006 .map_err(|e| Polled::Failed(e.last_line().to_string()))?;
1007 if more.status != 200 {
1008 return Err(refused(&more, now));
1009 }
1010 threads.extend(serde_json::from_str::<Vec<Thread>>(&more.body).map_err(unexpected)?);
1011 next = next_link(&more.headers);
1012 pages += 1;
1013 }
1014 Ok(threads)
1015}
1016
1017#[derive(Default)]
1019struct Swept {
1020 rows: Vec<PrSweepRow>,
1021 closed_issues: Vec<i64>,
1023}
1024
1025fn sweep_rows(
1028 repo: &Repo,
1029 cfg: &Config,
1030 repo_mode: bool,
1031 named_prs: &[i64],
1032 named_issues: &[i64],
1033 state: &ListenState,
1034) -> Result<Swept> {
1035 if repo_mode {
1036 return Ok(Swept {
1037 rows: repo.sweep_open_prs()?,
1038 closed_issues: Vec::new(),
1039 });
1040 }
1041 let watched = |n: &&i64| state.items.contains_key(*n);
1042 let mut swept = Swept::default();
1043 for number in named_prs.iter().filter(watched) {
1044 swept.rows.push(repo.sweep_pr(*number)?);
1045 }
1046 for issue in named_issues.iter().filter(watched) {
1047 if !repo.read_issue(*issue)?.state.eq_ignore_ascii_case("OPEN") {
1051 swept.closed_issues.push(*issue);
1052 continue;
1053 }
1054 if let Ok(Some(pr)) = repo.try_open_pr_for_issue(*issue, cfg.base_branch()) {
1057 if repo.read_review_record(pr.number).is_some_and(|r| r.posted) {
1058 swept.rows.push(repo.sweep_pr(pr.number)?);
1059 }
1060 }
1061 }
1062 Ok(swept)
1063}
1064
1065fn facts(repo: &Repo, cfg: &Config, number: i64, kind: ItemKind, named: bool) -> Result<Facts> {
1066 let viewer = repo.viewer_login()?.to_string();
1067 let below_floor = number < cfg.loop_cfg.min_number;
1068 match kind {
1069 ItemKind::Pr => {
1070 let row = repo.sweep_pr(number)?;
1071 let record = repo.read_review_record(number);
1072 let posted = record.as_ref().is_some_and(|r| r.posted);
1073 Ok(Facts {
1074 number,
1075 named,
1076 open: row.state.eq_ignore_ascii_case("OPEN"),
1077 author_is_viewer: row
1078 .author
1079 .as_ref()
1080 .is_some_and(|a| crate::comments::same_login(&a.login, &viewer)),
1081 has_posted_record: posted,
1082 recheck_capped: posted
1083 && record
1084 .as_ref()
1085 .is_some_and(|r| r.rechecks >= cfg.listen.max_rechecks),
1086 has_custody_state: repo.state_path(number).exists(),
1087 below_floor,
1088 linked_pr_with_record: None,
1089 })
1090 }
1091 ItemKind::Issue => {
1092 let issue = repo.read_issue(number)?;
1093 let linked = repo
1094 .try_open_pr_for_issue(number, cfg.base_branch())
1095 .ok()
1096 .flatten()
1097 .and_then(|pr| {
1098 repo.read_review_record(pr.number)
1099 .filter(|r| r.posted)
1100 .map(|r| (pr.number, r))
1101 });
1102 let (author_is_viewer, recheck_capped) = match &linked {
1104 Some((pr, record)) => {
1105 let row = repo.sweep_pr(*pr)?;
1106 (
1107 row.author
1108 .as_ref()
1109 .is_some_and(|a| crate::comments::same_login(&a.login, &viewer)),
1110 record.rechecks >= cfg.listen.max_rechecks,
1111 )
1112 }
1113 None => (false, false),
1114 };
1115 Ok(Facts {
1116 number,
1117 named,
1118 open: issue.state.eq_ignore_ascii_case("OPEN"),
1119 author_is_viewer,
1120 has_posted_record: false,
1121 recheck_capped,
1122 has_custody_state: false,
1123 below_floor,
1124 linked_pr_with_record: linked.map(|(pr, _)| pr),
1125 })
1126 }
1127 }
1128}
1129
1130fn dispatch(
1131 agents: &[Agent],
1132 cfg: &Config,
1133 repo: &Repo,
1134 action: &Action,
1135 number: i64,
1136 p: &Pending,
1137) -> IssueRun {
1138 match action {
1139 Action::Recheck(pr) => review_only::recheck_pr(
1140 agents,
1141 cfg,
1142 repo,
1143 *pr,
1144 RecheckMode {
1145 dry_run: false,
1146 requested: p.triggers.contains(&Trigger::ReviewRequested),
1147 verdict_on_fallback: cfg.listen.verdicts,
1148 },
1149 ),
1150 Action::Review => review_only::review_pr(
1151 agents,
1152 cfg,
1153 repo,
1154 number,
1155 ReviewOpts {
1156 dry_run: false,
1157 verdict: cfg.listen.verdicts,
1158 },
1159 ),
1160 Action::Checkin { reply_only } => checkin::checkin_pr(
1161 agents,
1162 cfg,
1163 repo,
1164 number,
1165 &checkin::Mode {
1166 dry_run: false,
1167 reply_only: *reply_only,
1168 trust: cfg.loop_cfg.checkin_trust,
1169 again: false,
1170 resolve: cfg.loop_cfg.checkin_resolve,
1171 posts: checkin::posts(cfg),
1172 },
1173 ),
1174 other => {
1175 let mut run = IssueRun::new(number, format!("#{number}"));
1176 run.status = Status::Error;
1177 run.notes
1178 .push(format!("nothing to run: {}", other.phrase(number)));
1179 run
1180 }
1181 }
1182}
1183
1184fn describe(repo: &Repo, event: &Event) -> String {
1186 let n = event.number;
1187 match event.trigger {
1188 Trigger::Pushed => {
1189 let (from, to) = event.head.clone().unwrap_or_default();
1190 let short = |s: &str| s.chars().take(7).collect::<String>();
1191 let commits = repo
1192 .commits_between(&from, &to)
1193 .map(|c| format!(", {c} new commit{} since the review", plural(c as usize)))
1194 .unwrap_or_default();
1195 format!(
1196 "#{n}: head moved {} to {}{commits}",
1197 short(&from),
1198 short(&to)
1199 )
1200 }
1201 Trigger::Replied => match &event.who {
1202 Some(who) => format!("#{n}: {who} commented"),
1203 None => format!("#{n}: somebody commented"),
1204 },
1205 Trigger::Mentioned => match &event.who {
1206 Some(who) => format!("#{n}: mentioned by {who}"),
1207 None => format!("#{n}: mentioned"),
1208 },
1209 Trigger::ReviewRequested => format!("#{n}: your review was requested"),
1210 Trigger::Assigned => format!("#{n}: assigned to you"),
1211 Trigger::Closed => format!("#{n}: closed, no longer watched"),
1212 }
1213}
1214
1215fn plural(n: usize) -> &'static str {
1216 if n == 1 {
1217 ""
1218 } else {
1219 "s"
1220 }
1221}
1222
1223enum Ending {
1228 Interrupted,
1229 Once { failed: bool },
1230 Done,
1231}
1232
1233pub fn run(agents: &[Agent], cfg: &Config, repo: &Repo, opts: Opts) -> Result<i32> {
1235 proc::stop_on_signal()?;
1236 if !cfg!(unix) {
1237 logdim!(
1238 "Ctrl-C ends spar at once on this platform; an agent call in flight is not stopped"
1239 );
1240 }
1241 let now = clock::now_secs();
1242 let lock = Lock::acquire(repo, now)?;
1243 let viewer = repo.viewer_login()?.to_string();
1244 let name = repo
1245 .repo_name()
1246 .unwrap_or_else(|| repo.root().display().to_string());
1247
1248 let named: BTreeSet<i64> = opts.items.iter().copied().collect();
1251 let mut named_prs = Vec::new();
1252 let mut named_issues = Vec::new();
1253 for number in &named {
1254 match repo.item_kind(*number)? {
1255 ItemKind::Pr => named_prs.push(*number),
1256 ItemKind::Issue => named_issues.push(*number),
1257 }
1258 }
1259 let repo_mode = named.is_empty();
1260 let mut watching: BTreeSet<i64> = named.clone();
1265 let save = |state: &ListenState| {
1268 if !opts.dry_run {
1269 write_state(repo, state);
1270 }
1271 };
1272
1273 let mut state = read_state(repo);
1274 if state.watermark.is_empty() || opts.since.is_some() {
1275 let back = opts.since.map(|d| d.as_secs()).unwrap_or(0);
1276 state.watermark = clock::rfc3339(now.saturating_sub(back));
1277 state.last_modified = None;
1278 log!("watching from {}", state.watermark);
1279 }
1280 for number in &named_prs {
1281 state.watched(*number, ItemKind::Pr);
1282 }
1283 for number in &named_issues {
1284 state.watched(*number, ItemKind::Issue);
1285 }
1286 log!(
1287 "listening to {name}{}: polling every {}s, sweeping {} every {}s. Ctrl-C to stop.",
1288 if repo_mode {
1289 String::new()
1290 } else {
1291 format!(
1292 " for {}",
1293 named
1294 .iter()
1295 .map(|n| format!("#{n}"))
1296 .collect::<Vec<_>>()
1297 .join(", ")
1298 )
1299 },
1300 cfg.listen.interval.max(FLOOR_SECS),
1301 if repo_mode {
1302 "open pull requests"
1303 } else {
1304 "the named items"
1305 },
1306 cfg.listen.sweep
1307 );
1308
1309 let started = now;
1310 let mut next_poll = now;
1311 let mut next_sweep = now;
1312 let mut backoff = Backoff::default();
1313 let mut events_seen = 0usize;
1314 let mut dispatches = 0usize;
1315 let mut tick_failed = false;
1316 let mut last_prune = now;
1317
1318 let ending = loop {
1319 if proc::interrupted() {
1320 break Ending::Interrupted;
1321 }
1322 let now = clock::now_secs();
1323 let mut events: Vec<Event> = Vec::new();
1324
1325 if now >= next_poll {
1326 match poll(repo, &cfg.listen, &mut state, now) {
1327 Polled::Nothing(interval) => {
1328 backoff.recovered();
1329 next_poll = now + interval;
1330 }
1331 Polled::Threads(threads, interval) => {
1332 backoff.recovered();
1333 events.extend(events_from_threads(&threads, &mut state));
1334 next_poll = now + interval;
1335 }
1336 Polled::RateLimited(wait) => {
1337 logwarn!(
1338 "rate limited by GitHub, waiting {} until it resets",
1339 spend::clock(Duration::from_secs(wait))
1340 );
1341 next_poll = now + wait;
1342 tick_failed = true;
1343 }
1344 Polled::Failed(why) => {
1345 next_poll = now + backoff.failed("read notifications", &why);
1346 tick_failed = true;
1347 }
1348 }
1349 }
1350
1351 if now >= next_sweep {
1352 match sweep_rows(repo, cfg, repo_mode, &named_prs, &named_issues, &state) {
1353 Ok(Swept {
1354 rows,
1355 closed_issues,
1356 }) => {
1357 backoff.recovered();
1358 if !repo_mode {
1359 watching.extend(rows.iter().map(|r| r.number));
1360 }
1361 events.extend(closed_issues.into_iter().map(|number| Event {
1362 number,
1363 kind: ItemKind::Issue,
1364 trigger: Trigger::Closed,
1365 who: None,
1366 head: None,
1367 commits: None,
1368 }));
1369 let record_head = |n: i64| {
1370 repo.read_review_record(n)
1371 .filter(|r| r.posted)
1372 .map(|r| r.head)
1373 };
1374 let swept = events_from_sweep(
1375 &rows,
1376 &viewer,
1377 &mut state,
1378 &named,
1379 cfg.loop_cfg.min_number,
1380 &record_head,
1381 repo_mode,
1382 );
1383 let (kept, held) = cap_first_sweep(swept, opts.limit, !state.swept_once);
1384 if held > 0 {
1385 log!(
1386 "{held} more reviewed pull request{} moved since their review; raise \
1387 --limit or run `spar review` on them",
1388 plural(held)
1389 );
1390 }
1391 events.extend(kept);
1392 state.swept_once = true;
1393 next_sweep = now + cfg.listen.sweep;
1394 }
1395 Err(e) => {
1396 next_sweep = now + backoff.failed("sweep the pull requests", e.last_line());
1397 tick_failed = true;
1398 }
1399 }
1400 }
1401
1402 for event in &events {
1406 if !repo_mode && !watching.contains(&event.number) {
1407 continue;
1408 }
1409 if event.trigger != Trigger::Closed && event.who.is_none() {
1410 }
1414 events_seen += 1;
1415 log!("{}", describe(repo, event));
1416 if event.trigger == Trigger::Closed {
1417 state.items.remove(&event.number);
1418 state.pending.remove(&event.number);
1419 continue;
1420 }
1421 state.watched(event.number, event.kind);
1422 merge(&mut state.pending, event, now);
1423 }
1424 save(&state);
1425
1426 let due_now: Vec<i64> = state
1427 .pending
1428 .iter()
1429 .filter(|(n, p)| {
1430 (repo_mode || watching.contains(n))
1431 && due(p, now, &cfg.listen, opts.once)
1432 && state.items.get(n).is_none_or(|w| w.retry_after <= now)
1433 })
1434 .map(|(n, _)| *n)
1435 .collect();
1436 for number in due_now {
1437 let Some(p) = state.pending.get(&number).cloned() else {
1438 continue;
1439 };
1440 let named_item = named.contains(&number);
1441 let f = match facts(repo, cfg, number, p.kind, named_item) {
1442 Ok(f) => f,
1443 Err(e) => {
1444 let w = state.watched(number, p.kind);
1445 w.failures += 1;
1446 w.retry_after = now + dispatch_backoff_secs(w.failures);
1447 logwarn!("#{number}: could not read it: {}", e.last_line());
1448 tick_failed = true;
1449 save(&state);
1450 continue;
1451 }
1452 };
1453 let action = plan(&p, &f, &cfg.listen);
1454 match &action {
1455 Action::Ignore(why) => {
1456 logdim!("#{number}: ignoring {why}");
1457 state.pending.remove(&number);
1458 }
1459 Action::Drop => {
1460 log!("#{number}: closed, no longer watched");
1461 state.items.remove(&number);
1462 state.pending.remove(&number);
1463 }
1464 Action::Capped(pr) => {
1465 let w = state.watched(number, p.kind);
1466 if !w.said_capped {
1467 let what = if *pr == number {
1468 "rechecked".to_string()
1469 } else {
1470 format!("its pull request #{pr} was rechecked")
1471 };
1472 log!(
1473 "#{number}: {what} {} time{} already; run `spar review {pr}` to look \
1474 again",
1475 cfg.listen.max_rechecks,
1476 plural(cfg.listen.max_rechecks as usize)
1477 );
1478 w.said_capped = true;
1479 }
1480 state.pending.remove(&number);
1481 }
1482 Action::BelongsToResume => {
1483 let w = state.watched(number, p.kind);
1484 if !w.said_resume {
1485 log!("#{number}: in custody, `spar resume {number}` continues it");
1486 w.said_resume = true;
1487 }
1488 state.pending.remove(&number);
1489 }
1490 action if action.runs() => {
1491 if opts.dry_run {
1492 log!(
1493 "#{number}: would {} after {}, dry run so nothing runs",
1494 action.phrase(number),
1495 p.because()
1496 );
1497 state.pending.remove(&number);
1498 } else {
1499 log!(
1500 "#{number}: about to {} after {}",
1501 action.phrase(number),
1502 p.because()
1503 );
1504 let acted_on = match action {
1505 Action::Recheck(pr) => *pr,
1506 _ => number,
1507 };
1508 let record_before = repo.read_review_record(acted_on);
1509 let failed_before = repo.write_summary().failed;
1510 let run = dispatch(agents, cfg, repo, action, number, &p);
1511 dispatches += 1;
1512 let spent = spend::drain();
1513 if let Err(e) = repo.record_spend(&spent) {
1514 logdim!("could not record what this run spent: {e}");
1515 }
1516 crate::cli::print_outcome(&run, &spent);
1517 if proc::interrupted() {
1518 log!("interrupted while working on #{number}; its worktree was kept");
1519 save(&state);
1520 break;
1521 }
1522 let record_after = repo.read_review_record(acted_on);
1528 if record_after != record_before {
1529 if let Some(record) = record_after {
1530 state.watched(acted_on, ItemKind::Pr).head = Some(record.head);
1531 }
1532 }
1533 let unposted = repo.write_summary().failed > failed_before;
1537 let w = state.watched(number, p.kind);
1538 if run.status == Status::Error || unposted {
1539 w.failures += 1;
1540 let wait = dispatch_backoff_secs(w.failures);
1541 w.retry_after = clock::now_secs() + wait;
1544 if w.failures >= MAX_FAILURES {
1545 logwarn!("#{number}: giving up on it until it changes again");
1546 state.pending.remove(&number);
1547 } else {
1548 if unposted {
1549 logwarn!(
1550 "#{number}: not everything it wrote reached GitHub, so it \
1551 runs again in {}",
1552 spend::clock(Duration::from_secs(wait))
1553 );
1554 }
1555 if let Some(p) = state.pending.get_mut(&number) {
1556 p.first_seen = now;
1557 }
1558 }
1559 tick_failed = true;
1560 } else {
1561 w.failures = 0;
1562 w.retry_after = 0;
1563 w.last_dispatched = Some(now);
1564 state.pending.remove(&number);
1565 }
1566 }
1567 }
1568 _ => {}
1569 }
1570 save(&state);
1571 }
1572 if proc::interrupted() {
1573 break Ending::Interrupted;
1574 }
1575
1576 if opts.once {
1577 break Ending::Once {
1578 failed: tick_failed,
1579 };
1580 }
1581 if !repo_mode && named.iter().all(|n| !state.items.contains_key(n)) {
1582 log!("every named item is closed, nothing left to watch");
1583 break Ending::Done;
1584 }
1585 if now.saturating_sub(last_prune) >= 3600 {
1586 prune_seen(&mut state, now);
1587 last_prune = now;
1588 save(&state);
1589 }
1590
1591 let acts_on = |n: i64| repo_mode || watching.contains(&n);
1592 let next = [next_poll, next_sweep]
1593 .into_iter()
1594 .chain(next_due(&state, &cfg.listen, &acts_on))
1595 .min()
1596 .unwrap_or(now + FLOOR_SECS);
1597 let wait = next.saturating_sub(clock::now_secs()).clamp(1, 3600);
1598 if proc::sleep_unless_interrupted(Duration::from_secs(wait)) {
1599 break Ending::Interrupted;
1600 }
1601 };
1602
1603 save(&state);
1604 drop(lock);
1605 log!(
1606 "listened for {}: {events_seen} event{}, {dispatches} dispatch{}",
1607 spend::clock(Duration::from_secs(
1608 clock::now_secs().saturating_sub(started)
1609 )),
1610 plural(events_seen),
1611 if dispatches == 1 { "" } else { "es" }
1612 );
1613 let writes = crate::cli::report_writes(repo);
1614 Ok(match ending {
1615 Ending::Interrupted => 130.max(writes),
1616 Ending::Once { failed: true } => 1.max(writes),
1617 Ending::Once { failed: false } | Ending::Done => writes,
1618 })
1619}
1620
1621fn read_state(repo: &Repo) -> ListenState {
1622 let path = repo.listen_state_path();
1623 match std::fs::read_to_string(&path) {
1624 Ok(text) => match serde_json::from_str::<ListenState>(&text) {
1625 Ok(state) => state,
1626 Err(e) => {
1627 logdim!("could not read {}: {e}. Starting fresh.", path.display());
1628 ListenState::default()
1629 }
1630 },
1631 Err(_) => ListenState::default(),
1632 }
1633}
1634
1635fn write_state(repo: &Repo, state: &ListenState) {
1636 let mut state = state.clone();
1637 state.version = STATE_VERSION;
1638 if let Err(e) = crate::repo::write_json_atomic(&repo.listen_state_path(), &state) {
1639 logdim!("could not write the listen state: {e}");
1640 }
1641}
1642
1643#[cfg(test)]
1644mod tests {
1645 use super::*;
1646 use crate::model::{Author, Login};
1647
1648 fn cfg() -> ListenCfg {
1649 ListenCfg::default()
1650 }
1651
1652 fn thread(id: &str, reason: &str, kind: &str, number: i64, at: &str) -> Thread {
1653 Thread {
1654 id: id.into(),
1655 reason: reason.into(),
1656 updated_at: at.into(),
1657 subject: Subject {
1658 title: format!("thing {number}"),
1659 url: Some(format!(
1660 "https://api.github.com/repos/o/r/{}/{number}",
1661 if kind == "PullRequest" {
1662 "pulls"
1663 } else {
1664 "issues"
1665 }
1666 )),
1667 latest_comment_url: None,
1668 kind: kind.into(),
1669 },
1670 }
1671 }
1672
1673 fn row(number: i64, head: &str, author: &str, state: &str) -> PrSweepRow {
1674 PrSweepRow {
1675 number,
1676 title: format!("PR {number}"),
1677 head_ref_oid: head.into(),
1678 author: Some(Author {
1679 login: author.into(),
1680 }),
1681 state: state.into(),
1682 ..Default::default()
1683 }
1684 }
1685
1686 fn pending(kind: ItemKind, triggers: &[Trigger], now: u64) -> Pending {
1687 Pending {
1688 kind,
1689 first_seen: now,
1690 last_seen: now,
1691 triggers: triggers.iter().copied().collect(),
1692 who: Vec::new(),
1693 head_from: None,
1694 head_to: None,
1695 }
1696 }
1697
1698 fn open_pr() -> Facts {
1699 Facts {
1700 number: 7,
1701 open: true,
1702 ..Default::default()
1703 }
1704 }
1705
1706 fn response(status: u16, headers: &[(&str, &str)]) -> ApiResponse {
1707 ApiResponse {
1708 status,
1709 headers: headers
1710 .iter()
1711 .map(|(k, v)| (k.to_string(), v.to_string()))
1712 .collect(),
1713 body: String::new(),
1714 }
1715 }
1716
1717 #[test]
1720 fn the_poll_interval_is_the_largest_of_the_config_the_header_and_the_floor() {
1721 let mut c = cfg();
1722 assert_eq!(60, poll_interval(&c, None));
1723 assert_eq!(90, poll_interval(&c, Some("90")));
1724 assert_eq!(60, poll_interval(&c, Some("30")));
1725 assert_eq!(60, poll_interval(&c, Some("soon")));
1726 c.interval = 5;
1727 assert_eq!(15, poll_interval(&c, None));
1728 }
1729
1730 #[test]
1731 fn a_rate_limit_answer_says_how_long_to_wait_and_caps_it_at_an_hour() {
1732 let now = 1_000_000;
1733 assert_eq!(
1734 Some(120),
1735 rate_limit_wait(&response(429, &[("retry-after", "120")]), now)
1736 );
1737 assert_eq!(
1738 Some(600),
1739 rate_limit_wait(
1740 &response(
1741 403,
1742 &[
1743 ("x-ratelimit-remaining", "0"),
1744 ("x-ratelimit-reset", "1000600")
1745 ]
1746 ),
1747 now
1748 )
1749 );
1750 assert_eq!(
1751 Some(3600),
1752 rate_limit_wait(&response(403, &[("retry-after", "99999")]), now)
1753 );
1754 assert_eq!(
1755 Some(60),
1756 rate_limit_wait(&response(429, &[("retry-after", "1")]), now)
1757 );
1758 }
1759
1760 #[test]
1761 fn a_forbidden_answer_without_a_rate_limit_header_is_a_failure_not_a_wait() {
1762 assert_eq!(None, rate_limit_wait(&response(403, &[]), 0));
1763 assert_eq!(
1764 None,
1765 rate_limit_wait(&response(403, &[("x-ratelimit-remaining", "40")]), 0)
1766 );
1767 assert_eq!(
1768 None,
1769 rate_limit_wait(&response(200, &[("retry-after", "5")]), 0)
1770 );
1771 }
1772
1773 #[test]
1774 fn next_link_reads_the_next_page_and_nothing_else() {
1775 let mut headers = BTreeMap::new();
1776 headers.insert(
1777 "link".to_string(),
1778 "<https://api.github.com/x?page=2>; rel=\"next\", <https://api.github.com/x?page=9>; rel=\"last\"".to_string(),
1779 );
1780 assert_eq!(
1781 Some("https://api.github.com/x?page=2".to_string()),
1782 next_link(&headers)
1783 );
1784 headers.insert(
1785 "link".to_string(),
1786 "<https://api.github.com/x?page=1>; rel=\"prev\"".to_string(),
1787 );
1788 assert_eq!(None, next_link(&headers));
1789 assert_eq!(None, next_link(&BTreeMap::new()));
1790 }
1791
1792 #[test]
1795 fn a_thread_becomes_an_event_keyed_by_its_number_and_reason() {
1796 let mut state = ListenState::default();
1797 let threads = vec![
1798 thread(
1799 "1",
1800 "review_requested",
1801 "PullRequest",
1802 7,
1803 "2026-10-20T10:00:00Z",
1804 ),
1805 thread("2", "mention", "Issue", 42, "2026-10-20T10:01:00Z"),
1806 thread("3", "comment", "PullRequest", 8, "2026-10-20T10:02:00Z"),
1807 thread("4", "assign", "PullRequest", 9, "2026-10-20T10:03:00Z"),
1808 thread("5", "team_mention", "Issue", 43, "2026-10-20T10:04:00Z"),
1809 thread(
1810 "6",
1811 "state_change",
1812 "PullRequest",
1813 10,
1814 "2026-10-20T10:05:00Z",
1815 ),
1816 ];
1817 let events = events_from_threads(&threads, &mut state);
1818 let got: Vec<(i64, ItemKind, Trigger)> = events
1819 .iter()
1820 .map(|e| (e.number, e.kind, e.trigger))
1821 .collect();
1822 assert_eq!(
1823 vec![
1824 (7, ItemKind::Pr, Trigger::ReviewRequested),
1825 (42, ItemKind::Issue, Trigger::Mentioned),
1826 (8, ItemKind::Pr, Trigger::Replied),
1827 (9, ItemKind::Pr, Trigger::Assigned),
1828 (43, ItemKind::Issue, Trigger::Mentioned),
1829 (10, ItemKind::Pr, Trigger::Replied),
1830 ],
1831 got
1832 );
1833 }
1834
1835 #[test]
1836 fn a_thread_seen_at_the_same_update_time_is_not_an_event_twice() {
1837 let mut state = ListenState::default();
1838 let same = vec![thread(
1839 "1",
1840 "comment",
1841 "PullRequest",
1842 7,
1843 "2026-10-20T10:00:00Z",
1844 )];
1845 assert_eq!(1, events_from_threads(&same, &mut state).len());
1846 assert_eq!(0, events_from_threads(&same, &mut state).len());
1847 let moved = vec![thread(
1848 "1",
1849 "comment",
1850 "PullRequest",
1851 7,
1852 "2026-10-20T10:05:00Z",
1853 )];
1854 assert_eq!(1, events_from_threads(&moved, &mut state).len());
1855 }
1856
1857 #[test]
1858 fn a_thread_about_a_release_or_discussion_is_ignored() {
1859 let mut state = ListenState::default();
1860 let threads = vec![
1861 thread("1", "subscribed", "Release", 1, "2026-10-20T10:00:00Z"),
1862 thread("2", "mention", "Discussion", 2, "2026-10-20T10:01:00Z"),
1863 Thread {
1864 subject: Subject {
1865 url: None,
1866 kind: "PullRequest".into(),
1867 ..Default::default()
1868 },
1869 ..thread("3", "comment", "PullRequest", 3, "2026-10-20T10:02:00Z")
1870 },
1871 ];
1872 assert!(events_from_threads(&threads, &mut state).is_empty());
1873 assert_eq!(3, state.seen.len());
1876 }
1877
1878 #[test]
1879 fn the_watermark_advances_to_the_newest_update_seen() {
1880 let mut state = ListenState {
1881 watermark: "2026-10-20T09:00:00Z".into(),
1882 ..Default::default()
1883 };
1884 let threads = vec![
1885 thread("1", "comment", "PullRequest", 7, "2026-10-20T10:02:00Z"),
1886 thread("2", "comment", "PullRequest", 8, "2026-10-20T10:01:00Z"),
1887 ];
1888 events_from_threads(&threads, &mut state);
1889 assert_eq!("2026-10-20T10:02:00Z", state.watermark);
1890 }
1891
1892 fn no_record(_: i64) -> Option<String> {
1895 None
1896 }
1897
1898 #[test]
1899 fn a_sweep_notices_a_head_that_moved_and_says_it_once() {
1900 let mut state = ListenState::default();
1901 let named = BTreeSet::new();
1902 let first = events_from_sweep(
1903 &[row(7, "aaa", "alice", "OPEN")],
1904 "me",
1905 &mut state,
1906 &named,
1907 0,
1908 &no_record,
1909 true,
1910 );
1911 assert!(first.is_empty(), "{first:?}");
1912 let moved = events_from_sweep(
1913 &[row(7, "bbb", "alice", "OPEN")],
1914 "me",
1915 &mut state,
1916 &named,
1917 0,
1918 &no_record,
1919 true,
1920 );
1921 assert_eq!(1, moved.len());
1922 assert_eq!(Trigger::Pushed, moved[0].trigger);
1923 assert_eq!(Some(("aaa".to_string(), "bbb".to_string())), moved[0].head);
1924 let again = events_from_sweep(
1925 &[row(7, "bbb", "alice", "OPEN")],
1926 "me",
1927 &mut state,
1928 &named,
1929 0,
1930 &no_record,
1931 true,
1932 );
1933 assert!(again.is_empty(), "{again:?}");
1934 assert_eq!("alice", state.items[&7].author);
1935 }
1936
1937 #[test]
1938 fn the_first_sweep_treats_a_reviewed_pull_request_that_already_moved_as_due() {
1939 let mut state = ListenState::default();
1940 let record = |n: i64| (n == 7).then(|| "aaa".to_string());
1941 let events = events_from_sweep(
1942 &[row(7, "bbb", "alice", "OPEN"), row(8, "ccc", "bob", "OPEN")],
1943 "me",
1944 &mut state,
1945 &BTreeSet::new(),
1946 0,
1947 &record,
1948 true,
1949 );
1950 assert_eq!(1, events.len());
1951 assert_eq!(7, events[0].number);
1952 assert_eq!(Trigger::Pushed, events[0].trigger);
1953 }
1954
1955 #[test]
1956 fn a_sweep_notices_a_review_request_once_not_every_five_minutes() {
1957 let mut state = ListenState::default();
1958 let mut asked = row(7, "aaa", "alice", "OPEN");
1959 asked.review_requests = vec![Login { login: "ME".into() }];
1960 asked.assignees = vec![Login { login: "me".into() }];
1961 let rows = [asked];
1962 let first = events_from_sweep(
1963 &rows,
1964 "me",
1965 &mut state,
1966 &BTreeSet::new(),
1967 0,
1968 &no_record,
1969 true,
1970 );
1971 let triggers: BTreeSet<Trigger> = first.iter().map(|e| e.trigger).collect();
1972 assert_eq!(
1973 [Trigger::ReviewRequested, Trigger::Assigned]
1974 .into_iter()
1975 .collect::<BTreeSet<_>>(),
1976 triggers
1977 );
1978 let second = events_from_sweep(
1979 &rows,
1980 "me",
1981 &mut state,
1982 &BTreeSet::new(),
1983 0,
1984 &no_record,
1985 true,
1986 );
1987 assert!(second.is_empty(), "{second:?}");
1988 }
1989
1990 #[test]
1991 fn a_sweep_drops_a_pull_request_that_closed_or_vanished() {
1992 let mut state = ListenState::default();
1993 let named = BTreeSet::new();
1994 events_from_sweep(
1995 &[row(7, "aaa", "alice", "OPEN"), row(8, "bbb", "bob", "OPEN")],
1996 "me",
1997 &mut state,
1998 &named,
1999 0,
2000 &no_record,
2001 true,
2002 );
2003 let events = events_from_sweep(
2004 &[row(7, "aaa", "alice", "MERGED")],
2005 "me",
2006 &mut state,
2007 &named,
2008 0,
2009 &no_record,
2010 true,
2011 );
2012 let closed: Vec<i64> = events
2013 .iter()
2014 .filter(|e| e.trigger == Trigger::Closed)
2015 .map(|e| e.number)
2016 .collect();
2017 assert_eq!(vec![7, 8], closed);
2018
2019 let mut state = ListenState::default();
2022 let named: BTreeSet<i64> = [7, 8].into_iter().collect();
2023 events_from_sweep(
2024 &[row(7, "aaa", "alice", "OPEN"), row(8, "bbb", "bob", "OPEN")],
2025 "me",
2026 &mut state,
2027 &named,
2028 0,
2029 &no_record,
2030 false,
2031 );
2032 let events = events_from_sweep(
2033 &[row(7, "aaa", "alice", "OPEN")],
2034 "me",
2035 &mut state,
2036 &named,
2037 0,
2038 &no_record,
2039 false,
2040 );
2041 assert!(events.is_empty(), "{events:?}");
2042 }
2043
2044 #[test]
2045 fn a_sweep_ignores_a_number_below_the_floor_unless_it_was_named() {
2046 let mut state = ListenState::default();
2047 let named: BTreeSet<i64> = [3].into_iter().collect();
2048 let record = |_: i64| Some("old".to_string());
2049 let events = events_from_sweep(
2050 &[
2051 row(3, "new", "alice", "OPEN"),
2052 row(4, "new", "alice", "OPEN"),
2053 row(500, "new", "alice", "OPEN"),
2054 ],
2055 "me",
2056 &mut state,
2057 &named,
2058 480,
2059 &record,
2060 true,
2061 );
2062 let numbers: Vec<i64> = events.iter().map(|e| e.number).collect();
2063 assert_eq!(vec![3, 500], numbers);
2064 assert!(!state.items.contains_key(&4));
2065 }
2066
2067 #[test]
2070 fn a_pull_request_below_the_floor_that_is_still_open_is_not_called_closed() {
2071 let mut state = ListenState::default();
2072 state.watched(4, ItemKind::Pr);
2073 let events = events_from_sweep(
2074 &[
2075 row(4, "new", "alice", "OPEN"),
2076 row(500, "new", "alice", "OPEN"),
2077 ],
2078 "me",
2079 &mut state,
2080 &BTreeSet::new(),
2081 480,
2082 &no_record,
2083 true,
2084 );
2085 assert!(
2086 events.iter().all(|e| e.trigger != Trigger::Closed),
2087 "{events:?}"
2088 );
2089 let gone = events_from_sweep(
2090 &[row(500, "new", "alice", "OPEN")],
2091 "me",
2092 &mut state,
2093 &BTreeSet::new(),
2094 480,
2095 &no_record,
2096 true,
2097 );
2098 assert_eq!(
2099 vec![(4, Trigger::Closed)],
2100 gone.iter()
2101 .map(|e| (e.number, e.trigger))
2102 .collect::<Vec<_>>()
2103 );
2104 }
2105
2106 #[test]
2107 fn the_first_sweep_caps_pending_rechecks_at_the_limit_and_counts_the_rest() {
2108 let push = |n: i64| Event {
2109 number: n,
2110 kind: ItemKind::Pr,
2111 trigger: Trigger::Pushed,
2112 who: None,
2113 head: None,
2114 commits: None,
2115 };
2116 let asked = Event {
2117 trigger: Trigger::ReviewRequested,
2118 ..push(50)
2119 };
2120 let events = vec![push(9), push(3), push(7), asked.clone()];
2121 let (kept, held) = cap_first_sweep(events.clone(), 2, true);
2122 let numbers: Vec<i64> = kept.iter().map(|e| e.number).collect();
2123 assert_eq!(vec![3, 7, 50], numbers, "{kept:?}");
2124 assert_eq!(1, held);
2125 let (kept, held) = cap_first_sweep(events, 2, false);
2126 assert_eq!(4, kept.len());
2127 assert_eq!(0, held);
2128 }
2129
2130 #[test]
2133 fn a_push_or_a_reply_on_a_reviewed_pull_request_is_a_recheck() {
2134 let f = Facts {
2135 has_posted_record: true,
2136 ..open_pr()
2137 };
2138 for t in [
2139 Trigger::Pushed,
2140 Trigger::Replied,
2141 Trigger::Mentioned,
2142 Trigger::ReviewRequested,
2143 ] {
2144 assert_eq!(
2145 Action::Recheck(7),
2146 plan(&pending(ItemKind::Pr, &[t], 0), &f, &cfg()),
2147 "{t:?}"
2148 );
2149 }
2150 }
2151
2152 #[test]
2153 fn a_review_request_or_an_assignment_on_an_unreviewed_pull_request_follows_the_config() {
2154 let f = open_pr();
2155 let mut c = cfg();
2156 assert_eq!(
2157 Action::Review,
2158 plan(
2159 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2160 &f,
2161 &c
2162 )
2163 );
2164 assert_eq!(
2165 Action::Review,
2166 plan(&pending(ItemKind::Pr, &[Trigger::Assigned], 0), &f, &c)
2167 );
2168 assert_eq!(
2169 Action::Review,
2170 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2171 );
2172 c.on_review_requested = ReviewOrIgnore::Ignore;
2173 c.on_assigned = ReviewOrIgnore::Ignore;
2174 assert!(matches!(
2175 plan(
2176 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2177 &f,
2178 &c
2179 ),
2180 Action::Ignore(_)
2181 ));
2182 assert!(matches!(
2183 plan(&pending(ItemKind::Pr, &[Trigger::Assigned], 0), &f, &c),
2184 Action::Ignore(_)
2185 ));
2186 assert_eq!(
2188 Action::Review,
2189 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2190 );
2191 }
2192
2193 #[test]
2194 fn a_push_to_an_unreviewed_pull_request_is_ignored_unless_it_was_named() {
2195 let f = open_pr();
2196 assert!(matches!(
2197 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &f, &cfg()),
2198 Action::Ignore(_)
2199 ));
2200 assert!(matches!(
2201 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &cfg()),
2202 Action::Ignore(_)
2203 ));
2204 let named = Facts {
2205 named: true,
2206 ..open_pr()
2207 };
2208 assert_eq!(
2209 Action::Review,
2210 plan(
2211 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2212 &named,
2213 &cfg()
2214 )
2215 );
2216 assert_eq!(
2217 Action::Review,
2218 plan(
2219 &pending(ItemKind::Pr, &[Trigger::Replied], 0),
2220 &named,
2221 &cfg()
2222 )
2223 );
2224 }
2225
2226 #[test]
2227 fn your_own_pull_request_follows_on_own_pr_and_a_push_to_it_is_ignored() {
2228 let f = Facts {
2229 author_is_viewer: true,
2230 has_posted_record: true,
2231 ..open_pr()
2232 };
2233 let mut c = cfg();
2234 assert_eq!(
2235 Action::Checkin { reply_only: false },
2236 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &c)
2237 );
2238 assert_eq!(
2239 Action::Checkin { reply_only: false },
2240 plan(
2241 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2242 &f,
2243 &c
2244 )
2245 );
2246 assert!(matches!(
2247 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &f, &c),
2248 Action::Ignore(_)
2249 ));
2250 c.on_own_pr = OwnPr::Reply;
2251 assert_eq!(
2252 Action::Checkin { reply_only: true },
2253 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2254 );
2255 c.on_own_pr = OwnPr::Ignore;
2256 assert!(matches!(
2257 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &c),
2258 Action::Ignore(_)
2259 ));
2260 }
2261
2262 #[test]
2263 fn a_pull_request_in_custody_belongs_to_resume_and_a_capped_one_waits_for_a_person() {
2264 let custody = Facts {
2265 has_custody_state: true,
2266 ..open_pr()
2267 };
2268 assert_eq!(
2269 Action::BelongsToResume,
2270 plan(
2271 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2272 &custody,
2273 &cfg()
2274 )
2275 );
2276 let capped = Facts {
2277 has_posted_record: true,
2278 recheck_capped: true,
2279 ..open_pr()
2280 };
2281 assert_eq!(
2282 Action::Capped(7),
2283 plan(
2284 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2285 &capped,
2286 &cfg()
2287 )
2288 );
2289 }
2290
2291 #[test]
2295 fn an_issue_mention_keeps_to_its_pull_requests_cap_and_owner() {
2296 let linked = Facts {
2297 number: 42,
2298 open: true,
2299 linked_pr_with_record: Some(108),
2300 ..Default::default()
2301 };
2302 let capped = Facts {
2303 recheck_capped: true,
2304 ..linked.clone()
2305 };
2306 assert_eq!(
2307 Action::Capped(108),
2308 plan(
2309 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2310 &capped,
2311 &cfg()
2312 )
2313 );
2314 let own = Facts {
2315 author_is_viewer: true,
2316 ..linked
2317 };
2318 assert!(matches!(
2319 plan(
2320 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2321 &own,
2322 &cfg()
2323 ),
2324 Action::Ignore(_)
2325 ));
2326 }
2327
2328 #[test]
2329 fn an_issue_mention_without_a_pull_request_is_ignored_and_with_one_rechecks_it() {
2330 let issue = Facts {
2331 number: 42,
2332 open: true,
2333 ..Default::default()
2334 };
2335 assert!(matches!(
2336 plan(
2337 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2338 &issue,
2339 &cfg()
2340 ),
2341 Action::Ignore(_)
2342 ));
2343 assert!(matches!(
2344 plan(
2345 &pending(ItemKind::Issue, &[Trigger::Replied], 0),
2346 &issue,
2347 &cfg()
2348 ),
2349 Action::Ignore(_)
2350 ));
2351 let linked = Facts {
2352 linked_pr_with_record: Some(108),
2353 ..issue.clone()
2354 };
2355 assert_eq!(
2356 Action::Recheck(108),
2357 plan(
2358 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2359 &linked,
2360 &cfg()
2361 )
2362 );
2363 assert_eq!(
2364 Action::Recheck(108),
2365 plan(
2366 &pending(ItemKind::Issue, &[Trigger::Assigned], 0),
2367 &linked,
2368 &cfg()
2369 )
2370 );
2371 let named = Facts {
2373 named: true,
2374 ..linked
2375 };
2376 assert_eq!(
2377 Action::Recheck(108),
2378 plan(
2379 &pending(ItemKind::Issue, &[Trigger::Replied], 0),
2380 &named,
2381 &cfg()
2382 )
2383 );
2384 }
2385
2386 #[test]
2387 fn a_closed_item_is_dropped_and_one_below_the_floor_is_ignored_unless_named() {
2388 let closed = Facts {
2389 open: false,
2390 has_posted_record: true,
2391 ..open_pr()
2392 };
2393 assert_eq!(
2394 Action::Drop,
2395 plan(
2396 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2397 &closed,
2398 &cfg()
2399 )
2400 );
2401 let f = Facts {
2402 has_posted_record: true,
2403 ..open_pr()
2404 };
2405 assert_eq!(
2406 Action::Drop,
2407 plan(
2408 &pending(ItemKind::Pr, &[Trigger::Pushed, Trigger::Closed], 0),
2409 &f,
2410 &cfg()
2411 )
2412 );
2413 let low = Facts {
2414 below_floor: true,
2415 has_posted_record: true,
2416 ..open_pr()
2417 };
2418 assert!(matches!(
2419 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &low, &cfg()),
2420 Action::Ignore(_)
2421 ));
2422 let named_low = Facts { named: true, ..low };
2423 assert_eq!(
2424 Action::Recheck(7),
2425 plan(
2426 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2427 &named_low,
2428 &cfg()
2429 )
2430 );
2431 }
2432
2433 #[test]
2436 fn an_item_is_due_once_quiet_for_the_settle_time_or_at_max_wait_anyway() {
2437 let c = cfg();
2438 let mut p = pending(ItemKind::Pr, &[Trigger::Pushed], 1000);
2439 assert!(!due(&p, 1000, &c, false));
2440 assert!(!due(&p, 1000 + c.settle - 1, &c, false));
2441 assert!(due(&p, 1000 + c.settle, &c, false));
2442 p.last_seen = 1000 + c.max_wait - 30;
2444 assert!(!due(&p, 1000 + c.max_wait - 1, &c, false));
2445 assert!(due(&p, 1000 + c.max_wait, &c, false));
2446 assert!(due(&p, 1000, &c, true), "--once waits for nothing");
2447 }
2448
2449 #[test]
2450 fn events_merge_into_one_pending_record_per_item_and_the_next_due_is_the_earliest() {
2451 let c = cfg();
2452 let mut pending = BTreeMap::new();
2453 let push = Event {
2454 number: 7,
2455 kind: ItemKind::Pr,
2456 trigger: Trigger::Pushed,
2457 who: None,
2458 head: Some(("aaa".into(), "bbb".into())),
2459 commits: None,
2460 };
2461 merge(&mut pending, &push, 1000);
2462 let reply = Event {
2463 trigger: Trigger::Replied,
2464 who: Some("alice".into()),
2465 head: Some(("bbb".into(), "ccc".into())),
2466 ..push.clone()
2467 };
2468 merge(&mut pending, &reply, 1060);
2469 let other = Event {
2470 number: 8,
2471 ..push.clone()
2472 };
2473 merge(&mut pending, &other, 1100);
2474 assert_eq!(2, pending.len());
2475 let p = &pending[&7];
2476 assert_eq!(1000, p.first_seen);
2477 assert_eq!(1060, p.last_seen);
2478 assert_eq!(Some("aaa".to_string()), p.head_from);
2479 assert_eq!(Some("ccc".to_string()), p.head_to);
2480 assert_eq!(vec!["alice".to_string()], p.who);
2481 assert_eq!("a push and a reply", p.because());
2482 let mut state = ListenState {
2483 pending,
2484 ..Default::default()
2485 };
2486 let all = |_: i64| true;
2487 assert_eq!(Some(1060 + c.settle), next_due(&state, &c, &all));
2488 assert_eq!(None, next_due(&ListenState::default(), &c, &all));
2489
2490 state.watched(7, ItemKind::Pr).retry_after = 5000;
2493 assert_eq!(Some(1100 + c.settle), next_due(&state, &c, &all));
2494 state.watched(8, ItemKind::Pr).retry_after = 6000;
2495 assert_eq!(Some(5000), next_due(&state, &c, &all));
2496 assert_eq!(Some(6000), next_due(&state, &c, &|n| n == 8));
2498 }
2499
2500 #[test]
2503 fn since_accepts_minutes_hours_days_and_combinations() {
2504 assert_eq!(Duration::from_secs(1800), parse_duration("30m").unwrap());
2505 assert_eq!(Duration::from_secs(7200), parse_duration("2h").unwrap());
2506 assert_eq!(Duration::from_secs(86_400), parse_duration("1d").unwrap());
2507 assert_eq!(
2508 Duration::from_secs(5400),
2509 parse_duration(" 1h30m ").unwrap()
2510 );
2511 assert_eq!(Duration::from_secs(45), parse_duration("45s").unwrap());
2512 }
2513
2514 #[test]
2515 fn since_rejects_a_bare_number_and_says_what_it_takes() {
2516 for bad in ["", "30", "2 hours", "h", "1w", "1h30"] {
2517 let err = parse_duration(bad).unwrap_err().to_string();
2518 assert!(err.contains("like 30m, 2h or 1d"), "{bad:?}: {err}");
2519 }
2520 }
2521
2522 #[test]
2523 fn listen_state_round_trips_through_json_and_old_seen_entries_are_pruned() {
2524 let mut state = ListenState {
2525 version: STATE_VERSION,
2526 watermark: "2026-10-20T10:00:00Z".into(),
2527 last_modified: Some("Tue, 20 Oct 2026 10:00:00 GMT".into()),
2528 ..Default::default()
2529 };
2530 state
2531 .seen
2532 .insert("old".into(), "2026-10-01T10:00:00Z".into());
2533 state
2534 .seen
2535 .insert("new".into(), "2026-10-20T09:00:00Z".into());
2536 state.seen.insert("junk".into(), "yesterday".into());
2537 state.watched(7, ItemKind::Pr).head = Some("aaa".into());
2538 state
2539 .pending
2540 .insert(7, pending(ItemKind::Pr, &[Trigger::Pushed], 5));
2541 let text = serde_json::to_string_pretty(&state).unwrap();
2542 assert!(text.contains("\"pushed\""), "{text}");
2543 assert!(text.contains("\"kind\": \"pr\""), "{text}");
2544 let back: ListenState = serde_json::from_str(&text).unwrap();
2545 assert_eq!(state, back);
2546
2547 let now = clock::parse_rfc3339("2026-10-20T10:00:00Z").unwrap();
2548 prune_seen(&mut state, now);
2549 let left: Vec<&String> = state.seen.keys().collect();
2550 assert_eq!(vec!["junk", "new"], left);
2551 }
2552
2553 #[test]
2554 fn gh_failures_back_off_from_a_minute_to_fifteen_and_dispatches_from_fifteen_to_six_hours() {
2555 assert_eq!(60, backoff_secs(1));
2556 assert_eq!(120, backoff_secs(2));
2557 assert_eq!(480, backoff_secs(4));
2558 assert_eq!(900, backoff_secs(5));
2559 assert_eq!(900, backoff_secs(40));
2560 assert_eq!(900, dispatch_backoff_secs(1));
2561 assert_eq!(3600, dispatch_backoff_secs(3));
2562 assert_eq!(6 * 3600, dispatch_backoff_secs(9));
2563 }
2564
2565 #[test]
2566 fn number_from_url_reads_the_last_segment() {
2567 assert_eq!(
2568 Some(108),
2569 number_from_url("https://api.github.com/repos/o/r/pulls/108")
2570 );
2571 assert_eq!(
2572 Some(42),
2573 number_from_url("https://api.github.com/repos/o/r/issues/42/")
2574 );
2575 assert_eq!(
2576 None,
2577 number_from_url("https://api.github.com/repos/o/r/pulls")
2578 );
2579 }
2580
2581 #[cfg(unix)]
2582 #[test]
2583 fn a_held_lock_refuses_a_second_listener_however_old_and_a_left_file_is_taken_over() {
2584 let dir = std::env::temp_dir().join(format!(
2585 "spar-listen-lock-{}-{}",
2586 std::process::id(),
2587 clock::now_secs()
2588 ));
2589 std::fs::create_dir_all(&dir).unwrap();
2590 let path = dir.join("listen.lock");
2591
2592 let held = Lock::at(path.clone(), 1000).expect("first lock");
2595 for now in [1000, 1000 + 86_400] {
2596 let err = Lock::at(path.clone(), now).unwrap_err().to_string();
2597 assert!(err.contains("already watching"), "{err}");
2598 assert!(err.contains(&std::process::id().to_string()), "{err}");
2599 }
2600 assert!(path.exists(), "a refused listener leaves the lock alone");
2601 drop(held);
2602 assert!(!path.exists(), "dropping the lock removes the file");
2603
2604 std::fs::write(
2607 &path,
2608 serde_json::to_string(&LockFile {
2609 pid: std::process::id(),
2610 started: "x".into(),
2611 })
2612 .unwrap(),
2613 )
2614 .unwrap();
2615 let taken = Lock::at(path.clone(), 1000).expect("a left file is taken over");
2616 drop(taken);
2617 let _ = std::fs::remove_dir_all(&dir);
2618 }
2619}