Skip to main content

spar/
listen.rs

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