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