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, ReviewRequested, 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 {
515 watched.requested_noticed = false;
516 } else if !watched.requested_noticed {
517 watched.requested_noticed = true;
518 out.push(Event {
519 number: row.number,
520 kind: ItemKind::Pr,
521 trigger: Trigger::ReviewRequested,
522 who: None,
523 head: None,
524 commits: None,
525 from_thread: false,
526 });
527 }
528 let assigned = row
529 .assignees
530 .iter()
531 .any(|l| crate::comments::same_login(&l.login, viewer));
532 if assigned && !watched.assigned_noticed {
533 watched.assigned_noticed = true;
534 out.push(Event {
535 number: row.number,
536 kind: ItemKind::Pr,
537 trigger: Trigger::Assigned,
538 who: None,
539 head: None,
540 commits: None,
541 from_thread: false,
542 });
543 }
544 }
545 if repo_mode {
546 let gone: Vec<i64> = state
549 .items
550 .iter()
551 .filter(|(n, w)| w.kind == ItemKind::Pr && !present.contains(n))
552 .map(|(n, _)| *n)
553 .collect();
554 for number in gone {
555 out.push(Event {
556 number,
557 kind: ItemKind::Pr,
558 trigger: Trigger::Closed,
559 who: None,
560 head: None,
561 commits: None,
562 from_thread: false,
563 });
564 }
565 }
566 out
567}
568
569pub fn cap_first_sweep(events: Vec<Event>, limit: usize, first: bool) -> (Vec<Event>, usize) {
572 if !first {
573 return (events, 0);
574 }
575 let mut pushes: Vec<&Event> = events
576 .iter()
577 .filter(|e| e.trigger == Trigger::Pushed)
578 .collect();
579 pushes.sort_by_key(|e| e.number);
580 let allowed: BTreeSet<i64> = pushes.iter().take(limit).map(|e| e.number).collect();
581 let held = pushes.len().saturating_sub(allowed.len());
582 let kept = events
583 .into_iter()
584 .filter(|e| e.trigger != Trigger::Pushed || allowed.contains(&e.number))
585 .collect();
586 (kept, held)
587}
588
589pub fn merge(pending: &mut BTreeMap<i64, Pending>, event: &Event, now: u64) {
591 let p = pending.entry(event.number).or_insert_with(|| Pending {
592 kind: event.kind,
593 first_seen: now,
594 last_seen: now,
595 triggers: BTreeSet::new(),
596 who: Vec::new(),
597 head_from: None,
598 head_to: None,
599 });
600 p.last_seen = now;
601 p.triggers.insert(event.trigger);
602 if let Some(who) = &event.who {
603 if !p.who.contains(who) {
604 p.who.push(who.clone());
605 }
606 }
607 if let Some((from, to)) = &event.head {
608 if p.head_from.is_none() {
609 p.head_from = Some(from.clone());
610 }
611 p.head_to = Some(to.clone());
612 }
613}
614
615pub fn due(p: &Pending, now: u64, cfg: &ListenCfg, once: bool) -> bool {
618 once || now.saturating_sub(p.last_seen) >= cfg.settle
619 || now.saturating_sub(p.first_seen) >= cfg.max_wait
620}
621
622pub fn next_due(
626 state: &ListenState,
627 cfg: &ListenCfg,
628 acts_on: &dyn Fn(i64) -> bool,
629) -> Option<u64> {
630 state
631 .pending
632 .iter()
633 .filter(|(n, _)| acts_on(**n))
634 .map(|(n, p)| {
635 let settled = (p.last_seen + cfg.settle).min(p.first_seen + cfg.max_wait);
636 state
637 .items
638 .get(n)
639 .map_or(settled, |w| settled.max(w.retry_after))
640 })
641 .min()
642}
643
644#[derive(Debug, Clone, PartialEq, Eq)]
646pub enum Action {
647 Recheck(i64),
649 Review,
651 Checkin { reply_only: bool },
653 Capped(i64),
656 BelongsToResume,
658 Drop,
660 Ignore(&'static str),
662}
663
664impl Action {
665 pub fn phrase(&self, number: i64) -> String {
667 match self {
668 Action::Recheck(pr) if *pr == number => "take a second look".to_string(),
669 Action::Recheck(pr) => format!("take a second look at #{pr}"),
670 Action::Review => "review it".to_string(),
671 Action::Checkin { reply_only: false } => "check in on it".to_string(),
672 Action::Checkin { reply_only: true } => "answer the comments on it".to_string(),
673 Action::Capped(_) | Action::BelongsToResume => "leave it".to_string(),
674 Action::Drop => "drop it".to_string(),
675 Action::Ignore(_) => "ignore it".to_string(),
676 }
677 }
678
679 fn runs(&self) -> bool {
680 matches!(
681 self,
682 Action::Recheck(_) | Action::Review | Action::Checkin { .. }
683 )
684 }
685}
686
687#[derive(Debug, Clone, Default, PartialEq, Eq)]
690pub struct Facts {
691 pub number: i64,
692 pub named: bool,
693 pub open: bool,
694 pub author_is_viewer: bool,
697 pub has_posted_record: bool,
699 pub recheck_capped: bool,
700 pub has_custody_state: bool,
702 pub below_floor: bool,
703 pub linked_pr_with_record: Option<i64>,
705 pub review_pending: bool,
708 pub request_answered: bool,
711}
712
713pub fn review_pending(
717 requests: &[Login],
718 viewer: &str,
719 in_team: &dyn Fn(&str) -> Option<bool>,
720) -> bool {
721 requests
722 .iter()
723 .any(|r| crate::comments::same_login(&r.login, viewer))
724 || requests
725 .iter()
726 .filter(|r| r.login.is_empty())
727 .any(|team| in_team(&team.slug).unwrap_or(true))
728}
729
730pub fn request_answered(
739 made: &[ReviewRequested],
740 viewer: &str,
741 in_team: &dyn Fn(&str) -> Option<bool>,
742 reviewed_at: &str,
743) -> bool {
744 let Some(reviewed) = clock::parse_rfc3339(reviewed_at) else {
745 return false;
746 };
747 made.iter()
748 .filter(|r| review_pending(std::slice::from_ref(&r.who), viewer, in_team))
749 .filter_map(|r| clock::parse_rfc3339(&r.at))
750 .max()
751 .is_some_and(|newest| newest <= reviewed)
752}
753
754pub fn settle(p: &mut Pending, f: &Facts) -> bool {
761 let stale = p.kind == ItemKind::Pr
762 && (!f.review_pending || f.request_answered)
763 && p.triggers.remove(&Trigger::ReviewRequested);
764 if stale {
765 p.triggers.insert(Trigger::Replied);
766 }
767 stale
768}
769
770pub fn plan(p: &Pending, f: &Facts, cfg: &ListenCfg) -> Action {
772 use Trigger::*;
773 if !f.open || p.triggers.contains(&Closed) {
774 return Action::Drop;
775 }
776 if f.below_floor && !f.named {
777 return Action::Ignore("it is below min_number and was not named");
778 }
779 match p.kind {
780 ItemKind::Issue => {
781 let asked = p.triggers.contains(&Mentioned)
782 || p.triggers.contains(&Assigned)
783 || (f.named && p.triggers.contains(&Replied));
784 if !asked {
785 return Action::Ignore("nothing on the issue asked for spar");
786 }
787 match f.linked_pr_with_record {
790 None => Action::Ignore("an issue with no reviewed pull request"),
791 Some(_) if f.author_is_viewer => Action::Ignore("its pull request is your own"),
792 Some(pr) if f.recheck_capped => Action::Capped(pr),
793 Some(pr) => Action::Recheck(pr),
794 }
795 }
796 ItemKind::Pr => {
797 if f.author_is_viewer {
802 let spoken_to = p
803 .triggers
804 .iter()
805 .any(|t| matches!(t, Replied | Mentioned | ReviewRequested));
806 if !spoken_to {
807 return Action::Ignore("a push to your own pull request");
808 }
809 return match cfg.on_own_pr {
810 OwnPr::Checkin => Action::Checkin { reply_only: false },
811 OwnPr::Reply => Action::Checkin { reply_only: true },
812 OwnPr::Ignore => Action::Ignore("on_own_pr is ignore"),
813 };
814 }
815 if f.has_posted_record {
816 return if f.recheck_capped {
817 Action::Capped(f.number)
818 } else {
819 Action::Recheck(f.number)
820 };
821 }
822 if f.has_custody_state {
823 return Action::BelongsToResume;
824 }
825 if f.named {
826 return Action::Review;
827 }
828 if p.triggers.contains(&ReviewRequested) {
829 return match cfg.on_review_requested {
830 ReviewOrIgnore::Review => Action::Review,
831 ReviewOrIgnore::Ignore => Action::Ignore("on_review_requested is ignore"),
832 };
833 }
834 if p.triggers.contains(&Assigned) {
835 return match cfg.on_assigned {
836 ReviewOrIgnore::Review => Action::Review,
837 ReviewOrIgnore::Ignore => Action::Ignore("on_assigned is ignore"),
838 };
839 }
840 if p.triggers.contains(&Mentioned) {
841 return Action::Review;
842 }
843 Action::Ignore("a push or a comment on a pull request spar has not reviewed")
844 }
845 }
846}
847
848pub fn prune_seen(state: &mut ListenState, now: u64) {
850 state.seen.retain(|_, at| {
851 clock::parse_rfc3339(at).is_none_or(|t| now.saturating_sub(t) < SEEN_FOR_SECS)
852 });
853}
854
855pub fn backoff_secs(failures: u32) -> u64 {
857 (60u64 << failures.saturating_sub(1).min(10)).min(900)
858}
859
860pub fn dispatch_backoff_secs(failures: u32) -> u64 {
862 (900u64 << failures.saturating_sub(1).min(10)).min(6 * 3600)
863}
864
865pub fn retry_wait(moved: bool, failures: u32, cfg: &ListenCfg) -> u64 {
869 if moved {
870 cfg.settle.max(FLOOR_SECS)
871 } else {
872 dispatch_backoff_secs(failures)
873 }
874}
875
876#[derive(Debug, Default)]
878struct Backoff {
879 failures: u32,
880 said: bool,
881}
882
883impl Backoff {
884 fn failed(&mut self, what: &str, why: &str) -> u64 {
885 self.failures += 1;
886 let wait = backoff_secs(self.failures);
887 let line = format!(
888 "could not {what}: {why}. Trying again in {}.",
889 spend::clock(Duration::from_secs(wait))
890 );
891 if self.said {
892 logdim!("{line}");
893 } else {
894 logwarn!("{line}");
895 self.said = true;
896 }
897 wait
898 }
899
900 fn recovered(&mut self) {
901 if self.said {
902 log!("gh is answering again");
903 }
904 *self = Self::default();
905 }
906}
907
908#[derive(Debug, Serialize, Deserialize)]
915struct LockFile {
916 pid: u32,
917 started: String,
918}
919
920#[derive(Debug)]
929struct Lock {
930 path: PathBuf,
931 _file: File,
933}
934
935impl Lock {
936 fn acquire(repo: &Repo, now: u64) -> Result<Lock> {
937 Lock::at(repo.listen_lock_path(), now)
938 }
939
940 fn at(path: PathBuf, now: u64) -> Result<Lock> {
941 if let Some(parent) = path.parent() {
942 std::fs::create_dir_all(parent)?;
943 }
944 for _ in 0..3 {
949 let Some(mut file) = open_locked(&path)? else {
950 let pid = std::fs::read_to_string(&path)
951 .ok()
952 .and_then(|text| serde_json::from_str::<LockFile>(&text).ok())
953 .map(|h| h.pid.to_string())
954 .unwrap_or_else(|| "unknown".into());
955 bail!(
956 "another `spar listen` (pid {pid}) is already watching this checkout. \
957 Stop it first.{}",
958 if cfg!(unix) {
959 String::new()
960 } else {
961 format!(" If that process is gone, delete {}.", path.display())
962 }
963 );
964 };
965 if !same_file(&file, &path) {
966 continue;
967 }
968 file.set_len(0)?;
969 serde_json::to_writer_pretty(
970 &mut file,
971 &LockFile {
972 pid: std::process::id(),
973 started: clock::rfc3339(now),
974 },
975 )?;
976 return Ok(Lock { path, _file: file });
977 }
978 bail!("could not take the listen lock at {}", path.display())
979 }
980}
981
982impl Drop for Lock {
983 fn drop(&mut self) {
984 let _ = std::fs::remove_file(&self.path);
987 }
988}
989
990#[cfg(unix)]
992fn open_locked(path: &Path) -> Result<Option<File>> {
993 use std::os::unix::io::AsRawFd;
994 let file = std::fs::OpenOptions::new()
995 .read(true)
996 .write(true)
997 .create(true)
998 .truncate(false)
999 .open(path)?;
1000 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
1001 return Ok(Some(file));
1002 }
1003 let e = std::io::Error::last_os_error();
1004 if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
1005 Ok(None)
1006 } else {
1007 Err(e.into())
1008 }
1009}
1010
1011#[cfg(not(unix))]
1014fn open_locked(path: &Path) -> Result<Option<File>> {
1015 match std::fs::OpenOptions::new()
1016 .write(true)
1017 .create_new(true)
1018 .open(path)
1019 {
1020 Ok(file) => Ok(Some(file)),
1021 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
1022 Err(e) => Err(e.into()),
1023 }
1024}
1025
1026#[cfg(unix)]
1027fn same_file(file: &File, path: &Path) -> bool {
1028 use std::os::unix::fs::MetadataExt;
1029 match (file.metadata(), std::fs::metadata(path)) {
1030 (Ok(open), Ok(named)) => open.dev() == named.dev() && open.ino() == named.ino(),
1031 _ => false,
1032 }
1033}
1034
1035#[cfg(not(unix))]
1036fn same_file(_file: &File, _path: &Path) -> bool {
1037 true
1038}
1039
1040enum Polled {
1045 Nothing(u64),
1047 Threads(Vec<Thread>, u64),
1048 RateLimited(u64),
1050 Failed(String),
1051}
1052
1053fn poll(repo: &Repo, cfg: &ListenCfg, state: &mut ListenState, now: u64) -> Polled {
1054 let endpoint = format!(
1055 "repos/{{owner}}/{{repo}}/notifications?participating=true&all=true&per_page=100&since={}",
1056 state.watermark
1057 );
1058 let last_modified = state.last_modified.clone();
1059 let headers: Vec<(&str, &str)> = last_modified
1060 .as_deref()
1061 .map(|lm| vec![("If-Modified-Since", lm)])
1062 .unwrap_or_default();
1063 let resp = match repo.gh_api_raw(&endpoint, &headers) {
1064 Ok(resp) => resp,
1065 Err(e) => return Polled::Failed(e.last_line().to_string()),
1066 };
1067 let interval = poll_interval(cfg, resp.headers.get("x-poll-interval").map(String::as_str));
1068 match resp.status {
1069 304 => Polled::Nothing(interval),
1070 200 => match every_page(repo, &resp, &state.watermark, now) {
1071 Ok(threads) => {
1072 state.last_modified = resp
1075 .headers
1076 .get("last-modified")
1077 .or_else(|| resp.headers.get("date"))
1078 .cloned();
1079 Polled::Threads(threads, interval)
1080 }
1081 Err(failed) => failed,
1082 },
1083 401 => Polled::Failed("gh is not authenticated; run `gh auth status`".into()),
1084 _ => refused(&resp, now),
1085 }
1086}
1087
1088fn refused(resp: &ApiResponse, now: u64) -> Polled {
1090 match rate_limit_wait(resp, now) {
1091 Some(wait) => Polled::RateLimited(wait),
1092 None => Polled::Failed(status_message(resp)),
1093 }
1094}
1095
1096fn every_page(
1103 repo: &Repo,
1104 first: &ApiResponse,
1105 watermark: &str,
1106 now: u64,
1107) -> std::result::Result<Vec<Thread>, Polled> {
1108 let unexpected =
1109 |e: serde_json::Error| Polled::Failed(format!("unexpected notification data: {e}"));
1110 let mut threads: Vec<Thread> = serde_json::from_str(&first.body).map_err(unexpected)?;
1111 let mut next = next_link(&first.headers);
1112 let mut pages = 1;
1113 while let Some(url) = next.take() {
1114 if pages >= MAX_PAGES {
1115 return Err(Polled::Failed(format!(
1116 "more than {MAX_PAGES} pages of notifications since {watermark}. Run once with \
1117 --since, like --since 1d, to start from later and skip the rest"
1118 )));
1119 }
1120 let more = repo
1121 .gh_api_raw(&url, &[])
1122 .map_err(|e| Polled::Failed(e.last_line().to_string()))?;
1123 if more.status != 200 {
1124 return Err(refused(&more, now));
1125 }
1126 threads.extend(serde_json::from_str::<Vec<Thread>>(&more.body).map_err(unexpected)?);
1127 next = next_link(&more.headers);
1128 pages += 1;
1129 }
1130 Ok(threads)
1131}
1132
1133#[derive(Default)]
1135struct Swept {
1136 rows: Vec<PrSweepRow>,
1137 closed_issues: Vec<i64>,
1139}
1140
1141fn sweep_rows(
1144 repo: &Repo,
1145 cfg: &Config,
1146 repo_mode: bool,
1147 named_prs: &[i64],
1148 named_issues: &[i64],
1149 state: &ListenState,
1150) -> Result<Swept> {
1151 if repo_mode {
1152 return Ok(Swept {
1153 rows: repo.sweep_open_prs()?,
1154 closed_issues: Vec::new(),
1155 });
1156 }
1157 let watched = |n: &&i64| state.items.contains_key(*n);
1158 let mut swept = Swept::default();
1159 for number in named_prs.iter().filter(watched) {
1160 swept.rows.push(repo.sweep_pr(*number)?);
1161 }
1162 for issue in named_issues.iter().filter(watched) {
1163 if !repo.read_issue(*issue)?.state.eq_ignore_ascii_case("OPEN") {
1167 swept.closed_issues.push(*issue);
1168 continue;
1169 }
1170 if let Ok(Some(pr)) = repo.try_open_pr_for_issue(*issue, cfg.base_branch()) {
1173 if repo.read_review_record(pr.number).is_some_and(|r| r.posted) {
1174 swept.rows.push(repo.sweep_pr(pr.number)?);
1175 }
1176 }
1177 }
1178 Ok(swept)
1179}
1180
1181fn facts(repo: &Repo, cfg: &Config, number: i64, kind: ItemKind, named: bool) -> Result<Facts> {
1182 let viewer = repo.viewer_login()?.to_string();
1183 let below_floor = number < cfg.loop_cfg.min_number;
1184 match kind {
1185 ItemKind::Pr => {
1186 let row = repo.sweep_pr(number)?;
1187 let record = repo.read_review_record(number);
1188 let posted = record.as_ref().is_some_and(|r| r.posted);
1189 let in_team = |team: &str| repo.team_has_member(team, &viewer);
1190 let pending = review_pending(&row.review_requests, &viewer, &in_team);
1191 let answered = pending
1194 && record.as_ref().filter(|r| r.posted).is_some_and(|r| {
1195 repo.review_requests_made(number).is_some_and(|made| {
1196 request_answered(&made, &viewer, &in_team, &r.reviewed_at)
1197 })
1198 });
1199 Ok(Facts {
1200 number,
1201 named,
1202 open: row.state.eq_ignore_ascii_case("OPEN"),
1203 author_is_viewer: row
1204 .author
1205 .as_ref()
1206 .is_some_and(|a| crate::comments::same_login(&a.login, &viewer)),
1207 has_posted_record: posted,
1208 recheck_capped: posted
1209 && record
1210 .as_ref()
1211 .is_some_and(|r| r.rechecks >= cfg.listen.max_rechecks),
1212 has_custody_state: repo.state_path(number).exists(),
1213 below_floor,
1214 linked_pr_with_record: None,
1215 review_pending: pending,
1216 request_answered: answered,
1217 })
1218 }
1219 ItemKind::Issue => {
1220 let issue = repo.read_issue(number)?;
1221 let linked = repo
1222 .try_open_pr_for_issue(number, cfg.base_branch())
1223 .ok()
1224 .flatten()
1225 .and_then(|pr| {
1226 repo.read_review_record(pr.number)
1227 .filter(|r| r.posted)
1228 .map(|r| (pr.number, r))
1229 });
1230 let (author_is_viewer, recheck_capped) = match &linked {
1232 Some((pr, record)) => {
1233 let row = repo.sweep_pr(*pr)?;
1234 (
1235 row.author
1236 .as_ref()
1237 .is_some_and(|a| crate::comments::same_login(&a.login, &viewer)),
1238 record.rechecks >= cfg.listen.max_rechecks,
1239 )
1240 }
1241 None => (false, false),
1242 };
1243 Ok(Facts {
1244 number,
1245 named,
1246 open: issue.state.eq_ignore_ascii_case("OPEN"),
1247 author_is_viewer,
1248 has_posted_record: false,
1249 recheck_capped,
1250 has_custody_state: false,
1251 below_floor,
1252 linked_pr_with_record: linked.map(|(pr, _)| pr),
1253 review_pending: false,
1254 request_answered: false,
1255 })
1256 }
1257 }
1258}
1259
1260fn dispatch(
1261 agents: &[Agent],
1262 cfg: &Config,
1263 repo: &Repo,
1264 action: &Action,
1265 number: i64,
1266 p: &Pending,
1267) -> IssueRun {
1268 match action {
1269 Action::Recheck(pr) => review_only::recheck_pr(
1270 agents,
1271 cfg,
1272 repo,
1273 *pr,
1274 RecheckMode {
1275 dry_run: false,
1276 requested: p.triggers.contains(&Trigger::ReviewRequested),
1277 verdict_on_fallback: cfg.listen.verdicts,
1278 },
1279 ),
1280 Action::Review => review_only::review_pr(
1281 agents,
1282 cfg,
1283 repo,
1284 number,
1285 ReviewOpts {
1286 dry_run: false,
1287 verdict: cfg.listen.verdicts,
1288 },
1289 ),
1290 Action::Checkin { reply_only } => checkin::checkin_pr(
1291 agents,
1292 cfg,
1293 repo,
1294 number,
1295 &checkin::Mode {
1296 dry_run: false,
1297 reply_only: *reply_only,
1298 trust: cfg.loop_cfg.checkin_trust,
1299 again: false,
1300 resolve: cfg.loop_cfg.checkin_resolve,
1301 posts: checkin::posts(cfg),
1302 },
1303 ),
1304 other => {
1305 let mut run = IssueRun::new(number, format!("#{number}"));
1306 run.status = Status::Error;
1307 run.notes
1308 .push(format!("nothing to run: {}", other.phrase(number)));
1309 run
1310 }
1311 }
1312}
1313
1314fn describe(repo: &Repo, event: &Event) -> String {
1316 let n = event.number;
1317 match event.trigger {
1318 Trigger::Pushed => {
1319 let (from, to) = event.head.clone().unwrap_or_default();
1320 let short = |s: &str| s.chars().take(7).collect::<String>();
1321 let commits = repo
1322 .commits_between(&from, &to)
1323 .map(|c| format!(", {c} new commit{} since the review", plural(c as usize)))
1324 .unwrap_or_default();
1325 format!(
1326 "#{n}: head moved {} to {}{commits}",
1327 short(&from),
1328 short(&to)
1329 )
1330 }
1331 Trigger::Replied => match &event.who {
1332 Some(who) => format!("#{n}: {who} commented"),
1333 None => format!("#{n}: somebody commented"),
1334 },
1335 Trigger::Mentioned => match &event.who {
1336 Some(who) => format!("#{n}: mentioned by {who}"),
1337 None => format!("#{n}: mentioned"),
1338 },
1339 Trigger::ReviewRequested if event.from_thread => {
1342 format!("#{n}: news on a thread that asked for your review")
1343 }
1344 Trigger::ReviewRequested => format!("#{n}: your review was requested"),
1345 Trigger::Assigned => format!("#{n}: assigned to you"),
1346 Trigger::Closed => format!("#{n}: closed, no longer watched"),
1347 }
1348}
1349
1350fn plural(n: usize) -> &'static str {
1351 if n == 1 {
1352 ""
1353 } else {
1354 "s"
1355 }
1356}
1357
1358enum Ending {
1363 Interrupted,
1364 Once { failed: bool },
1365 Done,
1366}
1367
1368pub fn run(agents: &[Agent], cfg: &Config, repo: &Repo, opts: Opts) -> Result<i32> {
1370 proc::stop_on_signal()?;
1371 if !cfg!(unix) {
1372 logdim!(
1373 "Ctrl-C ends spar at once on this platform; an agent call in flight is not stopped"
1374 );
1375 }
1376 let now = clock::now_secs();
1377 let lock = Lock::acquire(repo, now)?;
1378 let viewer = repo.viewer_login()?.to_string();
1379 let name = repo
1380 .repo_name()
1381 .unwrap_or_else(|| repo.root().display().to_string());
1382
1383 let named: BTreeSet<i64> = opts.items.iter().copied().collect();
1386 let mut named_prs = Vec::new();
1387 let mut named_issues = Vec::new();
1388 for number in &named {
1389 match repo.item_kind(*number)? {
1390 ItemKind::Pr => named_prs.push(*number),
1391 ItemKind::Issue => named_issues.push(*number),
1392 }
1393 }
1394 let repo_mode = named.is_empty();
1395 let mut watching: BTreeSet<i64> = named.clone();
1400 let save = |state: &ListenState| {
1403 if !opts.dry_run {
1404 write_state(repo, state);
1405 }
1406 };
1407
1408 let mut state = read_state(repo);
1409 if state.watermark.is_empty() || opts.since.is_some() {
1410 let back = opts.since.map(|d| d.as_secs()).unwrap_or(0);
1411 state.watermark = clock::rfc3339(now.saturating_sub(back));
1412 state.last_modified = None;
1413 log!("watching from {}", state.watermark);
1414 }
1415 for number in &named_prs {
1416 state.watched(*number, ItemKind::Pr);
1417 }
1418 for number in &named_issues {
1419 state.watched(*number, ItemKind::Issue);
1420 }
1421 log!(
1422 "listening to {name}{}: polling every {}s, sweeping {} every {}s. Ctrl-C to stop.",
1423 if repo_mode {
1424 String::new()
1425 } else {
1426 format!(
1427 " for {}",
1428 named
1429 .iter()
1430 .map(|n| format!("#{n}"))
1431 .collect::<Vec<_>>()
1432 .join(", ")
1433 )
1434 },
1435 cfg.listen.interval.max(FLOOR_SECS),
1436 if repo_mode {
1437 "open pull requests"
1438 } else {
1439 "the named items"
1440 },
1441 cfg.listen.sweep
1442 );
1443
1444 let started = now;
1445 let mut next_poll = now;
1446 let mut next_sweep = now;
1447 let mut backoff = Backoff::default();
1448 let mut events_seen = 0usize;
1449 let mut dispatches = 0usize;
1450 let mut tick_failed = false;
1451 let mut last_prune = now;
1452
1453 let ending = loop {
1454 if proc::interrupted() {
1455 break Ending::Interrupted;
1456 }
1457 let now = clock::now_secs();
1458 let mut events: Vec<Event> = Vec::new();
1459
1460 if now >= next_poll {
1461 match poll(repo, &cfg.listen, &mut state, now) {
1462 Polled::Nothing(interval) => {
1463 backoff.recovered();
1464 next_poll = now + interval;
1465 }
1466 Polled::Threads(threads, interval) => {
1467 backoff.recovered();
1468 events.extend(events_from_threads(&threads, &mut state));
1469 next_poll = now + interval;
1470 }
1471 Polled::RateLimited(wait) => {
1472 logwarn!(
1473 "rate limited by GitHub, waiting {} until it resets",
1474 spend::clock(Duration::from_secs(wait))
1475 );
1476 next_poll = now + wait;
1477 tick_failed = true;
1478 }
1479 Polled::Failed(why) => {
1480 next_poll = now + backoff.failed("read notifications", &why);
1481 tick_failed = true;
1482 }
1483 }
1484 }
1485
1486 if now >= next_sweep {
1487 match sweep_rows(repo, cfg, repo_mode, &named_prs, &named_issues, &state) {
1488 Ok(Swept {
1489 rows,
1490 closed_issues,
1491 }) => {
1492 backoff.recovered();
1493 if !repo_mode {
1494 watching.extend(rows.iter().map(|r| r.number));
1495 }
1496 events.extend(closed_issues.into_iter().map(|number| Event {
1497 number,
1498 kind: ItemKind::Issue,
1499 trigger: Trigger::Closed,
1500 who: None,
1501 head: None,
1502 commits: None,
1503 from_thread: false,
1504 }));
1505 let record_head = |n: i64| {
1506 repo.read_review_record(n)
1507 .filter(|r| r.posted)
1508 .map(|r| r.head)
1509 };
1510 let swept = events_from_sweep(
1511 &rows,
1512 &viewer,
1513 &mut state,
1514 &named,
1515 cfg.loop_cfg.min_number,
1516 &record_head,
1517 repo_mode,
1518 );
1519 let (kept, held) = cap_first_sweep(swept, opts.limit, !state.swept_once);
1520 if held > 0 {
1521 log!(
1522 "{held} more reviewed pull request{} moved since their review; raise \
1523 --limit or run `spar review` on them",
1524 plural(held)
1525 );
1526 }
1527 events.extend(kept);
1528 state.swept_once = true;
1529 next_sweep = now + cfg.listen.sweep;
1530 }
1531 Err(e) => {
1532 next_sweep = now + backoff.failed("sweep the pull requests", e.last_line());
1533 tick_failed = true;
1534 }
1535 }
1536 }
1537
1538 for event in &events {
1542 if !repo_mode && !watching.contains(&event.number) {
1543 continue;
1544 }
1545 if event.trigger != Trigger::Closed && event.who.is_none() {
1546 }
1550 events_seen += 1;
1551 log!("{}", describe(repo, event));
1552 if event.trigger == Trigger::Closed {
1553 state.items.remove(&event.number);
1554 state.pending.remove(&event.number);
1555 continue;
1556 }
1557 state.note(event, now);
1558 }
1559 save(&state);
1560
1561 let due_now: Vec<i64> = state
1562 .pending
1563 .iter()
1564 .filter(|(n, p)| {
1565 (repo_mode || watching.contains(n))
1566 && due(p, now, &cfg.listen, opts.once)
1567 && state.items.get(n).is_none_or(|w| w.retry_after <= now)
1568 })
1569 .map(|(n, _)| *n)
1570 .collect();
1571 for number in due_now {
1572 let Some(mut p) = state.pending.get(&number).cloned() else {
1573 continue;
1574 };
1575 let named_item = named.contains(&number);
1576 let f = match facts(repo, cfg, number, p.kind, named_item) {
1577 Ok(f) => f,
1578 Err(e) => {
1579 let w = state.watched(number, p.kind);
1580 w.failures += 1;
1581 w.retry_after = now + dispatch_backoff_secs(w.failures);
1582 logwarn!("#{number}: could not read it: {}", e.last_line());
1583 tick_failed = true;
1584 save(&state);
1585 continue;
1586 }
1587 };
1588 if settle(&mut p, &f) {
1589 if f.request_answered {
1590 log!(
1591 "#{number}: the review asked of you is the one on record, so that was \
1592 only an update"
1593 );
1594 } else {
1595 log!("#{number}: no review is asked of you now, so that was only an update");
1596 }
1597 }
1598 if p.triggers.contains(&Trigger::ReviewRequested) || f.request_answered {
1599 state.watched(number, p.kind).requested_noticed = true;
1604 }
1605 let action = plan(&p, &f, &cfg.listen);
1606 match &action {
1607 Action::Ignore(why) => {
1608 logdim!("#{number}: ignoring {why}");
1609 state.pending.remove(&number);
1610 }
1611 Action::Drop => {
1612 log!("#{number}: closed, no longer watched");
1613 state.items.remove(&number);
1614 state.pending.remove(&number);
1615 }
1616 Action::Capped(pr) => {
1617 let w = state.watched(number, p.kind);
1618 if !w.said_capped {
1619 let what = if *pr == number {
1620 "rechecked".to_string()
1621 } else {
1622 format!("its pull request #{pr} was rechecked")
1623 };
1624 log!(
1625 "#{number}: {what} {} time{} already; run `spar review {pr}` to look \
1626 again",
1627 cfg.listen.max_rechecks,
1628 plural(cfg.listen.max_rechecks as usize)
1629 );
1630 w.said_capped = true;
1631 }
1632 state.pending.remove(&number);
1633 }
1634 Action::BelongsToResume => {
1635 let w = state.watched(number, p.kind);
1636 if !w.said_resume {
1637 log!("#{number}: in custody, `spar resume {number}` continues it");
1638 w.said_resume = true;
1639 }
1640 state.pending.remove(&number);
1641 }
1642 action if action.runs() => {
1643 if opts.dry_run {
1644 log!(
1645 "#{number}: would {} after {}, dry run so nothing runs",
1646 action.phrase(number),
1647 p.because()
1648 );
1649 state.pending.remove(&number);
1650 } else {
1651 log!(
1652 "#{number}: about to {} after {}",
1653 action.phrase(number),
1654 p.because()
1655 );
1656 let acted_on = match action {
1657 Action::Recheck(pr) => *pr,
1658 _ => number,
1659 };
1660 let record_before = repo.read_review_record(acted_on);
1661 let failed_before = repo.write_summary().failed;
1662 let run = dispatch(agents, cfg, repo, action, number, &p);
1663 dispatches += 1;
1664 let spent = spend::drain();
1665 if let Err(e) = repo.record_spend(&spent) {
1666 logdim!("could not record what this run spent: {e}");
1667 }
1668 crate::cli::print_outcome(&run, &spent);
1669 if proc::interrupted() {
1670 log!("interrupted while working on #{number}; its worktree was kept");
1671 save(&state);
1672 break;
1673 }
1674 let record_after = repo.read_review_record(acted_on);
1680 if record_after != record_before {
1681 if let Some(record) = record_after {
1682 state.watched(acted_on, ItemKind::Pr).head = Some(record.head);
1683 }
1684 }
1685 let unposted = repo.write_summary().failed > failed_before;
1689 let w = state.watched(number, p.kind);
1690 let moved = run.status == Status::Moved && !unposted;
1694 if run.status == Status::Error || moved || unposted {
1695 w.failures += 1;
1696 let wait = retry_wait(moved, w.failures, &cfg.listen);
1697 w.retry_after = clock::now_secs() + wait;
1700 let gave_up = w.failures >= MAX_FAILURES;
1701 if gave_up {
1702 logwarn!("#{number}: giving up on it until it changes again");
1703 state.give_up(number, p.kind);
1704 } else {
1705 if moved {
1706 log!(
1707 "#{number}: its head moved while spar read it, so it runs \
1708 again on the new head in {}",
1709 spend::clock(Duration::from_secs(wait))
1710 );
1711 } else if unposted {
1712 logwarn!(
1713 "#{number}: not everything it wrote reached GitHub, so it \
1714 runs again in {}",
1715 spend::clock(Duration::from_secs(wait))
1716 );
1717 }
1718 if let Some(p) = state.pending.get_mut(&number) {
1719 p.first_seen = now;
1720 }
1721 }
1722 if !moved || gave_up {
1725 tick_failed = true;
1726 }
1727 } else {
1728 w.failures = 0;
1729 w.retry_after = 0;
1730 w.last_dispatched = Some(now);
1731 state.pending.remove(&number);
1732 }
1733 }
1734 }
1735 _ => {}
1736 }
1737 save(&state);
1738 }
1739 if proc::interrupted() {
1740 break Ending::Interrupted;
1741 }
1742
1743 if opts.once {
1744 break Ending::Once {
1745 failed: tick_failed,
1746 };
1747 }
1748 if !repo_mode && named.iter().all(|n| !state.items.contains_key(n)) {
1749 log!("every named item is closed, nothing left to watch");
1750 break Ending::Done;
1751 }
1752 if now.saturating_sub(last_prune) >= 3600 {
1753 prune_seen(&mut state, now);
1754 last_prune = now;
1755 save(&state);
1756 }
1757
1758 let acts_on = |n: i64| repo_mode || watching.contains(&n);
1759 let next = [next_poll, next_sweep]
1760 .into_iter()
1761 .chain(next_due(&state, &cfg.listen, &acts_on))
1762 .min()
1763 .unwrap_or(now + FLOOR_SECS);
1764 let wait = next.saturating_sub(clock::now_secs()).clamp(1, 3600);
1765 if proc::sleep_unless_interrupted(Duration::from_secs(wait)) {
1766 break Ending::Interrupted;
1767 }
1768 };
1769
1770 save(&state);
1771 drop(lock);
1772 log!(
1773 "listened for {}: {events_seen} event{}, {dispatches} dispatch{}",
1774 spend::clock(Duration::from_secs(
1775 clock::now_secs().saturating_sub(started)
1776 )),
1777 plural(events_seen),
1778 if dispatches == 1 { "" } else { "es" }
1779 );
1780 let writes = crate::cli::report_writes(repo);
1781 Ok(match ending {
1782 Ending::Interrupted => 130.max(writes),
1783 Ending::Once { failed: true } => 1.max(writes),
1784 Ending::Once { failed: false } | Ending::Done => writes,
1785 })
1786}
1787
1788fn read_state(repo: &Repo) -> ListenState {
1789 let path = repo.listen_state_path();
1790 match std::fs::read_to_string(&path) {
1791 Ok(text) => match serde_json::from_str::<ListenState>(&text) {
1792 Ok(state) => state,
1793 Err(e) => {
1794 logdim!("could not read {}: {e}. Starting fresh.", path.display());
1795 ListenState::default()
1796 }
1797 },
1798 Err(_) => ListenState::default(),
1799 }
1800}
1801
1802fn write_state(repo: &Repo, state: &ListenState) {
1803 let mut state = state.clone();
1804 state.version = STATE_VERSION;
1805 if let Err(e) = crate::repo::write_json_atomic(&repo.listen_state_path(), &state) {
1806 logdim!("could not write the listen state: {e}");
1807 }
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812 use super::*;
1813 use crate::model::Author;
1814
1815 fn cfg() -> ListenCfg {
1816 ListenCfg::default()
1817 }
1818
1819 fn user(login: &str) -> Login {
1820 Login {
1821 login: login.into(),
1822 ..Login::default()
1823 }
1824 }
1825
1826 fn team(slug: &str) -> Login {
1827 Login {
1828 slug: slug.into(),
1829 ..Login::default()
1830 }
1831 }
1832
1833 fn thread(id: &str, reason: &str, kind: &str, number: i64, at: &str) -> Thread {
1834 Thread {
1835 id: id.into(),
1836 reason: reason.into(),
1837 updated_at: at.into(),
1838 subject: Subject {
1839 title: format!("thing {number}"),
1840 url: Some(format!(
1841 "https://api.github.com/repos/o/r/{}/{number}",
1842 if kind == "PullRequest" {
1843 "pulls"
1844 } else {
1845 "issues"
1846 }
1847 )),
1848 latest_comment_url: None,
1849 kind: kind.into(),
1850 },
1851 }
1852 }
1853
1854 fn row(number: i64, head: &str, author: &str, state: &str) -> PrSweepRow {
1855 PrSweepRow {
1856 number,
1857 title: format!("PR {number}"),
1858 head_ref_oid: head.into(),
1859 author: Some(Author {
1860 login: author.into(),
1861 }),
1862 state: state.into(),
1863 ..Default::default()
1864 }
1865 }
1866
1867 fn pending(kind: ItemKind, triggers: &[Trigger], now: u64) -> Pending {
1868 Pending {
1869 kind,
1870 first_seen: now,
1871 last_seen: now,
1872 triggers: triggers.iter().copied().collect(),
1873 who: Vec::new(),
1874 head_from: None,
1875 head_to: None,
1876 }
1877 }
1878
1879 fn open_pr() -> Facts {
1880 Facts {
1881 number: 7,
1882 open: true,
1883 ..Default::default()
1884 }
1885 }
1886
1887 fn response(status: u16, headers: &[(&str, &str)]) -> ApiResponse {
1888 ApiResponse {
1889 status,
1890 headers: headers
1891 .iter()
1892 .map(|(k, v)| (k.to_string(), v.to_string()))
1893 .collect(),
1894 body: String::new(),
1895 }
1896 }
1897
1898 #[test]
1901 fn the_poll_interval_is_the_largest_of_the_config_the_header_and_the_floor() {
1902 let mut c = cfg();
1903 assert_eq!(60, poll_interval(&c, None));
1904 assert_eq!(90, poll_interval(&c, Some("90")));
1905 assert_eq!(60, poll_interval(&c, Some("30")));
1906 assert_eq!(60, poll_interval(&c, Some("soon")));
1907 c.interval = 5;
1908 assert_eq!(15, poll_interval(&c, None));
1909 }
1910
1911 #[test]
1912 fn a_rate_limit_answer_says_how_long_to_wait_and_caps_it_at_an_hour() {
1913 let now = 1_000_000;
1914 assert_eq!(
1915 Some(120),
1916 rate_limit_wait(&response(429, &[("retry-after", "120")]), now)
1917 );
1918 assert_eq!(
1919 Some(600),
1920 rate_limit_wait(
1921 &response(
1922 403,
1923 &[
1924 ("x-ratelimit-remaining", "0"),
1925 ("x-ratelimit-reset", "1000600")
1926 ]
1927 ),
1928 now
1929 )
1930 );
1931 assert_eq!(
1932 Some(3600),
1933 rate_limit_wait(&response(403, &[("retry-after", "99999")]), now)
1934 );
1935 assert_eq!(
1936 Some(60),
1937 rate_limit_wait(&response(429, &[("retry-after", "1")]), now)
1938 );
1939 }
1940
1941 #[test]
1942 fn a_forbidden_answer_without_a_rate_limit_header_is_a_failure_not_a_wait() {
1943 assert_eq!(None, rate_limit_wait(&response(403, &[]), 0));
1944 assert_eq!(
1945 None,
1946 rate_limit_wait(&response(403, &[("x-ratelimit-remaining", "40")]), 0)
1947 );
1948 assert_eq!(
1949 None,
1950 rate_limit_wait(&response(200, &[("retry-after", "5")]), 0)
1951 );
1952 }
1953
1954 #[test]
1955 fn next_link_reads_the_next_page_and_nothing_else() {
1956 let mut headers = BTreeMap::new();
1957 headers.insert(
1958 "link".to_string(),
1959 "<https://api.github.com/x?page=2>; rel=\"next\", <https://api.github.com/x?page=9>; rel=\"last\"".to_string(),
1960 );
1961 assert_eq!(
1962 Some("https://api.github.com/x?page=2".to_string()),
1963 next_link(&headers)
1964 );
1965 headers.insert(
1966 "link".to_string(),
1967 "<https://api.github.com/x?page=1>; rel=\"prev\"".to_string(),
1968 );
1969 assert_eq!(None, next_link(&headers));
1970 assert_eq!(None, next_link(&BTreeMap::new()));
1971 }
1972
1973 #[test]
1976 fn a_thread_becomes_an_event_keyed_by_its_number_and_reason() {
1977 let mut state = ListenState::default();
1978 let threads = vec![
1979 thread(
1980 "1",
1981 "review_requested",
1982 "PullRequest",
1983 7,
1984 "2026-10-20T10:00:00Z",
1985 ),
1986 thread("2", "mention", "Issue", 42, "2026-10-20T10:01:00Z"),
1987 thread("3", "comment", "PullRequest", 8, "2026-10-20T10:02:00Z"),
1988 thread("4", "assign", "PullRequest", 9, "2026-10-20T10:03:00Z"),
1989 thread("5", "team_mention", "Issue", 43, "2026-10-20T10:04:00Z"),
1990 thread(
1991 "6",
1992 "state_change",
1993 "PullRequest",
1994 10,
1995 "2026-10-20T10:05:00Z",
1996 ),
1997 ];
1998 let events = events_from_threads(&threads, &mut state);
1999 let got: Vec<(i64, ItemKind, Trigger)> = events
2000 .iter()
2001 .map(|e| (e.number, e.kind, e.trigger))
2002 .collect();
2003 assert_eq!(
2004 vec![
2005 (7, ItemKind::Pr, Trigger::ReviewRequested),
2006 (42, ItemKind::Issue, Trigger::Mentioned),
2007 (8, ItemKind::Pr, Trigger::Replied),
2008 (9, ItemKind::Pr, Trigger::Assigned),
2009 (43, ItemKind::Issue, Trigger::Mentioned),
2010 (10, ItemKind::Pr, Trigger::Replied),
2011 ],
2012 got
2013 );
2014 }
2015
2016 #[test]
2017 fn a_thread_seen_at_the_same_update_time_is_not_an_event_twice() {
2018 let mut state = ListenState::default();
2019 let same = vec![thread(
2020 "1",
2021 "comment",
2022 "PullRequest",
2023 7,
2024 "2026-10-20T10:00:00Z",
2025 )];
2026 assert_eq!(1, events_from_threads(&same, &mut state).len());
2027 assert_eq!(0, events_from_threads(&same, &mut state).len());
2028 let moved = vec![thread(
2029 "1",
2030 "comment",
2031 "PullRequest",
2032 7,
2033 "2026-10-20T10:05:00Z",
2034 )];
2035 assert_eq!(1, events_from_threads(&moved, &mut state).len());
2036 }
2037
2038 #[test]
2039 fn a_thread_about_a_release_or_discussion_is_ignored() {
2040 let mut state = ListenState::default();
2041 let threads = vec![
2042 thread("1", "subscribed", "Release", 1, "2026-10-20T10:00:00Z"),
2043 thread("2", "mention", "Discussion", 2, "2026-10-20T10:01:00Z"),
2044 Thread {
2045 subject: Subject {
2046 url: None,
2047 kind: "PullRequest".into(),
2048 ..Default::default()
2049 },
2050 ..thread("3", "comment", "PullRequest", 3, "2026-10-20T10:02:00Z")
2051 },
2052 ];
2053 assert!(events_from_threads(&threads, &mut state).is_empty());
2054 assert_eq!(3, state.seen.len());
2057 }
2058
2059 #[test]
2060 fn the_watermark_advances_to_the_newest_update_seen() {
2061 let mut state = ListenState {
2062 watermark: "2026-10-20T09:00:00Z".into(),
2063 ..Default::default()
2064 };
2065 let threads = vec![
2066 thread("1", "comment", "PullRequest", 7, "2026-10-20T10:02:00Z"),
2067 thread("2", "comment", "PullRequest", 8, "2026-10-20T10:01:00Z"),
2068 ];
2069 events_from_threads(&threads, &mut state);
2070 assert_eq!("2026-10-20T10:02:00Z", state.watermark);
2071 }
2072
2073 fn no_record(_: i64) -> Option<String> {
2076 None
2077 }
2078
2079 #[test]
2080 fn a_sweep_notices_a_head_that_moved_and_says_it_once() {
2081 let mut state = ListenState::default();
2082 let named = BTreeSet::new();
2083 let first = events_from_sweep(
2084 &[row(7, "aaa", "alice", "OPEN")],
2085 "me",
2086 &mut state,
2087 &named,
2088 0,
2089 &no_record,
2090 true,
2091 );
2092 assert!(first.is_empty(), "{first:?}");
2093 let moved = events_from_sweep(
2094 &[row(7, "bbb", "alice", "OPEN")],
2095 "me",
2096 &mut state,
2097 &named,
2098 0,
2099 &no_record,
2100 true,
2101 );
2102 assert_eq!(1, moved.len());
2103 assert_eq!(Trigger::Pushed, moved[0].trigger);
2104 assert_eq!(Some(("aaa".to_string(), "bbb".to_string())), moved[0].head);
2105 let again = events_from_sweep(
2106 &[row(7, "bbb", "alice", "OPEN")],
2107 "me",
2108 &mut state,
2109 &named,
2110 0,
2111 &no_record,
2112 true,
2113 );
2114 assert!(again.is_empty(), "{again:?}");
2115 assert_eq!("alice", state.items[&7].author);
2116 }
2117
2118 #[test]
2119 fn the_first_sweep_treats_a_reviewed_pull_request_that_already_moved_as_due() {
2120 let mut state = ListenState::default();
2121 let record = |n: i64| (n == 7).then(|| "aaa".to_string());
2122 let events = events_from_sweep(
2123 &[row(7, "bbb", "alice", "OPEN"), row(8, "ccc", "bob", "OPEN")],
2124 "me",
2125 &mut state,
2126 &BTreeSet::new(),
2127 0,
2128 &record,
2129 true,
2130 );
2131 assert_eq!(1, events.len());
2132 assert_eq!(7, events[0].number);
2133 assert_eq!(Trigger::Pushed, events[0].trigger);
2134 }
2135
2136 #[test]
2137 fn a_sweep_notices_a_review_request_once_not_every_five_minutes() {
2138 let mut state = ListenState::default();
2139 let mut asked = row(7, "aaa", "alice", "OPEN");
2140 asked.review_requests = vec![user("ME")];
2141 asked.assignees = vec![user("me")];
2142 let rows = [asked];
2143 let first = events_from_sweep(
2144 &rows,
2145 "me",
2146 &mut state,
2147 &BTreeSet::new(),
2148 0,
2149 &no_record,
2150 true,
2151 );
2152 let triggers: BTreeSet<Trigger> = first.iter().map(|e| e.trigger).collect();
2153 assert_eq!(
2154 [Trigger::ReviewRequested, Trigger::Assigned]
2155 .into_iter()
2156 .collect::<BTreeSet<_>>(),
2157 triggers
2158 );
2159 let second = events_from_sweep(
2160 &rows,
2161 "me",
2162 &mut state,
2163 &BTreeSet::new(),
2164 0,
2165 &no_record,
2166 true,
2167 );
2168 assert!(second.is_empty(), "{second:?}");
2169 }
2170
2171 #[test]
2172 fn a_sweep_notices_a_review_requested_again_after_a_review() {
2173 let mut state = ListenState::default();
2174 let mut asked = row(7, "aaa", "alice", "OPEN");
2175 asked.review_requests = vec![user("me")];
2176 let asked = [asked];
2177 let reviewed = [row(7, "aaa", "alice", "OPEN")];
2178 let mut sweep = |rows: &[PrSweepRow]| -> Vec<Trigger> {
2179 events_from_sweep(
2180 rows,
2181 "me",
2182 &mut state,
2183 &BTreeSet::new(),
2184 0,
2185 &no_record,
2186 true,
2187 )
2188 .iter()
2189 .map(|e| e.trigger)
2190 .collect()
2191 };
2192 assert_eq!(vec![Trigger::ReviewRequested], sweep(&asked));
2193 assert!(sweep(&reviewed).is_empty());
2195 assert!(sweep(&reviewed).is_empty());
2196 assert_eq!(vec![Trigger::ReviewRequested], sweep(&asked));
2198 assert!(sweep(&asked).is_empty());
2199 }
2200
2201 #[test]
2202 fn a_sweep_drops_a_pull_request_that_closed_or_vanished() {
2203 let mut state = ListenState::default();
2204 let named = BTreeSet::new();
2205 events_from_sweep(
2206 &[row(7, "aaa", "alice", "OPEN"), row(8, "bbb", "bob", "OPEN")],
2207 "me",
2208 &mut state,
2209 &named,
2210 0,
2211 &no_record,
2212 true,
2213 );
2214 let events = events_from_sweep(
2215 &[row(7, "aaa", "alice", "MERGED")],
2216 "me",
2217 &mut state,
2218 &named,
2219 0,
2220 &no_record,
2221 true,
2222 );
2223 let closed: Vec<i64> = events
2224 .iter()
2225 .filter(|e| e.trigger == Trigger::Closed)
2226 .map(|e| e.number)
2227 .collect();
2228 assert_eq!(vec![7, 8], closed);
2229
2230 let mut state = ListenState::default();
2233 let named: BTreeSet<i64> = [7, 8].into_iter().collect();
2234 events_from_sweep(
2235 &[row(7, "aaa", "alice", "OPEN"), row(8, "bbb", "bob", "OPEN")],
2236 "me",
2237 &mut state,
2238 &named,
2239 0,
2240 &no_record,
2241 false,
2242 );
2243 let events = events_from_sweep(
2244 &[row(7, "aaa", "alice", "OPEN")],
2245 "me",
2246 &mut state,
2247 &named,
2248 0,
2249 &no_record,
2250 false,
2251 );
2252 assert!(events.is_empty(), "{events:?}");
2253 }
2254
2255 #[test]
2256 fn a_sweep_ignores_a_number_below_the_floor_unless_it_was_named() {
2257 let mut state = ListenState::default();
2258 let named: BTreeSet<i64> = [3].into_iter().collect();
2259 let record = |_: i64| Some("old".to_string());
2260 let events = events_from_sweep(
2261 &[
2262 row(3, "new", "alice", "OPEN"),
2263 row(4, "new", "alice", "OPEN"),
2264 row(500, "new", "alice", "OPEN"),
2265 ],
2266 "me",
2267 &mut state,
2268 &named,
2269 480,
2270 &record,
2271 true,
2272 );
2273 let numbers: Vec<i64> = events.iter().map(|e| e.number).collect();
2274 assert_eq!(vec![3, 500], numbers);
2275 assert!(!state.items.contains_key(&4));
2276 }
2277
2278 #[test]
2281 fn a_pull_request_below_the_floor_that_is_still_open_is_not_called_closed() {
2282 let mut state = ListenState::default();
2283 state.watched(4, ItemKind::Pr);
2284 let events = events_from_sweep(
2285 &[
2286 row(4, "new", "alice", "OPEN"),
2287 row(500, "new", "alice", "OPEN"),
2288 ],
2289 "me",
2290 &mut state,
2291 &BTreeSet::new(),
2292 480,
2293 &no_record,
2294 true,
2295 );
2296 assert!(
2297 events.iter().all(|e| e.trigger != Trigger::Closed),
2298 "{events:?}"
2299 );
2300 let gone = events_from_sweep(
2301 &[row(500, "new", "alice", "OPEN")],
2302 "me",
2303 &mut state,
2304 &BTreeSet::new(),
2305 480,
2306 &no_record,
2307 true,
2308 );
2309 assert_eq!(
2310 vec![(4, Trigger::Closed)],
2311 gone.iter()
2312 .map(|e| (e.number, e.trigger))
2313 .collect::<Vec<_>>()
2314 );
2315 }
2316
2317 #[test]
2318 fn the_first_sweep_caps_pending_rechecks_at_the_limit_and_counts_the_rest() {
2319 let push = |n: i64| Event {
2320 number: n,
2321 kind: ItemKind::Pr,
2322 trigger: Trigger::Pushed,
2323 who: None,
2324 head: None,
2325 commits: None,
2326 from_thread: false,
2327 };
2328 let asked = Event {
2329 trigger: Trigger::ReviewRequested,
2330 ..push(50)
2331 };
2332 let events = vec![push(9), push(3), push(7), asked.clone()];
2333 let (kept, held) = cap_first_sweep(events.clone(), 2, true);
2334 let numbers: Vec<i64> = kept.iter().map(|e| e.number).collect();
2335 assert_eq!(vec![3, 7, 50], numbers, "{kept:?}");
2336 assert_eq!(1, held);
2337 let (kept, held) = cap_first_sweep(events, 2, false);
2338 assert_eq!(4, kept.len());
2339 assert_eq!(0, held);
2340 }
2341
2342 #[test]
2345 fn a_review_request_nobody_is_still_making_is_only_an_update() {
2346 let threads = [thread(
2349 "1",
2350 "review_requested",
2351 "PullRequest",
2352 7,
2353 "2026-10-21T14:51:16Z",
2354 )];
2355 let events = events_from_threads(&threads, &mut ListenState::default());
2356 let mut waiting = BTreeMap::new();
2357 for e in &events {
2358 merge(&mut waiting, e, 0);
2359 }
2360 let reviewed = Facts {
2361 has_posted_record: true,
2362 ..open_pr()
2363 };
2364 let mut p = waiting.remove(&7).expect("pending");
2365 assert!(settle(&mut p, &reviewed));
2366 assert_eq!(
2367 [Trigger::Replied].into_iter().collect::<BTreeSet<_>>(),
2368 p.triggers
2369 );
2370 assert_eq!(Action::Recheck(7), plan(&p, &reviewed, &cfg()));
2371
2372 let mut p = pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0);
2374 assert!(settle(&mut p, &open_pr()));
2375 assert!(matches!(plan(&p, &open_pr(), &cfg()), Action::Ignore(_)));
2376
2377 let asked = Facts {
2379 review_pending: true,
2380 ..open_pr()
2381 };
2382 let mut p = pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0);
2383 assert!(!settle(&mut p, &asked));
2384 assert!(p.triggers.contains(&Trigger::ReviewRequested));
2385 assert_eq!(Action::Review, plan(&p, &asked, &cfg()));
2386
2387 let answered = Facts {
2391 has_posted_record: true,
2392 review_pending: true,
2393 request_answered: true,
2394 ..open_pr()
2395 };
2396 let mut p = pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0);
2397 assert!(settle(&mut p, &answered));
2398 assert_eq!(
2399 [Trigger::Replied].into_iter().collect::<BTreeSet<_>>(),
2400 p.triggers
2401 );
2402 assert_eq!(Action::Recheck(7), plan(&p, &answered, &cfg()));
2403
2404 let mut p = pending(ItemKind::Pr, &[Trigger::Pushed], 0);
2406 assert!(!settle(&mut p, &open_pr()));
2407 assert_eq!(
2408 [Trigger::Pushed].into_iter().collect::<BTreeSet<_>>(),
2409 p.triggers
2410 );
2411 }
2412
2413 #[test]
2414 fn a_review_request_counts_for_the_viewer_or_a_team_the_viewer_is_in() {
2415 let teams = |team: &str| match team {
2416 "example/ours" => Some(true),
2417 "example/theirs" => Some(false),
2418 _ => None,
2419 };
2420 let pending = |requests: &[Login]| review_pending(requests, "me", &teams);
2421 assert!(!pending(&[]));
2422 assert!(pending(&[user("ME")]));
2423 assert!(!pending(&[user("alice")]));
2424 assert!(pending(&[team("example/ours")]));
2425 assert!(!pending(&[team("example/theirs"), user("alice")]));
2428 assert!(pending(&[team("example/theirs"), team("example/ours")]));
2429 assert!(pending(&[team("example/unknown")]));
2431 let never = |_: &str| -> Option<bool> { panic!("looked a team up") };
2433 assert!(review_pending(
2434 &[team("example/ours"), user("me")],
2435 "me",
2436 &never
2437 ));
2438 }
2439
2440 #[test]
2444 fn a_request_made_before_the_review_on_record_was_answered_by_it() {
2445 let teams = |team: &str| match team {
2446 "example/ours" => Some(true),
2447 _ => Some(false),
2448 };
2449 let made = |at: &str, who: Login| ReviewRequested { at: at.into(), who };
2450 let answered = |requests: &[ReviewRequested]| {
2451 request_answered(requests, "me", &teams, "2026-10-20T12:00:00Z")
2452 };
2453 assert!(answered(&[made("2026-10-20T11:00:00Z", user("me"))]));
2454 assert!(answered(&[made("2026-10-20T12:00:00Z", user("ME"))]));
2455 assert!(answered(&[made(
2456 "2026-10-20T11:00:00Z",
2457 team("example/ours")
2458 )]));
2459 assert!(!answered(&[
2461 made("2026-10-20T11:00:00Z", user("me")),
2462 made("2026-10-20T13:00:00Z", user("me")),
2463 ]));
2464 assert!(!answered(&[
2465 made("2026-10-20T11:00:00Z", user("me")),
2466 made("2026-10-20T13:00:00Z", team("example/ours")),
2467 ]));
2468 assert!(answered(&[
2470 made("2026-10-20T11:00:00Z", user("me")),
2471 made("2026-10-20T13:00:00Z", user("alice")),
2472 made("2026-10-20T13:00:00Z", team("example/theirs")),
2473 ]));
2474 assert!(!answered(&[]));
2476 assert!(!answered(&[made("2026-10-20T11:00:00Z", user("alice"))]));
2477 assert!(!answered(&[made("", user("me"))]));
2478 assert!(!request_answered(
2479 &[made("2026-10-20T11:00:00Z", user("me"))],
2480 "me",
2481 &teams,
2482 ""
2483 ));
2484 }
2485
2486 #[test]
2487 fn a_push_or_a_reply_on_a_reviewed_pull_request_is_a_recheck() {
2488 let f = Facts {
2489 has_posted_record: true,
2490 ..open_pr()
2491 };
2492 for t in [
2493 Trigger::Pushed,
2494 Trigger::Replied,
2495 Trigger::Mentioned,
2496 Trigger::ReviewRequested,
2497 ] {
2498 assert_eq!(
2499 Action::Recheck(7),
2500 plan(&pending(ItemKind::Pr, &[t], 0), &f, &cfg()),
2501 "{t:?}"
2502 );
2503 }
2504 }
2505
2506 #[test]
2507 fn a_review_request_or_an_assignment_on_an_unreviewed_pull_request_follows_the_config() {
2508 let f = open_pr();
2509 let mut c = cfg();
2510 assert_eq!(
2511 Action::Review,
2512 plan(
2513 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2514 &f,
2515 &c
2516 )
2517 );
2518 assert_eq!(
2519 Action::Review,
2520 plan(&pending(ItemKind::Pr, &[Trigger::Assigned], 0), &f, &c)
2521 );
2522 assert_eq!(
2523 Action::Review,
2524 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2525 );
2526 c.on_review_requested = ReviewOrIgnore::Ignore;
2527 c.on_assigned = ReviewOrIgnore::Ignore;
2528 assert!(matches!(
2529 plan(
2530 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2531 &f,
2532 &c
2533 ),
2534 Action::Ignore(_)
2535 ));
2536 assert!(matches!(
2537 plan(&pending(ItemKind::Pr, &[Trigger::Assigned], 0), &f, &c),
2538 Action::Ignore(_)
2539 ));
2540 assert_eq!(
2542 Action::Review,
2543 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2544 );
2545 }
2546
2547 #[test]
2548 fn a_push_to_an_unreviewed_pull_request_is_ignored_unless_it_was_named() {
2549 let f = open_pr();
2550 assert!(matches!(
2551 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &f, &cfg()),
2552 Action::Ignore(_)
2553 ));
2554 assert!(matches!(
2555 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &cfg()),
2556 Action::Ignore(_)
2557 ));
2558 let named = Facts {
2559 named: true,
2560 ..open_pr()
2561 };
2562 assert_eq!(
2563 Action::Review,
2564 plan(
2565 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2566 &named,
2567 &cfg()
2568 )
2569 );
2570 assert_eq!(
2571 Action::Review,
2572 plan(
2573 &pending(ItemKind::Pr, &[Trigger::Replied], 0),
2574 &named,
2575 &cfg()
2576 )
2577 );
2578 }
2579
2580 #[test]
2581 fn your_own_pull_request_follows_on_own_pr_and_a_push_to_it_is_ignored() {
2582 let f = Facts {
2583 author_is_viewer: true,
2584 has_posted_record: true,
2585 ..open_pr()
2586 };
2587 let mut c = cfg();
2588 assert_eq!(
2589 Action::Checkin { reply_only: false },
2590 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &c)
2591 );
2592 assert_eq!(
2593 Action::Checkin { reply_only: false },
2594 plan(
2595 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2596 &f,
2597 &c
2598 )
2599 );
2600 assert!(matches!(
2601 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &f, &c),
2602 Action::Ignore(_)
2603 ));
2604 c.on_own_pr = OwnPr::Reply;
2605 assert_eq!(
2606 Action::Checkin { reply_only: true },
2607 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2608 );
2609 c.on_own_pr = OwnPr::Ignore;
2610 assert!(matches!(
2611 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &c),
2612 Action::Ignore(_)
2613 ));
2614 }
2615
2616 #[test]
2617 fn a_pull_request_in_custody_belongs_to_resume_and_a_capped_one_waits_for_a_person() {
2618 let custody = Facts {
2619 has_custody_state: true,
2620 ..open_pr()
2621 };
2622 assert_eq!(
2623 Action::BelongsToResume,
2624 plan(
2625 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2626 &custody,
2627 &cfg()
2628 )
2629 );
2630 let capped = Facts {
2631 has_posted_record: true,
2632 recheck_capped: true,
2633 ..open_pr()
2634 };
2635 assert_eq!(
2636 Action::Capped(7),
2637 plan(
2638 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2639 &capped,
2640 &cfg()
2641 )
2642 );
2643 }
2644
2645 #[test]
2649 fn an_issue_mention_keeps_to_its_pull_requests_cap_and_owner() {
2650 let linked = Facts {
2651 number: 42,
2652 open: true,
2653 linked_pr_with_record: Some(108),
2654 ..Default::default()
2655 };
2656 let capped = Facts {
2657 recheck_capped: true,
2658 ..linked.clone()
2659 };
2660 assert_eq!(
2661 Action::Capped(108),
2662 plan(
2663 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2664 &capped,
2665 &cfg()
2666 )
2667 );
2668 let own = Facts {
2669 author_is_viewer: true,
2670 ..linked
2671 };
2672 assert!(matches!(
2673 plan(
2674 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2675 &own,
2676 &cfg()
2677 ),
2678 Action::Ignore(_)
2679 ));
2680 }
2681
2682 #[test]
2683 fn an_issue_mention_without_a_pull_request_is_ignored_and_with_one_rechecks_it() {
2684 let issue = Facts {
2685 number: 42,
2686 open: true,
2687 ..Default::default()
2688 };
2689 assert!(matches!(
2690 plan(
2691 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2692 &issue,
2693 &cfg()
2694 ),
2695 Action::Ignore(_)
2696 ));
2697 assert!(matches!(
2698 plan(
2699 &pending(ItemKind::Issue, &[Trigger::Replied], 0),
2700 &issue,
2701 &cfg()
2702 ),
2703 Action::Ignore(_)
2704 ));
2705 let linked = Facts {
2706 linked_pr_with_record: Some(108),
2707 ..issue.clone()
2708 };
2709 assert_eq!(
2710 Action::Recheck(108),
2711 plan(
2712 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
2713 &linked,
2714 &cfg()
2715 )
2716 );
2717 assert_eq!(
2718 Action::Recheck(108),
2719 plan(
2720 &pending(ItemKind::Issue, &[Trigger::Assigned], 0),
2721 &linked,
2722 &cfg()
2723 )
2724 );
2725 let named = Facts {
2727 named: true,
2728 ..linked
2729 };
2730 assert_eq!(
2731 Action::Recheck(108),
2732 plan(
2733 &pending(ItemKind::Issue, &[Trigger::Replied], 0),
2734 &named,
2735 &cfg()
2736 )
2737 );
2738 }
2739
2740 #[test]
2741 fn a_closed_item_is_dropped_and_one_below_the_floor_is_ignored_unless_named() {
2742 let closed = Facts {
2743 open: false,
2744 has_posted_record: true,
2745 ..open_pr()
2746 };
2747 assert_eq!(
2748 Action::Drop,
2749 plan(
2750 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2751 &closed,
2752 &cfg()
2753 )
2754 );
2755 let f = Facts {
2756 has_posted_record: true,
2757 ..open_pr()
2758 };
2759 assert_eq!(
2760 Action::Drop,
2761 plan(
2762 &pending(ItemKind::Pr, &[Trigger::Pushed, Trigger::Closed], 0),
2763 &f,
2764 &cfg()
2765 )
2766 );
2767 let low = Facts {
2768 below_floor: true,
2769 has_posted_record: true,
2770 ..open_pr()
2771 };
2772 assert!(matches!(
2773 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &low, &cfg()),
2774 Action::Ignore(_)
2775 ));
2776 let named_low = Facts { named: true, ..low };
2777 assert_eq!(
2778 Action::Recheck(7),
2779 plan(
2780 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
2781 &named_low,
2782 &cfg()
2783 )
2784 );
2785 }
2786
2787 #[test]
2790 fn an_item_is_due_once_quiet_for_the_settle_time_or_at_max_wait_anyway() {
2791 let c = cfg();
2792 let mut p = pending(ItemKind::Pr, &[Trigger::Pushed], 1000);
2793 assert!(!due(&p, 1000, &c, false));
2794 assert!(!due(&p, 1000 + c.settle - 1, &c, false));
2795 assert!(due(&p, 1000 + c.settle, &c, false));
2796 p.last_seen = 1000 + c.max_wait - 30;
2798 assert!(!due(&p, 1000 + c.max_wait - 1, &c, false));
2799 assert!(due(&p, 1000 + c.max_wait, &c, false));
2800 assert!(due(&p, 1000, &c, true), "--once waits for nothing");
2801 }
2802
2803 #[test]
2804 fn events_merge_into_one_pending_record_per_item_and_the_next_due_is_the_earliest() {
2805 let c = cfg();
2806 let mut pending = BTreeMap::new();
2807 let push = Event {
2808 number: 7,
2809 kind: ItemKind::Pr,
2810 trigger: Trigger::Pushed,
2811 who: None,
2812 head: Some(("aaa".into(), "bbb".into())),
2813 commits: None,
2814 from_thread: false,
2815 };
2816 merge(&mut pending, &push, 1000);
2817 let reply = Event {
2818 trigger: Trigger::Replied,
2819 who: Some("alice".into()),
2820 head: Some(("bbb".into(), "ccc".into())),
2821 ..push.clone()
2822 };
2823 merge(&mut pending, &reply, 1060);
2824 let other = Event {
2825 number: 8,
2826 ..push.clone()
2827 };
2828 merge(&mut pending, &other, 1100);
2829 assert_eq!(2, pending.len());
2830 let p = &pending[&7];
2831 assert_eq!(1000, p.first_seen);
2832 assert_eq!(1060, p.last_seen);
2833 assert_eq!(Some("aaa".to_string()), p.head_from);
2834 assert_eq!(Some("ccc".to_string()), p.head_to);
2835 assert_eq!(vec!["alice".to_string()], p.who);
2836 assert_eq!("a push and a reply", p.because());
2837 let mut state = ListenState {
2838 pending,
2839 ..Default::default()
2840 };
2841 let all = |_: i64| true;
2842 assert_eq!(Some(1060 + c.settle), next_due(&state, &c, &all));
2843 assert_eq!(None, next_due(&ListenState::default(), &c, &all));
2844
2845 state.watched(7, ItemKind::Pr).retry_after = 5000;
2848 assert_eq!(Some(1100 + c.settle), next_due(&state, &c, &all));
2849 state.watched(8, ItemKind::Pr).retry_after = 6000;
2850 assert_eq!(Some(5000), next_due(&state, &c, &all));
2851 assert_eq!(Some(6000), next_due(&state, &c, &|n| n == 8));
2853 }
2854
2855 #[test]
2858 fn since_accepts_minutes_hours_days_and_combinations() {
2859 assert_eq!(Duration::from_secs(1800), parse_duration("30m").unwrap());
2860 assert_eq!(Duration::from_secs(7200), parse_duration("2h").unwrap());
2861 assert_eq!(Duration::from_secs(86_400), parse_duration("1d").unwrap());
2862 assert_eq!(
2863 Duration::from_secs(5400),
2864 parse_duration(" 1h30m ").unwrap()
2865 );
2866 assert_eq!(Duration::from_secs(45), parse_duration("45s").unwrap());
2867 }
2868
2869 #[test]
2870 fn since_rejects_a_bare_number_and_says_what_it_takes() {
2871 for bad in ["", "30", "2 hours", "h", "1w", "1h30"] {
2872 let err = parse_duration(bad).unwrap_err().to_string();
2873 assert!(err.contains("like 30m, 2h or 1d"), "{bad:?}: {err}");
2874 }
2875 }
2876
2877 #[test]
2878 fn listen_state_round_trips_through_json_and_old_seen_entries_are_pruned() {
2879 let mut state = ListenState {
2880 version: STATE_VERSION,
2881 watermark: "2026-10-20T10:00:00Z".into(),
2882 last_modified: Some("Tue, 20 Oct 2026 10:00:00 GMT".into()),
2883 ..Default::default()
2884 };
2885 state
2886 .seen
2887 .insert("old".into(), "2026-10-01T10:00:00Z".into());
2888 state
2889 .seen
2890 .insert("new".into(), "2026-10-20T09:00:00Z".into());
2891 state.seen.insert("junk".into(), "yesterday".into());
2892 state.watched(7, ItemKind::Pr).head = Some("aaa".into());
2893 state
2894 .pending
2895 .insert(7, pending(ItemKind::Pr, &[Trigger::Pushed], 5));
2896 let text = serde_json::to_string_pretty(&state).unwrap();
2897 assert!(text.contains("\"pushed\""), "{text}");
2898 assert!(text.contains("\"kind\": \"pr\""), "{text}");
2899 let back: ListenState = serde_json::from_str(&text).unwrap();
2900 assert_eq!(state, back);
2901
2902 let now = clock::parse_rfc3339("2026-10-20T10:00:00Z").unwrap();
2903 prune_seen(&mut state, now);
2904 let left: Vec<&String> = state.seen.keys().collect();
2905 assert_eq!(vec!["junk", "new"], left);
2906 }
2907
2908 #[test]
2909 fn gh_failures_back_off_from_a_minute_to_fifteen_and_dispatches_from_fifteen_to_six_hours() {
2910 assert_eq!(60, backoff_secs(1));
2911 assert_eq!(120, backoff_secs(2));
2912 assert_eq!(480, backoff_secs(4));
2913 assert_eq!(900, backoff_secs(5));
2914 assert_eq!(900, backoff_secs(40));
2915 assert_eq!(900, dispatch_backoff_secs(1));
2916 assert_eq!(3600, dispatch_backoff_secs(3));
2917 assert_eq!(6 * 3600, dispatch_backoff_secs(9));
2918 }
2919
2920 #[test]
2924 fn a_head_that_moved_runs_again_once_it_settles_and_a_failure_backs_off() {
2925 let c = cfg();
2926 for failures in [1, 2, 4] {
2927 assert_eq!(c.settle, retry_wait(true, failures, &c));
2928 assert_eq!(
2929 dispatch_backoff_secs(failures),
2930 retry_wait(false, failures, &c)
2931 );
2932 }
2933 let quick = ListenCfg { settle: 1, ..cfg() };
2934 assert_eq!(FLOOR_SECS, retry_wait(true, 1, &quick));
2935 }
2936
2937 #[test]
2941 fn giving_up_holds_what_the_item_was_waiting_on_until_it_changes() {
2942 let mut state = ListenState::default();
2943 let asked = Event {
2944 number: 7,
2945 kind: ItemKind::Pr,
2946 trigger: Trigger::ReviewRequested,
2947 who: None,
2948 head: None,
2949 commits: None,
2950 from_thread: true,
2951 };
2952 state.note(&asked, 0);
2953 state.give_up(7, ItemKind::Pr);
2954 assert!(state.pending.is_empty(), "{state:?}");
2955 assert_eq!(
2956 [Trigger::ReviewRequested]
2957 .into_iter()
2958 .collect::<BTreeSet<_>>(),
2959 state.items[&7].held
2960 );
2961
2962 let shown = Event {
2963 trigger: Trigger::Pushed,
2964 head: Some(("aaa".into(), "bbb".into())),
2965 from_thread: false,
2966 ..asked.clone()
2967 };
2968 state.note(&shown, 600);
2969 let p = &state.pending[&7];
2970 assert_eq!(
2971 [Trigger::Pushed, Trigger::ReviewRequested]
2972 .into_iter()
2973 .collect::<BTreeSet<_>>(),
2974 p.triggers
2975 );
2976 assert!(state.items[&7].held.is_empty(), "{state:?}");
2977 let asked_now = Facts {
2978 review_pending: true,
2979 ..open_pr()
2980 };
2981 assert_eq!(Action::Review, plan(p, &asked_now, &cfg()));
2982 state.pending.clear();
2984 state.note(&shown, 700);
2985 assert_eq!(
2986 [Trigger::Pushed].into_iter().collect::<BTreeSet<_>>(),
2987 state.pending[&7].triggers
2988 );
2989 }
2990
2991 #[test]
2992 fn number_from_url_reads_the_last_segment() {
2993 assert_eq!(
2994 Some(108),
2995 number_from_url("https://api.github.com/repos/o/r/pulls/108")
2996 );
2997 assert_eq!(
2998 Some(42),
2999 number_from_url("https://api.github.com/repos/o/r/issues/42/")
3000 );
3001 assert_eq!(
3002 None,
3003 number_from_url("https://api.github.com/repos/o/r/pulls")
3004 );
3005 }
3006
3007 #[cfg(unix)]
3008 #[test]
3009 fn a_held_lock_refuses_a_second_listener_however_old_and_a_left_file_is_taken_over() {
3010 let dir = std::env::temp_dir().join(format!(
3011 "spar-listen-lock-{}-{}",
3012 std::process::id(),
3013 clock::now_secs()
3014 ));
3015 std::fs::create_dir_all(&dir).unwrap();
3016 let path = dir.join("listen.lock");
3017
3018 let held = Lock::at(path.clone(), 1000).expect("first lock");
3021 for now in [1000, 1000 + 86_400] {
3022 let err = Lock::at(path.clone(), now).unwrap_err().to_string();
3023 assert!(err.contains("already watching"), "{err}");
3024 assert!(err.contains(&std::process::id().to_string()), "{err}");
3025 }
3026 assert!(path.exists(), "a refused listener leaves the lock alone");
3027 drop(held);
3028 assert!(!path.exists(), "dropping the lock removes the file");
3029
3030 std::fs::write(
3033 &path,
3034 serde_json::to_string(&LockFile {
3035 pid: std::process::id(),
3036 started: "x".into(),
3037 })
3038 .unwrap(),
3039 )
3040 .unwrap();
3041 let taken = Lock::at(path.clone(), 1000).expect("a left file is taken over");
3042 drop(taken);
3043 let _ = std::fs::remove_dir_all(&dir);
3044 }
3045}