Skip to main content

spar/
listen.rs

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