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