1use std::collections::{BTreeMap, BTreeSet};
33use std::fs::File;
34use std::path::{Path, PathBuf};
35use std::time::Duration;
36
37use serde::{Deserialize, Serialize};
38
39use crate::agent::Agent;
40use crate::answer;
41use crate::checkin;
42use crate::clock;
43use crate::config::{Config, ListenCfg, OnMention, OwnPr, ReviewOrIgnore, RunOrIgnore};
44use crate::error::Result;
45use crate::model::{AssignedIssue, IssueRun, ItemKind, Login, PrSweepRow, ReviewRequested, Status};
46use crate::proc;
47use crate::repo::{ApiResponse, Repo};
48use crate::review_only::{self, RecheckMode, ReviewOpts};
49use crate::spend;
50use crate::{bail, log, logdim, logwarn, spar_err};
51
52const FLOOR_SECS: u64 = 15;
54const SEEN_FOR_SECS: u64 = 7 * 86_400;
56const MAX_PAGES: usize = 50;
59const MAX_FAILURES: u32 = 5;
61
62pub const STATE_VERSION: u32 = 1;
63
64#[derive(Debug, Clone, Default)]
66pub struct Opts {
67 pub items: Vec<i64>,
69 pub once: bool,
71 pub dry_run: bool,
73 pub since: Option<Duration>,
75}
76
77#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
84pub struct Thread {
85 #[serde(default)]
86 pub id: String,
87 #[serde(default)]
88 pub reason: String,
89 #[serde(default)]
90 pub updated_at: String,
91 #[serde(default)]
92 pub subject: Subject,
93}
94
95#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
96pub struct Subject {
97 #[serde(default)]
98 pub title: String,
99 #[serde(default)]
102 pub url: Option<String>,
103 #[serde(default)]
104 pub latest_comment_url: Option<String>,
105 #[serde(default, rename = "type")]
107 pub kind: String,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum Trigger {
117 Pushed,
119 Replied,
121 Mentioned,
122 ReviewRequested,
123 Assigned,
124 Closed,
125}
126
127impl Trigger {
128 fn noun(self) -> &'static str {
130 match self {
131 Trigger::Pushed => "a push",
132 Trigger::Replied => "a reply",
133 Trigger::Mentioned => "a mention",
134 Trigger::ReviewRequested => "a review request",
135 Trigger::Assigned => "an assignment",
136 Trigger::Closed => "a close",
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Event {
144 pub number: i64,
145 pub kind: ItemKind,
146 pub trigger: Trigger,
147 pub who: Option<String>,
149 pub head: Option<(String, String)>,
151 pub commits: Option<u64>,
153 pub from_thread: bool,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163pub struct Pending {
164 pub kind: ItemKind,
165 pub first_seen: u64,
166 pub last_seen: u64,
167 pub triggers: BTreeSet<Trigger>,
168 #[serde(default)]
169 pub who: Vec<String>,
170 #[serde(default)]
171 pub head_from: Option<String>,
172 #[serde(default)]
173 pub head_to: Option<String>,
174}
175
176impl Pending {
177 pub fn because(&self) -> String {
179 let mut parts: Vec<String> = self.triggers.iter().map(|t| t.noun().to_string()).collect();
180 if parts.is_empty() {
181 parts.push("nothing in particular".into());
182 }
183 match parts.len() {
184 1 => parts.remove(0),
185 2 => format!("{} and {}", parts[0], parts[1]),
186 _ => {
187 let last = parts.pop().unwrap_or_default();
188 format!("{}, and {last}", parts.join(", "))
189 }
190 }
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196pub struct Watched {
197 pub kind: ItemKind,
198 #[serde(default)]
199 pub title: String,
200 #[serde(default)]
201 pub author: String,
202 #[serde(default)]
205 pub head: Option<String>,
206 #[serde(default)]
207 pub requested_noticed: bool,
208 #[serde(default)]
209 pub assigned_noticed: bool,
210 #[serde(default)]
211 pub failures: u32,
212 #[serde(default)]
213 pub retry_after: u64,
214 #[serde(default)]
215 pub said_capped: bool,
216 #[serde(default)]
217 pub said_resume: bool,
218 #[serde(default)]
219 pub last_dispatched: Option<u64>,
220 #[serde(default)]
225 pub held: BTreeSet<Trigger>,
226 #[serde(default)]
231 pub assignment_judged: Option<String>,
232 #[serde(default)]
235 pub assignment_raised: Option<String>,
236}
237
238impl Watched {
239 fn new(kind: ItemKind) -> Self {
240 Self {
241 kind,
242 title: String::new(),
243 author: String::new(),
244 head: None,
245 requested_noticed: false,
246 assigned_noticed: false,
247 failures: 0,
248 retry_after: 0,
249 said_capped: false,
250 said_resume: false,
251 last_dispatched: None,
252 held: BTreeSet::new(),
253 assignment_judged: None,
254 assignment_raised: None,
255 }
256 }
257}
258
259#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
262pub struct ListenState {
263 pub version: u32,
264 #[serde(default)]
266 pub watermark: String,
267 #[serde(default)]
272 pub origin: String,
273 #[serde(default)]
276 pub last_modified: Option<String>,
277 #[serde(default)]
280 pub seen: BTreeMap<String, String>,
281 #[serde(default)]
282 pub items: BTreeMap<i64, Watched>,
283 #[serde(default)]
284 pub pending: BTreeMap<i64, Pending>,
285}
286
287impl ListenState {
288 fn watched(&mut self, number: i64, kind: ItemKind) -> &mut Watched {
289 self.items
290 .entry(number)
291 .or_insert_with(|| Watched::new(kind))
292 }
293
294 fn note(&mut self, event: &Event, now: u64) {
297 let held = std::mem::take(&mut self.watched(event.number, event.kind).held);
298 merge(&mut self.pending, event, now);
299 if let Some(p) = self.pending.get_mut(&event.number) {
300 p.triggers.extend(held);
301 }
302 }
303
304 fn give_up(&mut self, number: i64, kind: ItemKind) {
308 if let Some(p) = self.pending.remove(&number) {
309 self.watched(number, kind).held.extend(p.triggers);
310 }
311 }
312}
313
314pub fn parse_duration(text: &str) -> Result<Duration> {
320 let bad = || {
321 spar_err!(
322 "`--since` takes a number and a unit, like 30m, 2h or 1d, not {:?}",
323 text.trim()
324 )
325 };
326 let mut total = 0u64;
327 let mut digits = String::new();
328 let mut any = false;
329 for c in text.trim().chars() {
330 if c.is_ascii_digit() {
331 digits.push(c);
332 continue;
333 }
334 let n: u64 = digits.parse().map_err(|_| bad())?;
335 digits.clear();
336 let unit = match c {
337 's' => 1,
338 'm' => 60,
339 'h' => 3600,
340 'd' => 86_400,
341 _ => return Err(bad()),
342 };
343 total = total.saturating_add(n.saturating_mul(unit));
344 any = true;
345 }
346 if !digits.is_empty() || !any {
347 return Err(bad());
348 }
349 Ok(Duration::from_secs(total))
350}
351
352pub fn poll_interval(cfg: &ListenCfg, header: Option<&str>) -> u64 {
355 let asked = header
356 .and_then(|h| h.trim().parse::<u64>().ok())
357 .unwrap_or(0);
358 cfg.interval.max(asked).max(FLOOR_SECS)
359}
360
361pub fn rate_limit_wait(resp: &ApiResponse, now: u64) -> Option<u64> {
364 if resp.status != 403 && resp.status != 429 {
365 return None;
366 }
367 let retry_after = resp
368 .headers
369 .get("retry-after")
370 .and_then(|v| v.trim().parse::<u64>().ok());
371 let exhausted = resp
372 .headers
373 .get("x-ratelimit-remaining")
374 .is_some_and(|v| v.trim() == "0");
375 let reset = resp
376 .headers
377 .get("x-ratelimit-reset")
378 .and_then(|v| v.trim().parse::<u64>().ok())
379 .map(|at| at.saturating_sub(now));
380 let wait = match (retry_after, exhausted, reset) {
381 (Some(secs), _, _) => secs,
382 (None, true, Some(secs)) => secs,
383 _ => return None,
384 };
385 Some(wait.clamp(60, 3600))
386}
387
388fn status_message(resp: &ApiResponse) -> String {
391 serde_json::from_str::<serde_json::Value>(&resp.body)
392 .ok()
393 .and_then(|v| {
394 v.get("message")
395 .and_then(|m| m.as_str())
396 .map(|m| format!("HTTP {}: {m}", resp.status))
397 })
398 .unwrap_or_else(|| format!("HTTP {}", resp.status))
399}
400
401pub fn next_link(headers: &BTreeMap<String, String>) -> Option<String> {
403 let link = headers.get("link")?;
404 link.split(',').find_map(|part| {
405 let (url, rel) = part.split_once(';')?;
406 if !rel.contains("rel=\"next\"") {
407 return None;
408 }
409 let url = url.trim().trim_start_matches('<').trim_end_matches('>');
410 (!url.is_empty()).then(|| url.to_string())
411 })
412}
413
414pub fn number_from_url(url: &str) -> Option<i64> {
416 url.trim_end_matches('/')
417 .rsplit('/')
418 .next()
419 .and_then(|last| last.parse().ok())
420}
421
422pub fn events_from_threads(threads: &[Thread], state: &mut ListenState) -> Vec<Event> {
425 let mut out = Vec::new();
426 for thread in threads {
427 if state.seen.get(&thread.id) == Some(&thread.updated_at) {
428 continue;
429 }
430 if !thread.updated_at.is_empty() {
431 state
432 .seen
433 .insert(thread.id.clone(), thread.updated_at.clone());
434 if thread.updated_at > state.watermark {
435 state.watermark = thread.updated_at.clone();
436 }
437 }
438 let kind = match thread.subject.kind.as_str() {
439 "PullRequest" => ItemKind::Pr,
440 "Issue" => ItemKind::Issue,
441 _ => continue,
442 };
443 let Some(number) = thread.subject.url.as_deref().and_then(number_from_url) else {
444 continue;
445 };
446 let trigger = match thread.reason.as_str() {
447 "review_requested" => Trigger::ReviewRequested,
448 "assign" => Trigger::Assigned,
449 "mention" | "team_mention" => Trigger::Mentioned,
450 _ => Trigger::Replied,
454 };
455 out.push(Event {
456 number,
457 kind,
458 trigger,
459 who: None,
460 head: None,
461 commits: None,
462 from_thread: true,
463 });
464 }
465 out
466}
467
468pub fn events_from_sweep(
474 rows: &[PrSweepRow],
475 viewer: &str,
476 state: &mut ListenState,
477 named: &BTreeSet<i64>,
478 min_number: i64,
479 record_head: &dyn Fn(i64) -> Option<String>,
480 repo_mode: bool,
481) -> Vec<Event> {
482 let mut out = Vec::new();
483 let mut present: BTreeSet<i64> = BTreeSet::new();
484 for row in rows {
485 present.insert(row.number);
489 if row.number < min_number && !named.contains(&row.number) {
490 continue;
491 }
492 let watched = state.watched(row.number, ItemKind::Pr);
493 if !row.title.is_empty() {
494 watched.title = row.title.clone();
495 }
496 if let Some(author) = &row.author {
497 watched.author = author.login.clone();
498 }
499 if !row.state.eq_ignore_ascii_case("OPEN") {
500 out.push(Event {
501 number: row.number,
502 kind: ItemKind::Pr,
503 trigger: Trigger::Closed,
504 who: None,
505 head: None,
506 commits: None,
507 from_thread: false,
508 });
509 continue;
510 }
511 let from = match &watched.head {
512 Some(old) => Some(old.clone()),
513 None => record_head(row.number),
514 };
515 if let Some(old) = from {
516 if !row.head_ref_oid.is_empty() && !old.eq_ignore_ascii_case(&row.head_ref_oid) {
517 out.push(Event {
518 number: row.number,
519 kind: ItemKind::Pr,
520 trigger: Trigger::Pushed,
521 who: None,
522 head: Some((old, row.head_ref_oid.clone())),
523 commits: None,
524 from_thread: false,
525 });
526 }
527 }
528 if !row.head_ref_oid.is_empty() {
529 watched.head = Some(row.head_ref_oid.clone());
530 }
531 let asked = row
532 .review_requests
533 .iter()
534 .any(|l| crate::comments::same_login(&l.login, viewer));
535 if !asked {
540 watched.requested_noticed = false;
541 } else if !watched.requested_noticed {
542 watched.requested_noticed = true;
543 out.push(Event {
544 number: row.number,
545 kind: ItemKind::Pr,
546 trigger: Trigger::ReviewRequested,
547 who: None,
548 head: None,
549 commits: None,
550 from_thread: false,
551 });
552 }
553 let assigned = row
554 .assignees
555 .iter()
556 .any(|l| crate::comments::same_login(&l.login, viewer));
557 if assigned && !watched.assigned_noticed {
558 watched.assigned_noticed = true;
559 out.push(Event {
560 number: row.number,
561 kind: ItemKind::Pr,
562 trigger: Trigger::Assigned,
563 who: None,
564 head: None,
565 commits: None,
566 from_thread: false,
567 });
568 }
569 }
570 if repo_mode {
571 let gone: Vec<i64> = state
574 .items
575 .iter()
576 .filter(|(n, w)| w.kind == ItemKind::Pr && !present.contains(n))
577 .map(|(n, _)| *n)
578 .collect();
579 for number in gone {
580 out.push(Event {
581 number,
582 kind: ItemKind::Pr,
583 trigger: Trigger::Closed,
584 who: None,
585 head: None,
586 commits: None,
587 from_thread: false,
588 });
589 }
590 }
591 out
592}
593
594pub fn assignment_after(state: &ListenState, number: i64) -> u64 {
598 let judged = state
599 .items
600 .get(&number)
601 .and_then(|w| w.assignment_judged.as_deref());
602 [Some(state.origin.as_str()), judged]
603 .into_iter()
604 .flatten()
605 .filter_map(clock::parse_rfc3339)
606 .max()
607 .unwrap_or(0)
608}
609
610pub fn events_from_assigned(
621 issues: &[AssignedIssue],
622 viewer: &str,
623 state: &mut ListenState,
624 named: &BTreeSet<i64>,
625 min_number: i64,
626 repo_mode: bool,
627) -> Vec<Event> {
628 let mut out = Vec::new();
629 for issue in issues {
630 let is_named = named.contains(&issue.number);
631 if (!repo_mode && !is_named) || (issue.number < min_number && !is_named) {
632 continue;
633 }
634 let Some(at) = issue.newest_for(viewer) else {
635 continue;
636 };
637 let Some(when) = clock::parse_rfc3339(&at) else {
638 continue;
639 };
640 let raised = state
641 .items
642 .get(&issue.number)
643 .and_then(|w| w.assignment_raised.as_deref())
644 .and_then(clock::parse_rfc3339)
645 .unwrap_or(0);
646 if when <= assignment_after(state, issue.number).max(raised) {
647 continue;
648 }
649 let watched = state.watched(issue.number, ItemKind::Issue);
650 if !issue.title.is_empty() {
651 watched.title = issue.title.clone();
652 }
653 watched.assignment_raised = Some(at);
654 out.push(Event {
655 number: issue.number,
656 kind: ItemKind::Issue,
657 trigger: Trigger::Assigned,
658 who: None,
659 head: None,
660 commits: None,
661 from_thread: false,
662 });
663 }
664 out
665}
666
667pub fn merge(pending: &mut BTreeMap<i64, Pending>, event: &Event, now: u64) {
669 let p = pending.entry(event.number).or_insert_with(|| Pending {
670 kind: event.kind,
671 first_seen: now,
672 last_seen: now,
673 triggers: BTreeSet::new(),
674 who: Vec::new(),
675 head_from: None,
676 head_to: None,
677 });
678 p.last_seen = now;
679 p.triggers.insert(event.trigger);
680 if let Some(who) = &event.who {
681 if !p.who.contains(who) {
682 p.who.push(who.clone());
683 }
684 }
685 if let Some((from, to)) = &event.head {
686 if p.head_from.is_none() {
687 p.head_from = Some(from.clone());
688 }
689 p.head_to = Some(to.clone());
690 }
691}
692
693pub fn due(p: &Pending, now: u64, cfg: &ListenCfg, once: bool) -> bool {
696 once || now.saturating_sub(p.last_seen) >= cfg.settle
697 || now.saturating_sub(p.first_seen) >= cfg.max_wait
698}
699
700pub fn next_due(
704 state: &ListenState,
705 cfg: &ListenCfg,
706 acts_on: &dyn Fn(i64) -> bool,
707) -> Option<u64> {
708 state
709 .pending
710 .iter()
711 .filter(|(n, _)| acts_on(**n))
712 .map(|(n, p)| {
713 let settled = (p.last_seen + cfg.settle).min(p.first_seen + cfg.max_wait);
714 state
715 .items
716 .get(n)
717 .map_or(settled, |w| settled.max(w.retry_after))
718 })
719 .min()
720}
721
722#[derive(Debug, Clone, PartialEq, Eq)]
724pub enum Action {
725 Recheck(i64),
727 Review,
729 Checkin { reply_only: bool },
731 Answer { draft: bool },
734 AnswersCapped,
737 Capped(i64),
740 Run,
742 BelongsToResume,
744 Drop,
746 Ignore(&'static str),
748}
749
750impl Action {
751 pub fn phrase(&self, number: i64) -> String {
753 match self {
754 Action::Recheck(pr) if *pr == number => "take a second look".to_string(),
755 Action::Recheck(pr) => format!("take a second look at #{pr}"),
756 Action::Review => "review it".to_string(),
757 Action::Checkin { reply_only: false } => "check in on it".to_string(),
758 Action::Checkin { reply_only: true } => "answer the comments on it".to_string(),
759 Action::Answer { draft: false } => "answer what it asks of you".to_string(),
760 Action::Answer { draft: true } => "draft an answer to what it asks of you".to_string(),
761 Action::Run => "work it with `spar run`".to_string(),
762 Action::Capped(_) | Action::AnswersCapped | Action::BelongsToResume => {
763 "leave it".to_string()
764 }
765 Action::Drop => "drop it".to_string(),
766 Action::Ignore(_) => "ignore it".to_string(),
767 }
768 }
769
770 fn runs(&self) -> bool {
771 matches!(
772 self,
773 Action::Recheck(_)
774 | Action::Review
775 | Action::Checkin { .. }
776 | Action::Answer { .. }
777 | Action::Run
778 )
779 }
780}
781
782#[derive(Debug, Clone, Default, PartialEq, Eq)]
785pub struct Facts {
786 pub number: i64,
787 pub named: bool,
788 pub open: bool,
789 pub author_is_viewer: bool,
792 pub has_posted_record: bool,
794 pub recheck_capped: bool,
795 pub has_custody_state: bool,
797 pub below_floor: bool,
798 pub linked_pr_with_record: Option<i64>,
800 pub review_pending: bool,
803 pub request_answered: bool,
806 pub mentioned: bool,
810 pub asks_open: bool,
814 pub answers_capped: bool,
817 pub assigned_at: Option<String>,
820 pub others_pr: bool,
823}
824
825pub fn review_pending(
829 requests: &[Login],
830 viewer: &str,
831 in_team: &dyn Fn(&str) -> Option<bool>,
832) -> bool {
833 requests
834 .iter()
835 .any(|r| crate::comments::same_login(&r.login, viewer))
836 || requests
837 .iter()
838 .filter(|r| r.login.is_empty())
839 .any(|team| in_team(&team.slug).unwrap_or(true))
840}
841
842pub fn request_answered(
851 made: &[ReviewRequested],
852 viewer: &str,
853 in_team: &dyn Fn(&str) -> Option<bool>,
854 reviewed_at: &str,
855) -> bool {
856 let Some(reviewed) = clock::parse_rfc3339(reviewed_at) else {
857 return false;
858 };
859 made.iter()
860 .filter(|r| review_pending(std::slice::from_ref(&r.who), viewer, in_team))
861 .filter_map(|r| clock::parse_rfc3339(&r.at))
862 .max()
863 .is_some_and(|newest| newest <= reviewed)
864}
865
866pub fn settle(p: &mut Pending, f: &Facts) -> bool {
873 let stale = p.kind == ItemKind::Pr
874 && (!f.review_pending || f.request_answered)
875 && p.triggers.remove(&Trigger::ReviewRequested);
876 if stale {
877 p.triggers.insert(Trigger::Replied);
878 }
879 stale
880}
881
882pub fn settle_mention(p: &mut Pending, f: &Facts) -> bool {
890 let asked = match p.kind {
891 ItemKind::Pr => !f.has_posted_record || f.mentioned,
892 ItemKind::Issue if f.linked_pr_with_record.is_some() => f.mentioned,
893 ItemKind::Issue => f.asks_open,
894 };
895 let stale = !asked && p.triggers.remove(&Trigger::Mentioned);
896 if stale {
897 p.triggers.insert(Trigger::Replied);
898 }
899 stale
900}
901
902pub fn settle_assignment(p: &mut Pending, f: &Facts, after: u64) -> bool {
909 if p.kind != ItemKind::Issue {
910 return false;
911 }
912 let fresh = f
913 .assigned_at
914 .as_deref()
915 .and_then(clock::parse_rfc3339)
916 .is_some_and(|when| when > after);
917 let stale = !fresh && p.triggers.remove(&Trigger::Assigned);
918 if stale {
919 p.triggers.insert(Trigger::Replied);
920 }
921 stale
922}
923
924pub fn plan(p: &Pending, f: &Facts, cfg: &ListenCfg) -> Action {
926 use Trigger::*;
927 if !f.open || p.triggers.contains(&Closed) {
928 return Action::Drop;
929 }
930 if f.below_floor && !f.named {
931 return Action::Ignore("it is below min_number and was not named");
932 }
933 match p.kind {
934 ItemKind::Issue => {
935 if p.triggers.contains(&Assigned) {
938 let declined = match cfg.on_assigned_issue {
939 RunOrIgnore::Ignore => "on_assigned_issue is ignore",
940 RunOrIgnore::Run if f.others_pr => {
944 "an issue whose open pull request somebody else wrote"
945 }
946 RunOrIgnore::Run => return Action::Run,
947 };
948 if !p.triggers.contains(&Mentioned) {
949 return Action::Ignore(declined);
950 }
951 }
952 if !p.triggers.contains(&Mentioned) {
954 return Action::Ignore("nothing on the issue asked for spar");
955 }
956 match f.linked_pr_with_record {
959 Some(_) if f.author_is_viewer => Action::Ignore("its pull request is your own"),
960 Some(pr) if f.recheck_capped => Action::Capped(pr),
961 Some(pr) => Action::Recheck(pr),
962 None => match cfg.on_mention {
966 OnMention::Ignore => Action::Ignore("on_mention is ignore"),
967 _ if !f.asks_open => Action::Ignore("a mention spar has already judged"),
968 _ if f.answers_capped => Action::AnswersCapped,
969 OnMention::Answer => Action::Answer { draft: false },
970 OnMention::Draft => Action::Answer { draft: true },
971 },
972 }
973 }
974 ItemKind::Pr => {
975 if f.author_is_viewer {
980 let spoken_to = p
981 .triggers
982 .iter()
983 .any(|t| matches!(t, Replied | Mentioned | ReviewRequested));
984 if !spoken_to {
985 return Action::Ignore("a push to your own pull request");
986 }
987 return match cfg.on_own_pr {
988 OwnPr::Checkin => Action::Checkin { reply_only: false },
989 OwnPr::Reply => Action::Checkin { reply_only: true },
990 OwnPr::Ignore => Action::Ignore("on_own_pr is ignore"),
991 };
992 }
993 if f.has_posted_record {
994 let asked =
998 p.triggers.contains(&ReviewRequested) || p.triggers.contains(&Mentioned);
999 if !asked {
1000 return Action::Ignore(
1001 "what is neither a review request nor a mention, the only things that \
1002 ask for a second look",
1003 );
1004 }
1005 return if f.recheck_capped {
1006 Action::Capped(f.number)
1007 } else {
1008 Action::Recheck(f.number)
1009 };
1010 }
1011 if f.has_custody_state {
1012 return Action::BelongsToResume;
1013 }
1014 if f.named {
1015 return Action::Review;
1016 }
1017 if p.triggers.contains(&ReviewRequested) {
1018 return match cfg.on_review_requested {
1019 ReviewOrIgnore::Review => Action::Review,
1020 ReviewOrIgnore::Ignore => Action::Ignore("on_review_requested is ignore"),
1021 };
1022 }
1023 if p.triggers.contains(&Assigned) {
1024 return match cfg.on_assigned {
1025 ReviewOrIgnore::Review => Action::Review,
1026 ReviewOrIgnore::Ignore => Action::Ignore("on_assigned is ignore"),
1027 };
1028 }
1029 if p.triggers.contains(&Mentioned) {
1030 return Action::Review;
1031 }
1032 Action::Ignore("a push or a comment on a pull request spar has not reviewed")
1033 }
1034 }
1035}
1036
1037pub fn prune_seen(state: &mut ListenState, now: u64) {
1039 state.seen.retain(|_, at| {
1040 clock::parse_rfc3339(at).is_none_or(|t| now.saturating_sub(t) < SEEN_FOR_SECS)
1041 });
1042}
1043
1044pub fn backoff_secs(failures: u32) -> u64 {
1046 (60u64 << failures.saturating_sub(1).min(10)).min(900)
1047}
1048
1049pub fn dispatch_backoff_secs(failures: u32) -> u64 {
1051 (900u64 << failures.saturating_sub(1).min(10)).min(6 * 3600)
1052}
1053
1054pub fn retry_wait(moved: bool, failures: u32, cfg: &ListenCfg) -> u64 {
1058 if moved {
1059 cfg.settle.max(FLOOR_SECS)
1060 } else {
1061 dispatch_backoff_secs(failures)
1062 }
1063}
1064
1065#[derive(Debug, Default)]
1067struct Backoff {
1068 failures: u32,
1069 said: bool,
1070}
1071
1072impl Backoff {
1073 fn failed(&mut self, what: &str, why: &str) -> u64 {
1074 self.failures += 1;
1075 let wait = backoff_secs(self.failures);
1076 let line = format!(
1077 "could not {what}: {why}. Trying again in {}.",
1078 spend::clock(Duration::from_secs(wait))
1079 );
1080 if self.said {
1081 logdim!("{line}");
1082 } else {
1083 logwarn!("{line}");
1084 self.said = true;
1085 }
1086 wait
1087 }
1088
1089 fn recovered(&mut self) {
1090 if self.said {
1091 log!("gh is answering again");
1092 }
1093 *self = Self::default();
1094 }
1095}
1096
1097#[derive(Debug, Serialize, Deserialize)]
1104struct LockFile {
1105 pid: u32,
1106 started: String,
1107}
1108
1109#[derive(Debug)]
1118struct Lock {
1119 path: PathBuf,
1120 _file: File,
1122}
1123
1124impl Lock {
1125 fn acquire(repo: &Repo, now: u64) -> Result<Lock> {
1126 Lock::at(repo.listen_lock_path(), now)
1127 }
1128
1129 fn at(path: PathBuf, now: u64) -> Result<Lock> {
1130 if let Some(parent) = path.parent() {
1131 std::fs::create_dir_all(parent)?;
1132 }
1133 for _ in 0..3 {
1138 let Some(mut file) = open_locked(&path)? else {
1139 let pid = std::fs::read_to_string(&path)
1140 .ok()
1141 .and_then(|text| serde_json::from_str::<LockFile>(&text).ok())
1142 .map(|h| h.pid.to_string())
1143 .unwrap_or_else(|| "unknown".into());
1144 bail!(
1145 "another `spar listen` (pid {pid}) is already watching this checkout. \
1146 Stop it first.{}",
1147 if cfg!(unix) {
1148 String::new()
1149 } else {
1150 format!(" If that process is gone, delete {}.", path.display())
1151 }
1152 );
1153 };
1154 if !same_file(&file, &path) {
1155 continue;
1156 }
1157 file.set_len(0)?;
1158 serde_json::to_writer_pretty(
1159 &mut file,
1160 &LockFile {
1161 pid: std::process::id(),
1162 started: clock::rfc3339(now),
1163 },
1164 )?;
1165 return Ok(Lock { path, _file: file });
1166 }
1167 bail!("could not take the listen lock at {}", path.display())
1168 }
1169}
1170
1171impl Drop for Lock {
1172 fn drop(&mut self) {
1173 let _ = std::fs::remove_file(&self.path);
1176 }
1177}
1178
1179#[cfg(unix)]
1181fn open_locked(path: &Path) -> Result<Option<File>> {
1182 use std::os::unix::io::AsRawFd;
1183 let file = std::fs::OpenOptions::new()
1184 .read(true)
1185 .write(true)
1186 .create(true)
1187 .truncate(false)
1188 .open(path)?;
1189 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
1190 return Ok(Some(file));
1191 }
1192 let e = std::io::Error::last_os_error();
1193 if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
1194 Ok(None)
1195 } else {
1196 Err(e.into())
1197 }
1198}
1199
1200#[cfg(not(unix))]
1203fn open_locked(path: &Path) -> Result<Option<File>> {
1204 match std::fs::OpenOptions::new()
1205 .write(true)
1206 .create_new(true)
1207 .open(path)
1208 {
1209 Ok(file) => Ok(Some(file)),
1210 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
1211 Err(e) => Err(e.into()),
1212 }
1213}
1214
1215#[cfg(unix)]
1216fn same_file(file: &File, path: &Path) -> bool {
1217 use std::os::unix::fs::MetadataExt;
1218 match (file.metadata(), std::fs::metadata(path)) {
1219 (Ok(open), Ok(named)) => open.dev() == named.dev() && open.ino() == named.ino(),
1220 _ => false,
1221 }
1222}
1223
1224#[cfg(not(unix))]
1225fn same_file(_file: &File, _path: &Path) -> bool {
1226 true
1227}
1228
1229enum Polled {
1234 Nothing(u64),
1236 Threads(Vec<Thread>, u64),
1237 RateLimited(u64),
1239 Failed(String),
1240}
1241
1242fn poll(repo: &Repo, cfg: &ListenCfg, state: &mut ListenState, now: u64) -> Polled {
1243 let endpoint = format!(
1244 "repos/{{owner}}/{{repo}}/notifications?participating=true&all=true&per_page=100&since={}",
1245 state.watermark
1246 );
1247 let last_modified = state.last_modified.clone();
1248 let headers: Vec<(&str, &str)> = last_modified
1249 .as_deref()
1250 .map(|lm| vec![("If-Modified-Since", lm)])
1251 .unwrap_or_default();
1252 let resp = match repo.gh_api_raw(&endpoint, &headers) {
1253 Ok(resp) => resp,
1254 Err(e) => return Polled::Failed(e.last_line().to_string()),
1255 };
1256 let interval = poll_interval(cfg, resp.headers.get("x-poll-interval").map(String::as_str));
1257 match resp.status {
1258 304 => Polled::Nothing(interval),
1259 200 => match every_page(repo, &resp, &state.watermark, now) {
1260 Ok(threads) => {
1261 state.last_modified = resp
1264 .headers
1265 .get("last-modified")
1266 .or_else(|| resp.headers.get("date"))
1267 .cloned();
1268 Polled::Threads(threads, interval)
1269 }
1270 Err(failed) => failed,
1271 },
1272 401 => Polled::Failed("gh is not authenticated; run `gh auth status`".into()),
1273 _ => refused(&resp, now),
1274 }
1275}
1276
1277fn refused(resp: &ApiResponse, now: u64) -> Polled {
1279 match rate_limit_wait(resp, now) {
1280 Some(wait) => Polled::RateLimited(wait),
1281 None => Polled::Failed(status_message(resp)),
1282 }
1283}
1284
1285fn every_page(
1292 repo: &Repo,
1293 first: &ApiResponse,
1294 watermark: &str,
1295 now: u64,
1296) -> std::result::Result<Vec<Thread>, Polled> {
1297 let unexpected =
1298 |e: serde_json::Error| Polled::Failed(format!("unexpected notification data: {e}"));
1299 let mut threads: Vec<Thread> = serde_json::from_str(&first.body).map_err(unexpected)?;
1300 let mut next = next_link(&first.headers);
1301 let mut pages = 1;
1302 while let Some(url) = next.take() {
1303 if pages >= MAX_PAGES {
1304 return Err(Polled::Failed(format!(
1305 "more than {MAX_PAGES} pages of notifications since {watermark}. Run once with \
1306 --since, like --since 1d, to start from later and skip the rest"
1307 )));
1308 }
1309 let more = repo
1310 .gh_api_raw(&url, &[])
1311 .map_err(|e| Polled::Failed(e.last_line().to_string()))?;
1312 if more.status != 200 {
1313 return Err(refused(&more, now));
1314 }
1315 threads.extend(serde_json::from_str::<Vec<Thread>>(&more.body).map_err(unexpected)?);
1316 next = next_link(&more.headers);
1317 pages += 1;
1318 }
1319 Ok(threads)
1320}
1321
1322#[derive(Default)]
1324struct Swept {
1325 rows: Vec<PrSweepRow>,
1326 closed_issues: Vec<i64>,
1328 assigned: Vec<AssignedIssue>,
1330}
1331
1332fn sweep_rows(
1336 repo: &Repo,
1337 cfg: &Config,
1338 viewer: &str,
1339 repo_mode: bool,
1340 named_prs: &[i64],
1341 named_issues: &[i64],
1342 state: &ListenState,
1343) -> Result<Swept> {
1344 let watched = |n: &&i64| state.items.contains_key(*n);
1345 let wants_assigned = cfg.listen.on_assigned_issue == RunOrIgnore::Run
1346 && (repo_mode || named_issues.iter().any(|n| watched(&n)));
1347 let assigned = if wants_assigned {
1348 repo.sweep_assigned_issues(viewer)?
1349 } else {
1350 Vec::new()
1351 };
1352 if repo_mode {
1353 return Ok(Swept {
1354 rows: repo.sweep_open_prs()?,
1355 closed_issues: Vec::new(),
1356 assigned,
1357 });
1358 }
1359 let mut swept = Swept {
1360 assigned,
1361 ..Swept::default()
1362 };
1363 for number in named_prs.iter().filter(watched) {
1364 swept.rows.push(repo.sweep_pr(*number)?);
1365 }
1366 for issue in named_issues.iter().filter(watched) {
1367 if !repo.read_issue(*issue)?.state.eq_ignore_ascii_case("OPEN") {
1371 swept.closed_issues.push(*issue);
1372 continue;
1373 }
1374 if let Ok(Some(pr)) = repo.try_open_pr_for_issue(*issue, cfg.base_branch()) {
1377 if repo.read_review_record(pr.number).is_some_and(|r| r.posted) {
1378 swept.rows.push(repo.sweep_pr(pr.number)?);
1379 }
1380 }
1381 }
1382 Ok(swept)
1383}
1384
1385fn facts(
1388 repo: &Repo,
1389 cfg: &Config,
1390 number: i64,
1391 kind: ItemKind,
1392 named: bool,
1393 mention: bool,
1394 assigned: bool,
1395) -> Result<Facts> {
1396 let viewer = repo.viewer_login()?.to_string();
1397 let below_floor = number < cfg.loop_cfg.min_number;
1398 let in_team = |team: &str| repo.team_has_member(team, &viewer);
1399 match kind {
1400 ItemKind::Pr => {
1401 let row = repo.sweep_pr(number)?;
1402 let record = repo.read_review_record(number);
1403 let posted = record.as_ref().is_some_and(|r| r.posted);
1404 let pending = review_pending(&row.review_requests, &viewer, &in_team);
1405 let answered = pending
1408 && record.as_ref().filter(|r| r.posted).is_some_and(|r| {
1409 repo.review_requests_made(number).is_some_and(|made| {
1410 request_answered(&made, &viewer, &in_team, &r.reviewed_at)
1411 })
1412 });
1413 let mentioned = match record.as_ref().filter(|r| r.posted) {
1416 Some(r) if mention => {
1417 let mut rows = repo.try_pr_review_comments(number)?;
1418 rows.extend(repo.try_pr_reviews(number)?);
1419 rows.extend(repo.try_issue_comments(number)?);
1420 crate::comments::mentioned_in(&rows, &viewer, &r.reviewed_at, &in_team)
1421 }
1422 _ => false,
1423 };
1424 Ok(Facts {
1425 number,
1426 named,
1427 open: row.state.eq_ignore_ascii_case("OPEN"),
1428 author_is_viewer: row
1429 .author
1430 .as_ref()
1431 .is_some_and(|a| crate::comments::same_login(&a.login, &viewer)),
1432 has_posted_record: posted,
1433 recheck_capped: posted
1434 && record
1435 .as_ref()
1436 .is_some_and(|r| r.rechecks >= cfg.listen.max_rechecks),
1437 has_custody_state: repo.state_path(number).exists(),
1438 below_floor,
1439 linked_pr_with_record: None,
1440 review_pending: pending,
1441 request_answered: answered,
1442 mentioned,
1443 asks_open: false,
1444 answers_capped: false,
1445 assigned_at: None,
1446 others_pr: false,
1447 })
1448 }
1449 ItemKind::Issue => {
1450 let issue = repo.read_issue(number)?;
1451 let open_pr = repo
1452 .try_open_pr_for_issue(number, cfg.base_branch())
1453 .ok()
1454 .flatten();
1455 let linked = open_pr.as_ref().and_then(|pr| {
1456 repo.read_review_record(pr.number)
1457 .filter(|r| r.posted)
1458 .map(|r| (pr.number, r))
1459 });
1460 let pr_by_viewer = match &open_pr {
1464 Some(pr) if assigned || linked.is_some() => Some(
1465 repo.sweep_pr(pr.number)?
1466 .author
1467 .as_ref()
1468 .is_some_and(|a| crate::comments::same_login(&a.login, &viewer)),
1469 ),
1470 _ => None,
1471 };
1472 let (author_is_viewer, recheck_capped) = match &linked {
1474 Some((_, record)) => (
1475 pr_by_viewer.unwrap_or(false),
1476 record.rechecks >= cfg.listen.max_rechecks,
1477 ),
1478 None => (false, false),
1479 };
1480 let assigned_at = if assigned {
1481 repo.issue_assignments(number)?.newest_for(&viewer)
1482 } else {
1483 None
1484 };
1485 let mentioned = match &linked {
1486 Some((_, record)) if mention => crate::comments::mentioned_in(
1487 &repo.try_issue_comments(number)?,
1488 &viewer,
1489 &record.reviewed_at,
1490 &in_team,
1491 ),
1492 _ => false,
1493 };
1494 let (asks_open, answers_capped) = match &linked {
1495 None if mention => {
1496 let (open, automatic) = answer::outstanding(repo, number, &viewer, &in_team)?;
1497 (open > 0, automatic >= cfg.listen.max_answers)
1498 }
1499 _ => (false, false),
1500 };
1501 Ok(Facts {
1502 number,
1503 named,
1504 open: issue.state.eq_ignore_ascii_case("OPEN"),
1505 author_is_viewer,
1506 has_posted_record: false,
1507 recheck_capped,
1508 has_custody_state: false,
1509 below_floor,
1510 linked_pr_with_record: linked.map(|(pr, _)| pr),
1511 review_pending: false,
1512 request_answered: false,
1513 mentioned,
1514 asks_open,
1515 answers_capped,
1516 assigned_at,
1517 others_pr: assigned && pr_by_viewer == Some(false),
1518 })
1519 }
1520 }
1521}
1522
1523fn dispatch(
1524 agents: &[Agent],
1525 cfg: &Config,
1526 repo: &Repo,
1527 action: &Action,
1528 number: i64,
1529 p: &Pending,
1530) -> IssueRun {
1531 match action {
1532 Action::Recheck(pr) => review_only::recheck_pr(
1533 agents,
1534 cfg,
1535 repo,
1536 *pr,
1537 RecheckMode {
1538 dry_run: false,
1539 requested: p.triggers.contains(&Trigger::ReviewRequested),
1540 verdict_on_fallback: cfg.listen.verdicts,
1541 },
1542 ),
1543 Action::Review => review_only::review_pr(
1544 agents,
1545 cfg,
1546 repo,
1547 number,
1548 ReviewOpts {
1549 dry_run: false,
1550 verdict: cfg.listen.verdicts,
1551 },
1552 ),
1553 Action::Checkin { reply_only } => checkin::checkin_pr(
1554 agents,
1555 cfg,
1556 repo,
1557 number,
1558 &checkin::Mode {
1559 dry_run: false,
1560 reply_only: *reply_only,
1561 trust: cfg.loop_cfg.checkin_trust,
1562 again: false,
1563 resolve: cfg.loop_cfg.checkin_resolve,
1564 posts: checkin::posts(cfg),
1565 },
1566 ),
1567 Action::Answer { draft } => answer::answer_issue(
1568 agents,
1569 cfg,
1570 repo,
1571 number,
1572 &answer::Mode {
1573 dry_run: false,
1574 draft: *draft,
1575 again: false,
1576 auto: true,
1577 posts: checkin::posts(cfg),
1578 },
1579 ),
1580 Action::Run => crate::cli::run_issue(agents, cfg, repo, number),
1581 other => {
1582 let mut run = IssueRun::new(number, format!("#{number}"));
1583 run.status = Status::Error;
1584 run.notes
1585 .push(format!("nothing to run: {}", other.phrase(number)));
1586 run
1587 }
1588 }
1589}
1590
1591fn describe(repo: &Repo, event: &Event) -> String {
1593 let n = event.number;
1594 match event.trigger {
1595 Trigger::Pushed => {
1596 let (from, to) = event.head.clone().unwrap_or_default();
1597 let short = |s: &str| s.chars().take(7).collect::<String>();
1598 let commits = repo
1599 .commits_between(&from, &to)
1600 .map(|c| format!(", {c} new commit{} since the review", plural(c as usize)))
1601 .unwrap_or_default();
1602 format!(
1603 "#{n}: head moved {} to {}{commits}",
1604 short(&from),
1605 short(&to)
1606 )
1607 }
1608 Trigger::Replied => match &event.who {
1609 Some(who) => format!("#{n}: {who} commented"),
1610 None => format!("#{n}: somebody commented"),
1611 },
1612 Trigger::Mentioned => match &event.who {
1613 Some(who) => format!("#{n}: mentioned by {who}"),
1614 None => format!("#{n}: mentioned"),
1615 },
1616 Trigger::ReviewRequested if event.from_thread => {
1619 format!("#{n}: news on a thread that asked for your review")
1620 }
1621 Trigger::ReviewRequested => format!("#{n}: your review was requested"),
1622 Trigger::Assigned if event.kind == ItemKind::Issue && event.from_thread => {
1625 format!("#{n}: news on an issue assigned to you")
1626 }
1627 Trigger::Assigned => format!("#{n}: assigned to you"),
1628 Trigger::Closed => format!("#{n}: closed, no longer watched"),
1629 }
1630}
1631
1632fn plural(n: usize) -> &'static str {
1633 if n == 1 {
1634 ""
1635 } else {
1636 "s"
1637 }
1638}
1639
1640enum Ending {
1645 Interrupted,
1646 Once { failed: bool },
1647 Done,
1648}
1649
1650pub fn run(agents: &[Agent], cfg: &Config, repo: &Repo, opts: Opts) -> Result<i32> {
1652 proc::stop_on_signal()?;
1653 if !cfg!(unix) {
1654 logdim!(
1655 "Ctrl-C ends spar at once on this platform; an agent call in flight is not stopped"
1656 );
1657 }
1658 let now = clock::now_secs();
1659 let lock = Lock::acquire(repo, now)?;
1660 let viewer = repo.viewer_login()?.to_string();
1661 let name = repo
1662 .repo_name()
1663 .unwrap_or_else(|| repo.root().display().to_string());
1664
1665 let named: BTreeSet<i64> = opts.items.iter().copied().collect();
1668 let mut named_prs = Vec::new();
1669 let mut named_issues = Vec::new();
1670 for number in &named {
1671 match repo.item_kind(*number)? {
1672 ItemKind::Pr => named_prs.push(*number),
1673 ItemKind::Issue => named_issues.push(*number),
1674 }
1675 }
1676 let repo_mode = named.is_empty();
1677 let mut watching: BTreeSet<i64> = named.clone();
1682 let save = |state: &ListenState| {
1685 if !opts.dry_run {
1686 write_state(repo, state);
1687 }
1688 };
1689
1690 let mut state = read_state(repo);
1691 if start_watching(&mut state, now, opts.since) {
1692 log!("watching from {}", state.watermark);
1693 }
1694 for number in &named_prs {
1695 state.watched(*number, ItemKind::Pr);
1696 }
1697 for number in &named_issues {
1698 state.watched(*number, ItemKind::Issue);
1699 }
1700 log!(
1701 "listening to {name}{}: polling every {}s, sweeping {} every {}s. Ctrl-C to stop.",
1702 if repo_mode {
1703 String::new()
1704 } else {
1705 format!(
1706 " for {}",
1707 named
1708 .iter()
1709 .map(|n| format!("#{n}"))
1710 .collect::<Vec<_>>()
1711 .join(", ")
1712 )
1713 },
1714 cfg.listen.interval.max(FLOOR_SECS),
1715 if repo_mode {
1716 "open pull requests"
1717 } else {
1718 "the named items"
1719 },
1720 cfg.listen.sweep
1721 );
1722
1723 let started = now;
1724 let mut next_poll = now;
1725 let mut next_sweep = now;
1726 let mut backoff = Backoff::default();
1727 let mut events_seen = 0usize;
1728 let mut dispatches = 0usize;
1729 let mut tick_failed = false;
1730 let mut last_prune = now;
1731
1732 let ending = loop {
1733 if proc::interrupted() {
1734 break Ending::Interrupted;
1735 }
1736 let now = clock::now_secs();
1737 let mut events: Vec<Event> = Vec::new();
1738
1739 if now >= next_poll {
1740 match poll(repo, &cfg.listen, &mut state, now) {
1741 Polled::Nothing(interval) => {
1742 backoff.recovered();
1743 next_poll = now + interval;
1744 }
1745 Polled::Threads(threads, interval) => {
1746 backoff.recovered();
1747 events.extend(events_from_threads(&threads, &mut state));
1748 next_poll = now + interval;
1749 }
1750 Polled::RateLimited(wait) => {
1751 logwarn!(
1752 "rate limited by GitHub, waiting {} until it resets",
1753 spend::clock(Duration::from_secs(wait))
1754 );
1755 next_poll = now + wait;
1756 tick_failed = true;
1757 }
1758 Polled::Failed(why) => {
1759 next_poll = now + backoff.failed("read notifications", &why);
1760 tick_failed = true;
1761 }
1762 }
1763 }
1764
1765 if now >= next_sweep {
1766 match sweep_rows(
1767 repo,
1768 cfg,
1769 &viewer,
1770 repo_mode,
1771 &named_prs,
1772 &named_issues,
1773 &state,
1774 ) {
1775 Ok(Swept {
1776 rows,
1777 closed_issues,
1778 assigned,
1779 }) => {
1780 backoff.recovered();
1781 if !repo_mode {
1782 watching.extend(rows.iter().map(|r| r.number));
1783 }
1784 events.extend(closed_issues.into_iter().map(|number| Event {
1785 number,
1786 kind: ItemKind::Issue,
1787 trigger: Trigger::Closed,
1788 who: None,
1789 head: None,
1790 commits: None,
1791 from_thread: false,
1792 }));
1793 let record_head = |n: i64| {
1794 repo.read_review_record(n)
1795 .filter(|r| r.posted)
1796 .map(|r| r.head)
1797 };
1798 let swept = events_from_sweep(
1799 &rows,
1800 &viewer,
1801 &mut state,
1802 &named,
1803 cfg.loop_cfg.min_number,
1804 &record_head,
1805 repo_mode,
1806 );
1807 events.extend(swept);
1808 events.extend(events_from_assigned(
1809 &assigned,
1810 &viewer,
1811 &mut state,
1812 &named,
1813 cfg.loop_cfg.min_number,
1814 repo_mode,
1815 ));
1816 next_sweep = now + cfg.listen.sweep;
1817 }
1818 Err(e) => {
1819 next_sweep = now
1820 + backoff
1821 .failed("sweep the pull requests and assigned issues", e.last_line());
1822 tick_failed = true;
1823 }
1824 }
1825 }
1826
1827 for event in &events {
1831 if !repo_mode && !watching.contains(&event.number) {
1832 continue;
1833 }
1834 if event.trigger != Trigger::Closed && event.who.is_none() {
1835 }
1839 events_seen += 1;
1840 log!("{}", describe(repo, event));
1841 if event.trigger == Trigger::Closed {
1842 state.items.remove(&event.number);
1843 state.pending.remove(&event.number);
1844 continue;
1845 }
1846 state.note(event, now);
1847 }
1848 save(&state);
1849
1850 let due_now: Vec<i64> = state
1851 .pending
1852 .iter()
1853 .filter(|(n, p)| {
1854 (repo_mode || watching.contains(n))
1855 && due(p, now, &cfg.listen, opts.once)
1856 && state.items.get(n).is_none_or(|w| w.retry_after <= now)
1857 })
1858 .map(|(n, _)| *n)
1859 .collect();
1860 for number in due_now {
1861 let Some(mut p) = state.pending.get(&number).cloned() else {
1862 continue;
1863 };
1864 let named_item = named.contains(&number);
1865 let mention = p.triggers.contains(&Trigger::Mentioned);
1866 let assigned = p.kind == ItemKind::Issue && p.triggers.contains(&Trigger::Assigned);
1867 let f = match facts(repo, cfg, number, p.kind, named_item, mention, assigned) {
1868 Ok(f) => f,
1869 Err(e) => {
1870 let w = state.watched(number, p.kind);
1871 w.failures += 1;
1872 w.retry_after = now + dispatch_backoff_secs(w.failures);
1873 logwarn!("#{number}: could not read it: {}", e.last_line());
1874 tick_failed = true;
1875 save(&state);
1876 continue;
1877 }
1878 };
1879 if settle(&mut p, &f) {
1880 if f.request_answered {
1881 log!(
1882 "#{number}: the review asked of you is the one on record, so that was \
1883 only an update"
1884 );
1885 } else {
1886 log!("#{number}: no review is asked of you now, so that was only an update");
1887 }
1888 }
1889 if settle_mention(&mut p, &f) {
1890 if p.kind == ItemKind::Issue && f.linked_pr_with_record.is_none() {
1891 log!(
1892 "#{number}: nothing on it asks you anything spar has not judged, so that \
1893 was only an update"
1894 );
1895 } else {
1896 log!(
1897 "#{number}: nobody mentioned you since the review on record, so that was \
1898 only an update"
1899 );
1900 }
1901 }
1902 if settle_assignment(&mut p, &f, assignment_after(&state, number)) {
1903 if f.assigned_at.is_none() {
1904 log!("#{number}: it is not assigned to you now, so that was only an update");
1905 } else {
1906 log!(
1907 "#{number}: its assignment to you is one spar already judged, or was made \
1908 before spar was listening, so that was only an update"
1909 );
1910 }
1911 }
1912 let judged = f
1916 .assigned_at
1917 .clone()
1918 .filter(|_| p.triggers.contains(&Trigger::Assigned));
1919 if p.triggers.contains(&Trigger::ReviewRequested) || f.request_answered {
1920 state.watched(number, p.kind).requested_noticed = true;
1925 }
1926 let action = plan(&p, &f, &cfg.listen);
1927 if !action.runs() || opts.dry_run {
1928 judge_assignment(&mut state, number, judged.as_deref());
1929 }
1930 match &action {
1931 Action::Ignore(why) => {
1932 logdim!("#{number}: ignoring {why}");
1933 state.pending.remove(&number);
1934 }
1935 Action::Drop => {
1936 log!("#{number}: closed, no longer watched");
1937 state.items.remove(&number);
1938 state.pending.remove(&number);
1939 }
1940 Action::Capped(pr) => {
1941 let w = state.watched(number, p.kind);
1942 if !w.said_capped {
1943 let what = if *pr == number {
1944 "rechecked".to_string()
1945 } else {
1946 format!("its pull request #{pr} was rechecked")
1947 };
1948 log!(
1949 "#{number}: {what} {} time{} already; run `spar review {pr}` to look \
1950 again",
1951 cfg.listen.max_rechecks,
1952 plural(cfg.listen.max_rechecks as usize)
1953 );
1954 w.said_capped = true;
1955 }
1956 state.pending.remove(&number);
1957 }
1958 Action::AnswersCapped => {
1959 let w = state.watched(number, p.kind);
1960 if !w.said_capped {
1961 if cfg.listen.max_answers == 0 {
1962 log!(
1963 "#{number}: max_answers is 0, so spar listen answers nothing; run \
1964 `spar answer {number}` to answer it"
1965 );
1966 } else {
1967 log!(
1968 "#{number}: answered {} time{} already; run `spar answer {number}` \
1969 to answer again",
1970 cfg.listen.max_answers,
1971 plural(cfg.listen.max_answers as usize)
1972 );
1973 }
1974 w.said_capped = true;
1975 }
1976 state.pending.remove(&number);
1977 }
1978 Action::BelongsToResume => {
1979 let w = state.watched(number, p.kind);
1980 if !w.said_resume {
1981 log!("#{number}: in custody, `spar resume {number}` continues it");
1982 w.said_resume = true;
1983 }
1984 state.pending.remove(&number);
1985 }
1986 action if action.runs() => {
1987 if opts.dry_run {
1988 log!(
1989 "#{number}: would {} after {}, dry run so nothing runs",
1990 action.phrase(number),
1991 p.because()
1992 );
1993 state.pending.remove(&number);
1994 } else {
1995 log!(
1996 "#{number}: about to {} after {}",
1997 action.phrase(number),
1998 p.because()
1999 );
2000 let acted_on = match action {
2001 Action::Recheck(pr) => *pr,
2002 _ => number,
2003 };
2004 let record_before = repo.read_review_record(acted_on);
2005 let failed_before = repo.write_summary().failed;
2006 let run = dispatch(agents, cfg, repo, action, number, &p);
2007 dispatches += 1;
2008 let spent = spend::drain();
2009 if let Err(e) = repo.record_spend(&spent) {
2010 logdim!("could not record what this run spent: {e}");
2011 }
2012 crate::cli::print_outcome(&run, &spent);
2013 if proc::interrupted() {
2014 log!("interrupted while working on #{number}; its worktree was kept");
2015 save(&state);
2016 break;
2017 }
2018 let record_after = repo.read_review_record(acted_on);
2024 if record_after != record_before {
2025 if let Some(record) = record_after {
2026 state.watched(acted_on, ItemKind::Pr).head = Some(record.head);
2027 }
2028 }
2029 let unposted = repo.write_summary().failed > failed_before;
2033 let w = state.watched(number, p.kind);
2034 let moved = run.status == Status::Moved && !unposted;
2038 if run.status == Status::Error || moved || unposted {
2039 w.failures += 1;
2040 let wait = retry_wait(moved, w.failures, &cfg.listen);
2041 w.retry_after = clock::now_secs() + wait;
2044 let gave_up = w.failures >= MAX_FAILURES;
2045 if gave_up {
2046 logwarn!("#{number}: giving up on it until it changes again");
2047 state.give_up(number, p.kind);
2048 } else {
2049 if moved {
2050 log!(
2051 "#{number}: its head moved while spar read it, so it runs \
2052 again on the new head in {}",
2053 spend::clock(Duration::from_secs(wait))
2054 );
2055 } else if unposted {
2056 logwarn!(
2057 "#{number}: not everything it wrote reached GitHub, so it \
2058 runs again in {}",
2059 spend::clock(Duration::from_secs(wait))
2060 );
2061 }
2062 if let Some(p) = state.pending.get_mut(&number) {
2063 p.first_seen = now;
2064 }
2065 }
2066 if !moved || gave_up {
2069 tick_failed = true;
2070 }
2071 } else {
2072 w.failures = 0;
2073 w.retry_after = 0;
2074 w.last_dispatched = Some(now);
2075 state.pending.remove(&number);
2076 judge_assignment(&mut state, number, judged.as_deref());
2077 }
2078 }
2079 }
2080 _ => {}
2081 }
2082 save(&state);
2083 }
2084 if proc::interrupted() {
2085 break Ending::Interrupted;
2086 }
2087
2088 if opts.once {
2089 break Ending::Once {
2090 failed: tick_failed,
2091 };
2092 }
2093 if !repo_mode && named.iter().all(|n| !state.items.contains_key(n)) {
2094 log!("every named item is closed, nothing left to watch");
2095 break Ending::Done;
2096 }
2097 if now.saturating_sub(last_prune) >= 3600 {
2098 prune_seen(&mut state, now);
2099 last_prune = now;
2100 save(&state);
2101 }
2102
2103 let acts_on = |n: i64| repo_mode || watching.contains(&n);
2104 let next = [next_poll, next_sweep]
2105 .into_iter()
2106 .chain(next_due(&state, &cfg.listen, &acts_on))
2107 .min()
2108 .unwrap_or(now + FLOOR_SECS);
2109 let wait = next.saturating_sub(clock::now_secs()).clamp(1, 3600);
2110 if proc::sleep_unless_interrupted(Duration::from_secs(wait)) {
2111 break Ending::Interrupted;
2112 }
2113 };
2114
2115 save(&state);
2116 drop(lock);
2117 log!(
2118 "listened for {}: {events_seen} event{}, {dispatches} dispatch{}",
2119 spend::clock(Duration::from_secs(
2120 clock::now_secs().saturating_sub(started)
2121 )),
2122 plural(events_seen),
2123 if dispatches == 1 { "" } else { "es" }
2124 );
2125 let writes = crate::cli::report_writes(repo);
2126 Ok(match ending {
2127 Ending::Interrupted => 130.max(writes),
2128 Ending::Once { failed: true } => 1.max(writes),
2129 Ending::Once { failed: false } | Ending::Done => writes,
2130 })
2131}
2132
2133pub fn start_watching(state: &mut ListenState, now: u64, since: Option<Duration>) -> bool {
2137 let reset = state.watermark.is_empty() || since.is_some();
2138 if reset {
2139 let back = since.map(|d| d.as_secs()).unwrap_or(0);
2140 state.watermark = clock::rfc3339(now.saturating_sub(back));
2141 state.origin = state.watermark.clone();
2142 state.last_modified = None;
2143 }
2144 if state.origin.is_empty() {
2145 state.origin = state.watermark.clone();
2148 }
2149 reset
2150}
2151
2152fn judge_assignment(state: &mut ListenState, number: i64, at: Option<&str>) {
2154 if let Some(at) = at {
2155 state.watched(number, ItemKind::Issue).assignment_judged = Some(at.to_string());
2156 }
2157}
2158
2159fn read_state(repo: &Repo) -> ListenState {
2160 let path = repo.listen_state_path();
2161 match std::fs::read_to_string(&path) {
2162 Ok(text) => match serde_json::from_str::<ListenState>(&text) {
2163 Ok(state) => state,
2164 Err(e) => {
2165 logdim!("could not read {}: {e}. Starting fresh.", path.display());
2166 ListenState::default()
2167 }
2168 },
2169 Err(_) => ListenState::default(),
2170 }
2171}
2172
2173fn write_state(repo: &Repo, state: &ListenState) {
2174 let mut state = state.clone();
2175 state.version = STATE_VERSION;
2176 if let Err(e) = crate::repo::write_json_atomic(&repo.listen_state_path(), &state) {
2177 logdim!("could not write the listen state: {e}");
2178 }
2179}
2180
2181#[cfg(test)]
2182mod tests {
2183 use super::*;
2184 use crate::model::Author;
2185
2186 fn cfg() -> ListenCfg {
2187 ListenCfg::default()
2188 }
2189
2190 fn user(login: &str) -> Login {
2191 Login {
2192 login: login.into(),
2193 ..Login::default()
2194 }
2195 }
2196
2197 fn team(slug: &str) -> Login {
2198 Login {
2199 slug: slug.into(),
2200 ..Login::default()
2201 }
2202 }
2203
2204 fn thread(id: &str, reason: &str, kind: &str, number: i64, at: &str) -> Thread {
2205 Thread {
2206 id: id.into(),
2207 reason: reason.into(),
2208 updated_at: at.into(),
2209 subject: Subject {
2210 title: format!("thing {number}"),
2211 url: Some(format!(
2212 "https://api.github.com/repos/o/r/{}/{number}",
2213 if kind == "PullRequest" {
2214 "pulls"
2215 } else {
2216 "issues"
2217 }
2218 )),
2219 latest_comment_url: None,
2220 kind: kind.into(),
2221 },
2222 }
2223 }
2224
2225 fn row(number: i64, head: &str, author: &str, state: &str) -> PrSweepRow {
2226 PrSweepRow {
2227 number,
2228 title: format!("PR {number}"),
2229 head_ref_oid: head.into(),
2230 author: Some(Author {
2231 login: author.into(),
2232 }),
2233 state: state.into(),
2234 ..Default::default()
2235 }
2236 }
2237
2238 fn pending(kind: ItemKind, triggers: &[Trigger], now: u64) -> Pending {
2239 Pending {
2240 kind,
2241 first_seen: now,
2242 last_seen: now,
2243 triggers: triggers.iter().copied().collect(),
2244 who: Vec::new(),
2245 head_from: None,
2246 head_to: None,
2247 }
2248 }
2249
2250 fn open_pr() -> Facts {
2251 Facts {
2252 number: 7,
2253 open: true,
2254 ..Default::default()
2255 }
2256 }
2257
2258 fn response(status: u16, headers: &[(&str, &str)]) -> ApiResponse {
2259 ApiResponse {
2260 status,
2261 headers: headers
2262 .iter()
2263 .map(|(k, v)| (k.to_string(), v.to_string()))
2264 .collect(),
2265 body: String::new(),
2266 }
2267 }
2268
2269 #[test]
2272 fn the_poll_interval_is_the_largest_of_the_config_the_header_and_the_floor() {
2273 let mut c = cfg();
2274 assert_eq!(60, poll_interval(&c, None));
2275 assert_eq!(90, poll_interval(&c, Some("90")));
2276 assert_eq!(60, poll_interval(&c, Some("30")));
2277 assert_eq!(60, poll_interval(&c, Some("soon")));
2278 c.interval = 5;
2279 assert_eq!(15, poll_interval(&c, None));
2280 }
2281
2282 #[test]
2283 fn a_rate_limit_answer_says_how_long_to_wait_and_caps_it_at_an_hour() {
2284 let now = 1_000_000;
2285 assert_eq!(
2286 Some(120),
2287 rate_limit_wait(&response(429, &[("retry-after", "120")]), now)
2288 );
2289 assert_eq!(
2290 Some(600),
2291 rate_limit_wait(
2292 &response(
2293 403,
2294 &[
2295 ("x-ratelimit-remaining", "0"),
2296 ("x-ratelimit-reset", "1000600")
2297 ]
2298 ),
2299 now
2300 )
2301 );
2302 assert_eq!(
2303 Some(3600),
2304 rate_limit_wait(&response(403, &[("retry-after", "99999")]), now)
2305 );
2306 assert_eq!(
2307 Some(60),
2308 rate_limit_wait(&response(429, &[("retry-after", "1")]), now)
2309 );
2310 }
2311
2312 #[test]
2313 fn a_forbidden_answer_without_a_rate_limit_header_is_a_failure_not_a_wait() {
2314 assert_eq!(None, rate_limit_wait(&response(403, &[]), 0));
2315 assert_eq!(
2316 None,
2317 rate_limit_wait(&response(403, &[("x-ratelimit-remaining", "40")]), 0)
2318 );
2319 assert_eq!(
2320 None,
2321 rate_limit_wait(&response(200, &[("retry-after", "5")]), 0)
2322 );
2323 }
2324
2325 #[test]
2326 fn next_link_reads_the_next_page_and_nothing_else() {
2327 let mut headers = BTreeMap::new();
2328 headers.insert(
2329 "link".to_string(),
2330 "<https://api.github.com/x?page=2>; rel=\"next\", <https://api.github.com/x?page=9>; rel=\"last\"".to_string(),
2331 );
2332 assert_eq!(
2333 Some("https://api.github.com/x?page=2".to_string()),
2334 next_link(&headers)
2335 );
2336 headers.insert(
2337 "link".to_string(),
2338 "<https://api.github.com/x?page=1>; rel=\"prev\"".to_string(),
2339 );
2340 assert_eq!(None, next_link(&headers));
2341 assert_eq!(None, next_link(&BTreeMap::new()));
2342 }
2343
2344 #[test]
2347 fn a_thread_becomes_an_event_keyed_by_its_number_and_reason() {
2348 let mut state = ListenState::default();
2349 let threads = vec![
2350 thread(
2351 "1",
2352 "review_requested",
2353 "PullRequest",
2354 7,
2355 "2026-10-20T10:00:00Z",
2356 ),
2357 thread("2", "mention", "Issue", 42, "2026-10-20T10:01:00Z"),
2358 thread("3", "comment", "PullRequest", 8, "2026-10-20T10:02:00Z"),
2359 thread("4", "assign", "PullRequest", 9, "2026-10-20T10:03:00Z"),
2360 thread("5", "team_mention", "Issue", 43, "2026-10-20T10:04:00Z"),
2361 thread(
2362 "6",
2363 "state_change",
2364 "PullRequest",
2365 10,
2366 "2026-10-20T10:05:00Z",
2367 ),
2368 ];
2369 let events = events_from_threads(&threads, &mut state);
2370 let got: Vec<(i64, ItemKind, Trigger)> = events
2371 .iter()
2372 .map(|e| (e.number, e.kind, e.trigger))
2373 .collect();
2374 assert_eq!(
2375 vec![
2376 (7, ItemKind::Pr, Trigger::ReviewRequested),
2377 (42, ItemKind::Issue, Trigger::Mentioned),
2378 (8, ItemKind::Pr, Trigger::Replied),
2379 (9, ItemKind::Pr, Trigger::Assigned),
2380 (43, ItemKind::Issue, Trigger::Mentioned),
2381 (10, ItemKind::Pr, Trigger::Replied),
2382 ],
2383 got
2384 );
2385 }
2386
2387 #[test]
2388 fn a_thread_seen_at_the_same_update_time_is_not_an_event_twice() {
2389 let mut state = ListenState::default();
2390 let same = vec![thread(
2391 "1",
2392 "comment",
2393 "PullRequest",
2394 7,
2395 "2026-10-20T10:00:00Z",
2396 )];
2397 assert_eq!(1, events_from_threads(&same, &mut state).len());
2398 assert_eq!(0, events_from_threads(&same, &mut state).len());
2399 let moved = vec![thread(
2400 "1",
2401 "comment",
2402 "PullRequest",
2403 7,
2404 "2026-10-20T10:05:00Z",
2405 )];
2406 assert_eq!(1, events_from_threads(&moved, &mut state).len());
2407 }
2408
2409 #[test]
2410 fn a_thread_about_a_release_or_discussion_is_ignored() {
2411 let mut state = ListenState::default();
2412 let threads = vec![
2413 thread("1", "subscribed", "Release", 1, "2026-10-20T10:00:00Z"),
2414 thread("2", "mention", "Discussion", 2, "2026-10-20T10:01:00Z"),
2415 Thread {
2416 subject: Subject {
2417 url: None,
2418 kind: "PullRequest".into(),
2419 ..Default::default()
2420 },
2421 ..thread("3", "comment", "PullRequest", 3, "2026-10-20T10:02:00Z")
2422 },
2423 ];
2424 assert!(events_from_threads(&threads, &mut state).is_empty());
2425 assert_eq!(3, state.seen.len());
2428 }
2429
2430 #[test]
2431 fn the_watermark_advances_to_the_newest_update_seen() {
2432 let mut state = ListenState {
2433 watermark: "2026-10-20T09:00:00Z".into(),
2434 ..Default::default()
2435 };
2436 let threads = vec![
2437 thread("1", "comment", "PullRequest", 7, "2026-10-20T10:02:00Z"),
2438 thread("2", "comment", "PullRequest", 8, "2026-10-20T10:01:00Z"),
2439 ];
2440 events_from_threads(&threads, &mut state);
2441 assert_eq!("2026-10-20T10:02:00Z", state.watermark);
2442 }
2443
2444 fn no_record(_: i64) -> Option<String> {
2447 None
2448 }
2449
2450 #[test]
2451 fn a_sweep_notices_a_head_that_moved_and_says_it_once() {
2452 let mut state = ListenState::default();
2453 let named = BTreeSet::new();
2454 let first = events_from_sweep(
2455 &[row(7, "aaa", "alice", "OPEN")],
2456 "me",
2457 &mut state,
2458 &named,
2459 0,
2460 &no_record,
2461 true,
2462 );
2463 assert!(first.is_empty(), "{first:?}");
2464 let moved = events_from_sweep(
2465 &[row(7, "bbb", "alice", "OPEN")],
2466 "me",
2467 &mut state,
2468 &named,
2469 0,
2470 &no_record,
2471 true,
2472 );
2473 assert_eq!(1, moved.len());
2474 assert_eq!(Trigger::Pushed, moved[0].trigger);
2475 assert_eq!(Some(("aaa".to_string(), "bbb".to_string())), moved[0].head);
2476 let again = events_from_sweep(
2477 &[row(7, "bbb", "alice", "OPEN")],
2478 "me",
2479 &mut state,
2480 &named,
2481 0,
2482 &no_record,
2483 true,
2484 );
2485 assert!(again.is_empty(), "{again:?}");
2486 assert_eq!("alice", state.items[&7].author);
2487 }
2488
2489 #[test]
2490 fn the_first_sweep_treats_a_reviewed_pull_request_that_already_moved_as_due() {
2491 let mut state = ListenState::default();
2492 let record = |n: i64| (n == 7).then(|| "aaa".to_string());
2493 let events = events_from_sweep(
2494 &[row(7, "bbb", "alice", "OPEN"), row(8, "ccc", "bob", "OPEN")],
2495 "me",
2496 &mut state,
2497 &BTreeSet::new(),
2498 0,
2499 &record,
2500 true,
2501 );
2502 assert_eq!(1, events.len());
2503 assert_eq!(7, events[0].number);
2504 assert_eq!(Trigger::Pushed, events[0].trigger);
2505 }
2506
2507 #[test]
2508 fn a_sweep_notices_a_review_request_once_not_every_five_minutes() {
2509 let mut state = ListenState::default();
2510 let mut asked = row(7, "aaa", "alice", "OPEN");
2511 asked.review_requests = vec![user("ME")];
2512 asked.assignees = vec![user("me")];
2513 let rows = [asked];
2514 let first = events_from_sweep(
2515 &rows,
2516 "me",
2517 &mut state,
2518 &BTreeSet::new(),
2519 0,
2520 &no_record,
2521 true,
2522 );
2523 let triggers: BTreeSet<Trigger> = first.iter().map(|e| e.trigger).collect();
2524 assert_eq!(
2525 [Trigger::ReviewRequested, Trigger::Assigned]
2526 .into_iter()
2527 .collect::<BTreeSet<_>>(),
2528 triggers
2529 );
2530 let second = events_from_sweep(
2531 &rows,
2532 "me",
2533 &mut state,
2534 &BTreeSet::new(),
2535 0,
2536 &no_record,
2537 true,
2538 );
2539 assert!(second.is_empty(), "{second:?}");
2540 }
2541
2542 #[test]
2543 fn a_sweep_notices_a_review_requested_again_after_a_review() {
2544 let mut state = ListenState::default();
2545 let mut asked = row(7, "aaa", "alice", "OPEN");
2546 asked.review_requests = vec![user("me")];
2547 let asked = [asked];
2548 let reviewed = [row(7, "aaa", "alice", "OPEN")];
2549 let mut sweep = |rows: &[PrSweepRow]| -> Vec<Trigger> {
2550 events_from_sweep(
2551 rows,
2552 "me",
2553 &mut state,
2554 &BTreeSet::new(),
2555 0,
2556 &no_record,
2557 true,
2558 )
2559 .iter()
2560 .map(|e| e.trigger)
2561 .collect()
2562 };
2563 assert_eq!(vec![Trigger::ReviewRequested], sweep(&asked));
2564 assert!(sweep(&reviewed).is_empty());
2566 assert!(sweep(&reviewed).is_empty());
2567 assert_eq!(vec![Trigger::ReviewRequested], sweep(&asked));
2569 assert!(sweep(&asked).is_empty());
2570 }
2571
2572 #[test]
2573 fn a_sweep_drops_a_pull_request_that_closed_or_vanished() {
2574 let mut state = ListenState::default();
2575 let named = BTreeSet::new();
2576 events_from_sweep(
2577 &[row(7, "aaa", "alice", "OPEN"), row(8, "bbb", "bob", "OPEN")],
2578 "me",
2579 &mut state,
2580 &named,
2581 0,
2582 &no_record,
2583 true,
2584 );
2585 let events = events_from_sweep(
2586 &[row(7, "aaa", "alice", "MERGED")],
2587 "me",
2588 &mut state,
2589 &named,
2590 0,
2591 &no_record,
2592 true,
2593 );
2594 let closed: Vec<i64> = events
2595 .iter()
2596 .filter(|e| e.trigger == Trigger::Closed)
2597 .map(|e| e.number)
2598 .collect();
2599 assert_eq!(vec![7, 8], closed);
2600
2601 let mut state = ListenState::default();
2604 let named: BTreeSet<i64> = [7, 8].into_iter().collect();
2605 events_from_sweep(
2606 &[row(7, "aaa", "alice", "OPEN"), row(8, "bbb", "bob", "OPEN")],
2607 "me",
2608 &mut state,
2609 &named,
2610 0,
2611 &no_record,
2612 false,
2613 );
2614 let events = events_from_sweep(
2615 &[row(7, "aaa", "alice", "OPEN")],
2616 "me",
2617 &mut state,
2618 &named,
2619 0,
2620 &no_record,
2621 false,
2622 );
2623 assert!(events.is_empty(), "{events:?}");
2624 }
2625
2626 #[test]
2627 fn a_sweep_ignores_a_number_below_the_floor_unless_it_was_named() {
2628 let mut state = ListenState::default();
2629 let named: BTreeSet<i64> = [3].into_iter().collect();
2630 let record = |_: i64| Some("old".to_string());
2631 let events = events_from_sweep(
2632 &[
2633 row(3, "new", "alice", "OPEN"),
2634 row(4, "new", "alice", "OPEN"),
2635 row(500, "new", "alice", "OPEN"),
2636 ],
2637 "me",
2638 &mut state,
2639 &named,
2640 480,
2641 &record,
2642 true,
2643 );
2644 let numbers: Vec<i64> = events.iter().map(|e| e.number).collect();
2645 assert_eq!(vec![3, 500], numbers);
2646 assert!(!state.items.contains_key(&4));
2647 }
2648
2649 #[test]
2652 fn a_pull_request_below_the_floor_that_is_still_open_is_not_called_closed() {
2653 let mut state = ListenState::default();
2654 state.watched(4, ItemKind::Pr);
2655 let events = events_from_sweep(
2656 &[
2657 row(4, "new", "alice", "OPEN"),
2658 row(500, "new", "alice", "OPEN"),
2659 ],
2660 "me",
2661 &mut state,
2662 &BTreeSet::new(),
2663 480,
2664 &no_record,
2665 true,
2666 );
2667 assert!(
2668 events.iter().all(|e| e.trigger != Trigger::Closed),
2669 "{events:?}"
2670 );
2671 let gone = events_from_sweep(
2672 &[row(500, "new", "alice", "OPEN")],
2673 "me",
2674 &mut state,
2675 &BTreeSet::new(),
2676 480,
2677 &no_record,
2678 true,
2679 );
2680 assert_eq!(
2681 vec![(4, Trigger::Closed)],
2682 gone.iter()
2683 .map(|e| (e.number, e.trigger))
2684 .collect::<Vec<_>>()
2685 );
2686 }
2687
2688 #[test]
2691 fn a_review_request_nobody_is_still_making_is_only_an_update() {
2692 let threads = [thread(
2695 "1",
2696 "review_requested",
2697 "PullRequest",
2698 7,
2699 "2026-10-21T14:51:16Z",
2700 )];
2701 let events = events_from_threads(&threads, &mut ListenState::default());
2702 let mut waiting = BTreeMap::new();
2703 for e in &events {
2704 merge(&mut waiting, e, 0);
2705 }
2706 let reviewed = Facts {
2707 has_posted_record: true,
2708 ..open_pr()
2709 };
2710 let mut p = waiting.remove(&7).expect("pending");
2711 assert!(settle(&mut p, &reviewed));
2712 assert_eq!(
2713 [Trigger::Replied].into_iter().collect::<BTreeSet<_>>(),
2714 p.triggers
2715 );
2716 assert!(matches!(plan(&p, &reviewed, &cfg()), Action::Ignore(_)));
2717
2718 let mut p = pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0);
2720 assert!(settle(&mut p, &open_pr()));
2721 assert!(matches!(plan(&p, &open_pr(), &cfg()), Action::Ignore(_)));
2722
2723 let asked = Facts {
2725 review_pending: true,
2726 ..open_pr()
2727 };
2728 let mut p = pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0);
2729 assert!(!settle(&mut p, &asked));
2730 assert!(p.triggers.contains(&Trigger::ReviewRequested));
2731 assert_eq!(Action::Review, plan(&p, &asked, &cfg()));
2732
2733 let answered = Facts {
2736 has_posted_record: true,
2737 review_pending: true,
2738 request_answered: true,
2739 ..open_pr()
2740 };
2741 let mut p = pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0);
2742 assert!(settle(&mut p, &answered));
2743 assert_eq!(
2744 [Trigger::Replied].into_iter().collect::<BTreeSet<_>>(),
2745 p.triggers
2746 );
2747 assert!(matches!(plan(&p, &answered, &cfg()), Action::Ignore(_)));
2748
2749 let mut p = pending(ItemKind::Pr, &[Trigger::Pushed], 0);
2751 assert!(!settle(&mut p, &open_pr()));
2752 assert_eq!(
2753 [Trigger::Pushed].into_iter().collect::<BTreeSet<_>>(),
2754 p.triggers
2755 );
2756 }
2757
2758 #[test]
2759 fn a_review_request_counts_for_the_viewer_or_a_team_the_viewer_is_in() {
2760 let teams = |team: &str| match team {
2761 "example/ours" => Some(true),
2762 "example/theirs" => Some(false),
2763 _ => None,
2764 };
2765 let pending = |requests: &[Login]| review_pending(requests, "me", &teams);
2766 assert!(!pending(&[]));
2767 assert!(pending(&[user("ME")]));
2768 assert!(!pending(&[user("alice")]));
2769 assert!(pending(&[team("example/ours")]));
2770 assert!(!pending(&[team("example/theirs"), user("alice")]));
2773 assert!(pending(&[team("example/theirs"), team("example/ours")]));
2774 assert!(pending(&[team("example/unknown")]));
2776 let never = |_: &str| -> Option<bool> { panic!("looked a team up") };
2778 assert!(review_pending(
2779 &[team("example/ours"), user("me")],
2780 "me",
2781 &never
2782 ));
2783 }
2784
2785 #[test]
2789 fn a_request_made_before_the_review_on_record_was_answered_by_it() {
2790 let teams = |team: &str| match team {
2791 "example/ours" => Some(true),
2792 _ => Some(false),
2793 };
2794 let made = |at: &str, who: Login| ReviewRequested { at: at.into(), who };
2795 let answered = |requests: &[ReviewRequested]| {
2796 request_answered(requests, "me", &teams, "2026-10-20T12:00:00Z")
2797 };
2798 assert!(answered(&[made("2026-10-20T11:00:00Z", user("me"))]));
2799 assert!(answered(&[made("2026-10-20T12:00:00Z", user("ME"))]));
2800 assert!(answered(&[made(
2801 "2026-10-20T11:00:00Z",
2802 team("example/ours")
2803 )]));
2804 assert!(!answered(&[
2806 made("2026-10-20T11:00:00Z", user("me")),
2807 made("2026-10-20T13:00:00Z", user("me")),
2808 ]));
2809 assert!(!answered(&[
2810 made("2026-10-20T11:00:00Z", user("me")),
2811 made("2026-10-20T13:00:00Z", team("example/ours")),
2812 ]));
2813 assert!(answered(&[
2815 made("2026-10-20T11:00:00Z", user("me")),
2816 made("2026-10-20T13:00:00Z", user("alice")),
2817 made("2026-10-20T13:00:00Z", team("example/theirs")),
2818 ]));
2819 assert!(!answered(&[]));
2821 assert!(!answered(&[made("2026-10-20T11:00:00Z", user("alice"))]));
2822 assert!(!answered(&[made("", user("me"))]));
2823 assert!(!request_answered(
2824 &[made("2026-10-20T11:00:00Z", user("me"))],
2825 "me",
2826 &teams,
2827 ""
2828 ));
2829 }
2830
2831 #[test]
2832 fn a_review_request_or_a_mention_on_a_reviewed_pull_request_is_a_recheck() {
2833 let f = Facts {
2834 has_posted_record: true,
2835 ..open_pr()
2836 };
2837 for triggers in [
2838 &[Trigger::ReviewRequested][..],
2839 &[Trigger::Mentioned],
2840 &[Trigger::Pushed, Trigger::Replied, Trigger::ReviewRequested],
2841 ] {
2842 assert_eq!(
2843 Action::Recheck(7),
2844 plan(&pending(ItemKind::Pr, triggers, 0), &f, &cfg()),
2845 "{triggers:?}"
2846 );
2847 }
2848 }
2849
2850 #[test]
2854 fn a_push_a_reply_or_an_assignment_on_a_reviewed_pull_request_asks_for_nothing() {
2855 let f = Facts {
2856 has_posted_record: true,
2857 ..open_pr()
2858 };
2859 for triggers in [
2860 &[Trigger::Pushed][..],
2861 &[Trigger::Replied],
2862 &[Trigger::Assigned],
2863 &[Trigger::Pushed, Trigger::Replied],
2864 ] {
2865 assert!(
2866 matches!(
2867 plan(&pending(ItemKind::Pr, triggers, 0), &f, &cfg()),
2868 Action::Ignore(_)
2869 ),
2870 "{triggers:?}"
2871 );
2872 }
2873 }
2874
2875 #[test]
2876 fn a_mention_from_before_the_review_on_record_is_only_an_update() {
2877 let reviewed = Facts {
2878 has_posted_record: true,
2879 ..open_pr()
2880 };
2881 let mut p = pending(ItemKind::Pr, &[Trigger::Mentioned], 0);
2882 assert!(settle_mention(&mut p, &reviewed));
2883 assert_eq!(
2884 [Trigger::Replied].into_iter().collect::<BTreeSet<_>>(),
2885 p.triggers
2886 );
2887 assert!(matches!(plan(&p, &reviewed, &cfg()), Action::Ignore(_)));
2888
2889 let asked = Facts {
2891 mentioned: true,
2892 ..reviewed.clone()
2893 };
2894 let mut p = pending(ItemKind::Pr, &[Trigger::Mentioned], 0);
2895 assert!(!settle_mention(&mut p, &asked));
2896 assert_eq!(Action::Recheck(7), plan(&p, &asked, &cfg()));
2897
2898 let mut p = pending(ItemKind::Pr, &[Trigger::Mentioned], 0);
2901 assert!(!settle_mention(&mut p, &open_pr()));
2902 assert_eq!(Action::Review, plan(&p, &open_pr(), &cfg()));
2903
2904 let linked = Facts {
2906 number: 42,
2907 open: true,
2908 linked_pr_with_record: Some(108),
2909 ..Default::default()
2910 };
2911 let mut p = pending(ItemKind::Issue, &[Trigger::Mentioned], 0);
2912 assert!(settle_mention(&mut p, &linked));
2913 assert!(matches!(plan(&p, &linked, &cfg()), Action::Ignore(_)));
2914 let asked = Facts {
2915 mentioned: true,
2916 ..linked
2917 };
2918 let mut p = pending(ItemKind::Issue, &[Trigger::Mentioned], 0);
2919 assert!(!settle_mention(&mut p, &asked));
2920 assert_eq!(Action::Recheck(108), plan(&p, &asked, &cfg()));
2921 }
2922
2923 #[test]
2927 fn an_issue_thread_moved_by_spars_own_answer_is_only_an_update() {
2928 let judged = Facts {
2929 number: 229,
2930 open: true,
2931 ..Default::default()
2932 };
2933 let mut p = pending(ItemKind::Issue, &[Trigger::Mentioned], 0);
2934 assert!(settle_mention(&mut p, &judged));
2935 assert_eq!(
2936 [Trigger::Replied].into_iter().collect::<BTreeSet<_>>(),
2937 p.triggers
2938 );
2939 assert!(matches!(plan(&p, &judged, &cfg()), Action::Ignore(_)));
2940
2941 let asked = Facts {
2942 asks_open: true,
2943 ..judged
2944 };
2945 let mut p = pending(ItemKind::Issue, &[Trigger::Mentioned], 0);
2946 assert!(!settle_mention(&mut p, &asked));
2947 assert_eq!(Action::Answer { draft: false }, plan(&p, &asked, &cfg()));
2948 }
2949
2950 #[test]
2951 fn a_review_request_or_an_assignment_on_an_unreviewed_pull_request_follows_the_config() {
2952 let f = open_pr();
2953 let mut c = cfg();
2954 assert_eq!(
2955 Action::Review,
2956 plan(
2957 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2958 &f,
2959 &c
2960 )
2961 );
2962 assert_eq!(
2963 Action::Review,
2964 plan(&pending(ItemKind::Pr, &[Trigger::Assigned], 0), &f, &c)
2965 );
2966 assert_eq!(
2967 Action::Review,
2968 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2969 );
2970 c.on_review_requested = ReviewOrIgnore::Ignore;
2971 c.on_assigned = ReviewOrIgnore::Ignore;
2972 assert!(matches!(
2973 plan(
2974 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
2975 &f,
2976 &c
2977 ),
2978 Action::Ignore(_)
2979 ));
2980 assert!(matches!(
2981 plan(&pending(ItemKind::Pr, &[Trigger::Assigned], 0), &f, &c),
2982 Action::Ignore(_)
2983 ));
2984 assert_eq!(
2986 Action::Review,
2987 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
2988 );
2989 }
2990
2991 #[test]
2992 fn a_push_to_an_unreviewed_pull_request_is_ignored_unless_it_was_named() {
2993 let f = open_pr();
2994 assert!(matches!(
2995 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &f, &cfg()),
2996 Action::Ignore(_)
2997 ));
2998 assert!(matches!(
2999 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &cfg()),
3000 Action::Ignore(_)
3001 ));
3002 let named = Facts {
3003 named: true,
3004 ..open_pr()
3005 };
3006 assert_eq!(
3007 Action::Review,
3008 plan(
3009 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
3010 &named,
3011 &cfg()
3012 )
3013 );
3014 assert_eq!(
3015 Action::Review,
3016 plan(
3017 &pending(ItemKind::Pr, &[Trigger::Replied], 0),
3018 &named,
3019 &cfg()
3020 )
3021 );
3022 }
3023
3024 #[test]
3025 fn your_own_pull_request_follows_on_own_pr_and_a_push_to_it_is_ignored() {
3026 let f = Facts {
3027 author_is_viewer: true,
3028 has_posted_record: true,
3029 ..open_pr()
3030 };
3031 let mut c = cfg();
3032 assert_eq!(
3033 Action::Checkin { reply_only: false },
3034 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &c)
3035 );
3036 assert_eq!(
3037 Action::Checkin { reply_only: false },
3038 plan(
3039 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
3040 &f,
3041 &c
3042 )
3043 );
3044 assert!(matches!(
3045 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &f, &c),
3046 Action::Ignore(_)
3047 ));
3048 c.on_own_pr = OwnPr::Reply;
3049 assert_eq!(
3050 Action::Checkin { reply_only: true },
3051 plan(&pending(ItemKind::Pr, &[Trigger::Mentioned], 0), &f, &c)
3052 );
3053 c.on_own_pr = OwnPr::Ignore;
3054 assert!(matches!(
3055 plan(&pending(ItemKind::Pr, &[Trigger::Replied], 0), &f, &c),
3056 Action::Ignore(_)
3057 ));
3058 }
3059
3060 #[test]
3061 fn a_pull_request_in_custody_belongs_to_resume_and_a_capped_one_waits_for_a_person() {
3062 let custody = Facts {
3063 has_custody_state: true,
3064 ..open_pr()
3065 };
3066 assert_eq!(
3067 Action::BelongsToResume,
3068 plan(
3069 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
3070 &custody,
3071 &cfg()
3072 )
3073 );
3074 let capped = Facts {
3075 has_posted_record: true,
3076 recheck_capped: true,
3077 ..open_pr()
3078 };
3079 assert_eq!(
3080 Action::Capped(7),
3081 plan(
3082 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
3083 &capped,
3084 &cfg()
3085 )
3086 );
3087 assert!(matches!(
3088 plan(
3089 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
3090 &capped,
3091 &cfg()
3092 ),
3093 Action::Ignore(_)
3094 ));
3095 }
3096
3097 #[test]
3101 fn an_issue_mention_keeps_to_its_pull_requests_cap_and_owner() {
3102 let linked = Facts {
3103 number: 42,
3104 open: true,
3105 linked_pr_with_record: Some(108),
3106 ..Default::default()
3107 };
3108 let capped = Facts {
3109 recheck_capped: true,
3110 ..linked.clone()
3111 };
3112 assert_eq!(
3113 Action::Capped(108),
3114 plan(
3115 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
3116 &capped,
3117 &cfg()
3118 )
3119 );
3120 let own = Facts {
3121 author_is_viewer: true,
3122 ..linked
3123 };
3124 assert!(matches!(
3125 plan(
3126 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
3127 &own,
3128 &cfg()
3129 ),
3130 Action::Ignore(_)
3131 ));
3132 }
3133
3134 #[test]
3135 fn an_issue_mention_without_a_pull_request_is_answered_and_with_one_rechecks_it() {
3136 let issue = Facts {
3137 number: 42,
3138 open: true,
3139 ..Default::default()
3140 };
3141 assert!(matches!(
3143 plan(
3144 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
3145 &issue,
3146 &cfg()
3147 ),
3148 Action::Ignore(_)
3149 ));
3150 let asks = Facts {
3151 asks_open: true,
3152 ..issue.clone()
3153 };
3154 assert_eq!(
3155 Action::Answer { draft: false },
3156 plan(
3157 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
3158 &asks,
3159 &cfg()
3160 )
3161 );
3162 let mut c = cfg();
3163 c.on_mention = OnMention::Draft;
3164 assert_eq!(
3165 Action::Answer { draft: true },
3166 plan(
3167 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
3168 &asks,
3169 &c
3170 )
3171 );
3172 c.on_mention = OnMention::Ignore;
3173 assert_eq!(
3174 Action::Ignore("on_mention is ignore"),
3175 plan(
3176 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
3177 &asks,
3178 &c
3179 )
3180 );
3181 let capped = Facts {
3182 answers_capped: true,
3183 ..asks.clone()
3184 };
3185 assert_eq!(
3186 Action::AnswersCapped,
3187 plan(
3188 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
3189 &capped,
3190 &cfg()
3191 )
3192 );
3193 assert!(matches!(
3194 plan(
3195 &pending(ItemKind::Issue, &[Trigger::Replied], 0),
3196 &issue,
3197 &cfg()
3198 ),
3199 Action::Ignore(_)
3200 ));
3201 let linked = Facts {
3202 linked_pr_with_record: Some(108),
3203 ..issue.clone()
3204 };
3205 assert_eq!(
3206 Action::Recheck(108),
3207 plan(
3208 &pending(ItemKind::Issue, &[Trigger::Mentioned], 0),
3209 &linked,
3210 &cfg()
3211 )
3212 );
3213 let mut thread = pending(ItemKind::Issue, &[Trigger::Assigned], 0);
3218 assert!(settle_assignment(&mut thread, &linked, 0));
3219 assert!(matches!(plan(&thread, &linked, &cfg()), Action::Ignore(_)));
3220 let named = Facts {
3221 named: true,
3222 ..linked
3223 };
3224 assert!(matches!(
3225 plan(
3226 &pending(ItemKind::Issue, &[Trigger::Replied], 0),
3227 &named,
3228 &cfg()
3229 ),
3230 Action::Ignore(_)
3231 ));
3232 }
3233
3234 #[test]
3235 fn a_closed_item_is_dropped_and_one_below_the_floor_is_ignored_unless_named() {
3236 let closed = Facts {
3237 open: false,
3238 has_posted_record: true,
3239 ..open_pr()
3240 };
3241 assert_eq!(
3242 Action::Drop,
3243 plan(
3244 &pending(ItemKind::Pr, &[Trigger::Pushed], 0),
3245 &closed,
3246 &cfg()
3247 )
3248 );
3249 let f = Facts {
3250 has_posted_record: true,
3251 ..open_pr()
3252 };
3253 assert_eq!(
3254 Action::Drop,
3255 plan(
3256 &pending(ItemKind::Pr, &[Trigger::Pushed, Trigger::Closed], 0),
3257 &f,
3258 &cfg()
3259 )
3260 );
3261 let low = Facts {
3262 below_floor: true,
3263 has_posted_record: true,
3264 ..open_pr()
3265 };
3266 assert!(matches!(
3267 plan(&pending(ItemKind::Pr, &[Trigger::Pushed], 0), &low, &cfg()),
3268 Action::Ignore(_)
3269 ));
3270 let named_low = Facts { named: true, ..low };
3271 assert_eq!(
3272 Action::Recheck(7),
3273 plan(
3274 &pending(ItemKind::Pr, &[Trigger::ReviewRequested], 0),
3275 &named_low,
3276 &cfg()
3277 )
3278 );
3279 }
3280
3281 #[test]
3284 fn an_item_is_due_once_quiet_for_the_settle_time_or_at_max_wait_anyway() {
3285 let c = cfg();
3286 let mut p = pending(ItemKind::Pr, &[Trigger::Pushed], 1000);
3287 assert!(!due(&p, 1000, &c, false));
3288 assert!(!due(&p, 1000 + c.settle - 1, &c, false));
3289 assert!(due(&p, 1000 + c.settle, &c, false));
3290 p.last_seen = 1000 + c.max_wait - 30;
3292 assert!(!due(&p, 1000 + c.max_wait - 1, &c, false));
3293 assert!(due(&p, 1000 + c.max_wait, &c, false));
3294 assert!(due(&p, 1000, &c, true), "--once waits for nothing");
3295 }
3296
3297 #[test]
3298 fn events_merge_into_one_pending_record_per_item_and_the_next_due_is_the_earliest() {
3299 let c = cfg();
3300 let mut pending = BTreeMap::new();
3301 let push = Event {
3302 number: 7,
3303 kind: ItemKind::Pr,
3304 trigger: Trigger::Pushed,
3305 who: None,
3306 head: Some(("aaa".into(), "bbb".into())),
3307 commits: None,
3308 from_thread: false,
3309 };
3310 merge(&mut pending, &push, 1000);
3311 let reply = Event {
3312 trigger: Trigger::Replied,
3313 who: Some("alice".into()),
3314 head: Some(("bbb".into(), "ccc".into())),
3315 ..push.clone()
3316 };
3317 merge(&mut pending, &reply, 1060);
3318 let other = Event {
3319 number: 8,
3320 ..push.clone()
3321 };
3322 merge(&mut pending, &other, 1100);
3323 assert_eq!(2, pending.len());
3324 let p = &pending[&7];
3325 assert_eq!(1000, p.first_seen);
3326 assert_eq!(1060, p.last_seen);
3327 assert_eq!(Some("aaa".to_string()), p.head_from);
3328 assert_eq!(Some("ccc".to_string()), p.head_to);
3329 assert_eq!(vec!["alice".to_string()], p.who);
3330 assert_eq!("a push and a reply", p.because());
3331 let mut state = ListenState {
3332 pending,
3333 ..Default::default()
3334 };
3335 let all = |_: i64| true;
3336 assert_eq!(Some(1060 + c.settle), next_due(&state, &c, &all));
3337 assert_eq!(None, next_due(&ListenState::default(), &c, &all));
3338
3339 state.watched(7, ItemKind::Pr).retry_after = 5000;
3342 assert_eq!(Some(1100 + c.settle), next_due(&state, &c, &all));
3343 state.watched(8, ItemKind::Pr).retry_after = 6000;
3344 assert_eq!(Some(5000), next_due(&state, &c, &all));
3345 assert_eq!(Some(6000), next_due(&state, &c, &|n| n == 8));
3347 }
3348
3349 #[test]
3352 fn since_accepts_minutes_hours_days_and_combinations() {
3353 assert_eq!(Duration::from_secs(1800), parse_duration("30m").unwrap());
3354 assert_eq!(Duration::from_secs(7200), parse_duration("2h").unwrap());
3355 assert_eq!(Duration::from_secs(86_400), parse_duration("1d").unwrap());
3356 assert_eq!(
3357 Duration::from_secs(5400),
3358 parse_duration(" 1h30m ").unwrap()
3359 );
3360 assert_eq!(Duration::from_secs(45), parse_duration("45s").unwrap());
3361 }
3362
3363 #[test]
3364 fn since_rejects_a_bare_number_and_says_what_it_takes() {
3365 for bad in ["", "30", "2 hours", "h", "1w", "1h30"] {
3366 let err = parse_duration(bad).unwrap_err().to_string();
3367 assert!(err.contains("like 30m, 2h or 1d"), "{bad:?}: {err}");
3368 }
3369 }
3370
3371 #[test]
3372 fn listen_state_round_trips_through_json_and_old_seen_entries_are_pruned() {
3373 let mut state = ListenState {
3374 version: STATE_VERSION,
3375 watermark: "2026-10-20T10:00:00Z".into(),
3376 last_modified: Some("Tue, 20 Oct 2026 10:00:00 GMT".into()),
3377 ..Default::default()
3378 };
3379 state
3380 .seen
3381 .insert("old".into(), "2026-10-01T10:00:00Z".into());
3382 state
3383 .seen
3384 .insert("new".into(), "2026-10-20T09:00:00Z".into());
3385 state.seen.insert("junk".into(), "yesterday".into());
3386 state.watched(7, ItemKind::Pr).head = Some("aaa".into());
3387 state
3388 .pending
3389 .insert(7, pending(ItemKind::Pr, &[Trigger::Pushed], 5));
3390 let text = serde_json::to_string_pretty(&state).unwrap();
3391 assert!(text.contains("\"pushed\""), "{text}");
3392 assert!(text.contains("\"kind\": \"pr\""), "{text}");
3393 let back: ListenState = serde_json::from_str(&text).unwrap();
3394 assert_eq!(state, back);
3395
3396 let now = clock::parse_rfc3339("2026-10-20T10:00:00Z").unwrap();
3397 prune_seen(&mut state, now);
3398 let left: Vec<&String> = state.seen.keys().collect();
3399 assert_eq!(vec!["junk", "new"], left);
3400 }
3401
3402 #[test]
3403 fn gh_failures_back_off_from_a_minute_to_fifteen_and_dispatches_from_fifteen_to_six_hours() {
3404 assert_eq!(60, backoff_secs(1));
3405 assert_eq!(120, backoff_secs(2));
3406 assert_eq!(480, backoff_secs(4));
3407 assert_eq!(900, backoff_secs(5));
3408 assert_eq!(900, backoff_secs(40));
3409 assert_eq!(900, dispatch_backoff_secs(1));
3410 assert_eq!(3600, dispatch_backoff_secs(3));
3411 assert_eq!(6 * 3600, dispatch_backoff_secs(9));
3412 }
3413
3414 #[test]
3418 fn a_head_that_moved_runs_again_once_it_settles_and_a_failure_backs_off() {
3419 let c = cfg();
3420 for failures in [1, 2, 4] {
3421 assert_eq!(c.settle, retry_wait(true, failures, &c));
3422 assert_eq!(
3423 dispatch_backoff_secs(failures),
3424 retry_wait(false, failures, &c)
3425 );
3426 }
3427 let quick = ListenCfg { settle: 1, ..cfg() };
3428 assert_eq!(FLOOR_SECS, retry_wait(true, 1, &quick));
3429 }
3430
3431 #[test]
3435 fn giving_up_holds_what_the_item_was_waiting_on_until_it_changes() {
3436 let mut state = ListenState::default();
3437 let asked = Event {
3438 number: 7,
3439 kind: ItemKind::Pr,
3440 trigger: Trigger::ReviewRequested,
3441 who: None,
3442 head: None,
3443 commits: None,
3444 from_thread: true,
3445 };
3446 state.note(&asked, 0);
3447 state.give_up(7, ItemKind::Pr);
3448 assert!(state.pending.is_empty(), "{state:?}");
3449 assert_eq!(
3450 [Trigger::ReviewRequested]
3451 .into_iter()
3452 .collect::<BTreeSet<_>>(),
3453 state.items[&7].held
3454 );
3455
3456 let shown = Event {
3457 trigger: Trigger::Pushed,
3458 head: Some(("aaa".into(), "bbb".into())),
3459 from_thread: false,
3460 ..asked.clone()
3461 };
3462 state.note(&shown, 600);
3463 let p = &state.pending[&7];
3464 assert_eq!(
3465 [Trigger::Pushed, Trigger::ReviewRequested]
3466 .into_iter()
3467 .collect::<BTreeSet<_>>(),
3468 p.triggers
3469 );
3470 assert!(state.items[&7].held.is_empty(), "{state:?}");
3471 let asked_now = Facts {
3472 review_pending: true,
3473 ..open_pr()
3474 };
3475 assert_eq!(Action::Review, plan(p, &asked_now, &cfg()));
3476 state.pending.clear();
3478 state.note(&shown, 700);
3479 assert_eq!(
3480 [Trigger::Pushed].into_iter().collect::<BTreeSet<_>>(),
3481 state.pending[&7].triggers
3482 );
3483 }
3484
3485 #[test]
3486 fn number_from_url_reads_the_last_segment() {
3487 assert_eq!(
3488 Some(108),
3489 number_from_url("https://api.github.com/repos/o/r/pulls/108")
3490 );
3491 assert_eq!(
3492 Some(42),
3493 number_from_url("https://api.github.com/repos/o/r/issues/42/")
3494 );
3495 assert_eq!(
3496 None,
3497 number_from_url("https://api.github.com/repos/o/r/pulls")
3498 );
3499 }
3500
3501 #[cfg(unix)]
3502 #[test]
3503 fn a_held_lock_refuses_a_second_listener_however_old_and_a_left_file_is_taken_over() {
3504 let dir = std::env::temp_dir().join(format!(
3505 "spar-listen-lock-{}-{}",
3506 std::process::id(),
3507 clock::now_secs()
3508 ));
3509 std::fs::create_dir_all(&dir).unwrap();
3510 let path = dir.join("listen.lock");
3511
3512 let held = Lock::at(path.clone(), 1000).expect("first lock");
3515 for now in [1000, 1000 + 86_400] {
3516 let err = Lock::at(path.clone(), now).unwrap_err().to_string();
3517 assert!(err.contains("already watching"), "{err}");
3518 assert!(err.contains(&std::process::id().to_string()), "{err}");
3519 }
3520 assert!(path.exists(), "a refused listener leaves the lock alone");
3521 drop(held);
3522 assert!(!path.exists(), "dropping the lock removes the file");
3523
3524 std::fs::write(
3527 &path,
3528 serde_json::to_string(&LockFile {
3529 pid: std::process::id(),
3530 started: "x".into(),
3531 })
3532 .unwrap(),
3533 )
3534 .unwrap();
3535 let taken = Lock::at(path.clone(), 1000).expect("a left file is taken over");
3536 drop(taken);
3537 let _ = std::fs::remove_dir_all(&dir);
3538 }
3539
3540 fn assigned(number: i64, assignees: &[&str], assigned: &[(&str, &str)]) -> AssignedIssue {
3541 AssignedIssue {
3542 number,
3543 title: format!("issue {number}"),
3544 assignees: assignees.iter().map(|s| s.to_string()).collect(),
3545 assigned: assigned
3546 .iter()
3547 .map(|(at, who)| (at.to_string(), who.to_string()))
3548 .collect(),
3549 }
3550 }
3551
3552 fn listening_since(origin: &str) -> ListenState {
3553 ListenState {
3554 version: STATE_VERSION,
3555 watermark: origin.into(),
3556 origin: origin.into(),
3557 ..Default::default()
3558 }
3559 }
3560
3561 fn secs(at: &str) -> u64 {
3562 clock::parse_rfc3339(at).unwrap()
3563 }
3564
3565 #[test]
3566 fn an_assignment_made_since_listening_began_is_raised_once_and_again_when_reassigned() {
3567 let mut state = listening_since("2026-11-03T12:00:00Z");
3568 let none = BTreeSet::new();
3569 let issues = vec![
3570 assigned(10, &["me"], &[("2026-08-10T10:00:00Z", "me")]),
3572 assigned(1144, &["Me"], &[("2026-11-03T12:36:22Z", "me")]),
3574 assigned(12, &["you"], &[("2026-11-03T12:40:00Z", "you")]),
3576 assigned(13, &[], &[("2026-11-03T12:40:00Z", "me")]),
3578 ];
3579 let events = events_from_assigned(&issues, "me", &mut state, &none, 0, true);
3580 assert_eq!(1, events.len());
3581 assert_eq!(1144, events[0].number);
3582 assert_eq!(ItemKind::Issue, events[0].kind);
3583 assert_eq!(Trigger::Assigned, events[0].trigger);
3584 assert!(!events[0].from_thread);
3585 assert_eq!("issue 1144", state.items[&1144].title);
3586 assert!(!state.items.contains_key(&10));
3588
3589 assert!(events_from_assigned(&issues, "me", &mut state, &none, 0, true).is_empty());
3591
3592 let again = vec![assigned(
3594 1144,
3595 &["me"],
3596 &[
3597 ("2026-11-03T12:36:22Z", "me"),
3598 ("2026-11-03T15:00:00Z", "me"),
3599 ],
3600 )];
3601 let events = events_from_assigned(&again, "me", &mut state, &none, 0, true);
3602 assert_eq!(
3603 vec![1144],
3604 events.iter().map(|e| e.number).collect::<Vec<_>>()
3605 );
3606
3607 let mut judged = listening_since("2026-11-03T12:00:00Z");
3610 judged.watched(1144, ItemKind::Issue).assignment_judged =
3611 Some("2026-11-03T12:36:22Z".into());
3612 assert!(events_from_assigned(&issues, "me", &mut judged, &none, 0, true).is_empty());
3613 }
3614
3615 #[test]
3616 fn an_assignment_below_the_floor_or_outside_the_named_items_is_not_raised() {
3617 let issues = vec![
3618 assigned(5, &["me"], &[("2026-11-03T12:36:22Z", "me")]),
3619 assigned(1144, &["me"], &[("2026-11-03T12:36:22Z", "me")]),
3620 ];
3621 let mut state = listening_since("2026-11-03T12:00:00Z");
3622 let events = events_from_assigned(&issues, "me", &mut state, &BTreeSet::new(), 100, true);
3623 assert_eq!(
3624 vec![1144],
3625 events.iter().map(|e| e.number).collect::<Vec<_>>()
3626 );
3627
3628 let mut state = listening_since("2026-11-03T12:00:00Z");
3632 let named: BTreeSet<i64> = [5].into_iter().collect();
3633 let events = events_from_assigned(&issues, "me", &mut state, &named, 100, false);
3634 assert_eq!(vec![5], events.iter().map(|e| e.number).collect::<Vec<_>>());
3635 assert!(!state.items.contains_key(&1144));
3636 }
3637
3638 #[test]
3639 fn an_issue_assignment_counts_only_when_newer_than_listening_and_the_last_one_judged() {
3640 let mut state = listening_since("2026-11-03T12:00:00Z");
3641 assert_eq!(secs("2026-11-03T12:00:00Z"), assignment_after(&state, 1144));
3642 state.watched(1144, ItemKind::Issue).assignment_judged =
3643 Some("2026-11-03T12:36:22Z".into());
3644 assert_eq!(secs("2026-11-03T12:36:22Z"), assignment_after(&state, 1144));
3645 state.watched(7, ItemKind::Issue).assignment_judged = Some("2026-08-01T00:00:00Z".into());
3647 assert_eq!(secs("2026-11-03T12:00:00Z"), assignment_after(&state, 7));
3648 assert_eq!(0, assignment_after(&ListenState::default(), 7));
3649
3650 let after = secs("2026-11-03T12:00:00Z");
3651 let fresh = Facts {
3652 number: 1144,
3653 open: true,
3654 assigned_at: Some("2026-11-03T12:36:22Z".into()),
3655 ..Default::default()
3656 };
3657 let mut p = pending(ItemKind::Issue, &[Trigger::Assigned], 0);
3658 assert!(!settle_assignment(&mut p, &fresh, after));
3659 assert!(p.triggers.contains(&Trigger::Assigned));
3660
3661 let mut p = pending(ItemKind::Issue, &[Trigger::Assigned], 0);
3663 assert!(settle_assignment(
3664 &mut p,
3665 &fresh,
3666 secs("2026-11-03T12:36:22Z")
3667 ));
3668 assert_eq!(
3669 vec![Trigger::Replied],
3670 p.triggers.iter().copied().collect::<Vec<_>>()
3671 );
3672
3673 let gone = Facts {
3675 assigned_at: None,
3676 ..fresh.clone()
3677 };
3678 let mut p = pending(ItemKind::Issue, &[Trigger::Assigned, Trigger::Mentioned], 0);
3679 assert!(settle_assignment(&mut p, &gone, after));
3680 assert!(p.triggers.contains(&Trigger::Mentioned));
3681 assert!(!p.triggers.contains(&Trigger::Assigned));
3682
3683 let mut p = pending(ItemKind::Pr, &[Trigger::Assigned], 0);
3685 assert!(!settle_assignment(&mut p, &gone, after));
3686 assert!(p.triggers.contains(&Trigger::Assigned));
3687 }
3688
3689 #[test]
3690 fn an_issue_assigned_to_you_gets_spar_run_unless_the_config_or_somebody_elses_pr_says_no() {
3691 let issue = Facts {
3692 number: 1144,
3693 open: true,
3694 assigned_at: Some("2026-11-03T12:36:22Z".into()),
3695 ..Default::default()
3696 };
3697 let assignment = pending(ItemKind::Issue, &[Trigger::Assigned], 0);
3698 assert_eq!(Action::Run, plan(&assignment, &issue, &cfg()));
3699 assert_eq!("work it with `spar run`", Action::Run.phrase(1144));
3700 assert!(Action::Run.runs());
3701 let own = Facts {
3704 linked_pr_with_record: Some(1147),
3705 ..issue.clone()
3706 };
3707 assert_eq!(Action::Run, plan(&assignment, &own, &cfg()));
3708
3709 let mut off = cfg();
3710 off.on_assigned_issue = RunOrIgnore::Ignore;
3711 assert_eq!(
3712 Action::Ignore("on_assigned_issue is ignore"),
3713 plan(&assignment, &issue, &off)
3714 );
3715 let others = Facts {
3716 others_pr: true,
3717 ..issue.clone()
3718 };
3719 assert_eq!(
3720 Action::Ignore("an issue whose open pull request somebody else wrote"),
3721 plan(&assignment, &others, &cfg())
3722 );
3723
3724 let both = pending(ItemKind::Issue, &[Trigger::Assigned, Trigger::Mentioned], 0);
3726 let asks = Facts {
3727 asks_open: true,
3728 ..issue.clone()
3729 };
3730 assert_eq!(Action::Run, plan(&both, &asks, &cfg()));
3731 assert_eq!(Action::Answer { draft: false }, plan(&both, &asks, &off));
3733
3734 let closed = Facts {
3736 open: false,
3737 ..issue.clone()
3738 };
3739 assert_eq!(Action::Drop, plan(&assignment, &closed, &cfg()));
3740 let low = Facts {
3741 below_floor: true,
3742 ..issue
3743 };
3744 assert!(matches!(plan(&assignment, &low, &cfg()), Action::Ignore(_)));
3745 }
3746
3747 #[test]
3748 fn watching_starts_where_it_left_off_and_an_old_state_takes_its_watermark_as_its_origin() {
3749 let now = secs("2026-11-03T15:00:00Z");
3750 let mut fresh = ListenState::default();
3751 assert!(start_watching(&mut fresh, now, None));
3752 assert_eq!("2026-11-03T15:00:00Z", fresh.watermark);
3753 assert_eq!(fresh.watermark, fresh.origin);
3754
3755 let text = r#"{"version": 1, "watermark": "2026-11-03T14:22:36Z"}"#;
3757 let mut old: ListenState = serde_json::from_str(text).unwrap();
3758 assert!(old.origin.is_empty());
3759 assert!(!start_watching(&mut old, now, None));
3760 assert_eq!("2026-11-03T14:22:36Z", old.watermark);
3761 assert_eq!("2026-11-03T14:22:36Z", old.origin);
3762
3763 let mut kept = listening_since("2026-11-01T00:00:00Z");
3765 kept.watermark = "2026-11-03T14:22:36Z".into();
3766 assert!(!start_watching(&mut kept, now, None));
3767 assert_eq!("2026-11-01T00:00:00Z", kept.origin);
3768
3769 assert!(start_watching(
3771 &mut kept,
3772 now,
3773 Some(Duration::from_secs(8 * 3600))
3774 ));
3775 assert_eq!("2026-11-03T07:00:00Z", kept.watermark);
3776 assert_eq!("2026-11-03T07:00:00Z", kept.origin);
3777
3778 let mut state = listening_since("2026-11-03T12:00:00Z");
3779 state.watched(1144, ItemKind::Issue).assignment_judged =
3780 Some("2026-11-03T12:36:22Z".into());
3781 state.watched(1144, ItemKind::Issue).assignment_raised =
3782 Some("2026-11-03T12:36:22Z".into());
3783 let back: ListenState =
3784 serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
3785 assert_eq!(state, back);
3786 }
3787}