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