Skip to main content

spar/
listen.rs

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