Skip to main content

magi/
dupes.rs

1//! Duplicate-work detection at the moment work is filed.
2//!
3//! Two tasks once chased the same commit without knowing about each other: an
4//! open task and its open pull request already covered a change when a chat
5//! filed a second task that named the same branch and commit. Nothing at
6//! filing time said so. This module is that check, and it is deliberately
7//! small: **concrete identifiers only** - a branch name, a commit SHA, a pull
8//! request number - never text similarity. A false positive costs one
9//! `--force`; a false negative is the behaviour before this module existed.
10//!
11//! A *claim* is a thing an unfinished piece of work owns: the branches of its
12//! candidates and the pull request it opened. The claims come from
13//!
14//! - every task that is not `done`: its `review_branch`, and each run in
15//!   `Task::runs`;
16//! - every run that has not reached a terminal status;
17//! - every run that is terminal but whose pull request is still open (the
18//!   shape of the original accident: the run ended, the PR did not).
19//!
20//! Run records are read through a tolerant view rather than `RunState::load`:
21//! a schema bump must not turn into a silent false negative (the same lesson
22//! as `clean::fold_due`).
23//!
24//! Everything here is synchronous, read-only and takes its roots as
25//! arguments, so tests need no process-global home.
26
27use std::collections::{BTreeSet, HashMap};
28use std::fmt;
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31
32use serde::Deserialize;
33
34use crate::proc::Quiet as _;
35use crate::queue::{Queue, Task, TaskStatus};
36use crate::run::RunStatus;
37
38/// What kind of identifier matched.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Signal {
41    /// A branch name.
42    Branch,
43    /// A commit SHA.
44    Sha,
45    /// A pull request number or URL.
46    Pr,
47}
48
49/// Who owns the thing that matched.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum Owner {
52    /// A queued task.
53    Task,
54    /// A recorded run.
55    Run,
56    /// A pull request the forge reports open that no record here owns.
57    Pr,
58}
59
60/// One reason a new piece of work looks like one already under way.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Hit {
63    /// Task or run.
64    pub owner: Owner,
65    /// Full id of the task or run.
66    pub id: String,
67    /// The owner's status word (`queued`, `reviewing`, ...).
68    pub status: String,
69    /// What kind of identifier matched.
70    pub signal: Signal,
71    /// The identifier as it matched: a branch, a SHA, `#48`.
72    pub token: String,
73    /// How the owner is tied to it, e.g. `produced by its run c9eb`.
74    pub via: String,
75}
76
77impl fmt::Display for Hit {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        let kind = match self.owner {
80            Owner::Task => "task",
81            Owner::Run => "run",
82            Owner::Pr => "pull request",
83        };
84        let what = match self.signal {
85            Signal::Branch => "names branch",
86            Signal::Sha => "names commit",
87            Signal::Pr => "names pull request",
88        };
89        write!(
90            f,
91            "{kind} {} ({}): this work {what} {}, {}",
92            crate::queue::short(&self.id),
93            self.status,
94            self.token,
95            self.via
96        )
97    }
98}
99
100/// The refusal: one or more [`Hit`]s and nothing filed.
101#[derive(Debug, Clone)]
102pub struct Duplicate(pub Vec<Hit>);
103
104impl Duplicate {
105    /// The refusal text, ending in how to override it.
106    pub fn render(&self, override_hint: &str) -> String {
107        let mut out = String::from("this looks like work that is already in flight:");
108        for h in &self.0 {
109            out.push_str("\n  - ");
110            out.push_str(&h.to_string());
111        }
112        out.push('\n');
113        out.push_str(override_hint);
114        out
115    }
116}
117
118impl fmt::Display for Duplicate {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(&self.render(
121            "If it is not a duplicate, pass --force to file it anyway \
122             (an agent should report this to the operator instead).",
123        ))
124    }
125}
126
127impl std::error::Error for Duplicate {}
128
129/// The slice of `run.json` this module needs. Every field is optional so a
130/// record from any schema still yields what it has.
131#[derive(Debug, Default, Deserialize)]
132struct RunView {
133    #[serde(default)]
134    id: String,
135    #[serde(default)]
136    repo: PathBuf,
137    #[serde(default)]
138    status: String,
139    #[serde(default)]
140    base_commit: String,
141    #[serde(default)]
142    candidates: Vec<CandView>,
143    #[serde(default)]
144    pr: Option<PrView>,
145}
146
147#[derive(Debug, Default, Deserialize)]
148struct CandView {
149    #[serde(default)]
150    branch: String,
151}
152
153#[derive(Debug, Default, Deserialize)]
154struct PrView {
155    #[serde(default)]
156    url: String,
157    #[serde(default)]
158    number: u64,
159    #[serde(default)]
160    state: String,
161}
162
163impl RunView {
164    fn read(runs_root: &Path, id: &str) -> Option<Self> {
165        let raw = std::fs::read_to_string(runs_root.join(id).join("run.json")).ok()?;
166        serde_json::from_str(&raw).ok()
167    }
168
169    /// An unknown status word counts as unfinished: claiming too much is the
170    /// cheap error here.
171    fn terminal(&self) -> bool {
172        serde_json::from_value::<RunStatus>(serde_json::Value::String(self.status.clone()))
173            .map(RunStatus::done)
174            .unwrap_or(false)
175    }
176
177    fn pr_open(&self) -> bool {
178        self.pr.as_ref().is_some_and(|p| p.state == "open")
179    }
180}
181
182/// Something an unfinished piece of work owns.
183#[derive(Debug, Clone)]
184struct Claim {
185    owner: Owner,
186    id: String,
187    status: String,
188    via: String,
189    branch: Option<String>,
190    /// Commit the branch forked from; without it a SHA cannot be judged.
191    base: Option<String>,
192    /// `(number, url)` of an open pull request.
193    pr: Option<(u64, String)>,
194}
195
196/// Look for work already in flight that `text` (and `review_branch`, for a
197/// review-only run) points at. `repo` is the repository the new work is for;
198/// `ignore_task` is a task being edited, whose own claims never count.
199pub fn check(
200    queue: &Queue,
201    runs_root: &Path,
202    repo: &Path,
203    text: &str,
204    review_branch: Option<&str>,
205    ignore_task: Option<&str>,
206) -> Vec<Hit> {
207    check_with(
208        queue,
209        runs_root,
210        repo,
211        text,
212        review_branch,
213        ignore_task,
214        &gh_open_pr,
215    )
216}
217
218/// [`check`] with the forge lookup supplied: `open_pr(repo, n)` says whether
219/// pull request `n` of `repo` is open (`Some(url)`) or not / unknown (`None`).
220/// It is asked only about numbers the text names that no local record already
221/// explained, so a PR magi never produced (opened by hand) still collides.
222pub fn check_with(
223    queue: &Queue,
224    runs_root: &Path,
225    repo: &Path,
226    text: &str,
227    review_branch: Option<&str>,
228    ignore_task: Option<&str>,
229    open_pr: &dyn Fn(&Path, u64) -> Option<String>,
230) -> Vec<Hit> {
231    let mut idents = Idents::default();
232    let here = idents.of(repo);
233    let tasks = queue.list();
234    let own_runs: BTreeSet<String> = tasks
235        .iter()
236        .filter(|t| Some(t.id.as_str()) == ignore_task)
237        .flat_map(|t| t.runs.iter().cloned())
238        .collect();
239    // PRs the edited task's own runs opened: never a rival, forge or not.
240    let own_prs: BTreeSet<u64> = own_runs
241        .iter()
242        .filter_map(|id| RunView::read(runs_root, id))
243        .filter_map(|v| v.pr.map(|p| p.number))
244        .collect();
245
246    let mut claims: Vec<Claim> = Vec::new();
247    let mut from_task: BTreeSet<String> = BTreeSet::new();
248    for t in tasks
249        .iter()
250        .filter(|t| t.status != TaskStatus::Done && Some(t.id.as_str()) != ignore_task)
251        .filter(|t| idents.of(&t.repo) == here)
252    {
253        claims.extend(task_claims(t, runs_root, &mut from_task));
254    }
255    for id in crate::run::list_ids_in(runs_root) {
256        if own_runs.contains(&id) {
257            continue;
258        }
259        let Some(view) = RunView::read(runs_root, &id) else {
260            continue;
261        };
262        if (view.terminal() && !view.pr_open()) || idents.of(&view.repo) != here {
263            continue;
264        }
265        claims.extend(run_claims(&view, Owner::Run, None, "its own run"));
266    }
267
268    let mut hits: Vec<Hit> = Vec::new();
269    let mut push = |c: &Claim, signal: Signal, token: String| {
270        let hit = Hit {
271            owner: c.owner.clone(),
272            id: c.id.clone(),
273            status: c.status.clone(),
274            signal,
275            token,
276            via: c.via.clone(),
277        };
278        if !hits.contains(&hit) {
279            hits.push(hit);
280        }
281    };
282
283    let prs = pr_numbers(text);
284    let shas = sha_candidates(repo, text);
285    for c in &claims {
286        if let Some(b) = &c.branch {
287            if names_branch(text, b) || review_branch == Some(b.as_str()) {
288                push(c, Signal::Branch, b.clone());
289            }
290            if let Some(base) = &c.base {
291                for sha in &shas {
292                    if on_branch_only(repo, sha, b, base) {
293                        push(c, Signal::Sha, short_sha(sha));
294                    }
295                }
296            }
297        }
298        if let Some((n, url)) = &c.pr {
299            if prs.contains(&Mention::Number(*n))
300                || prs.iter().any(|p| matches!(p, Mention::Url(u) if u == url))
301            {
302                push(c, Signal::Pr, format!("#{n}"));
303            }
304        }
305    }
306    let mut asked = BTreeSet::new();
307    for n in prs.iter().filter_map(|m| match m {
308        Mention::Number(n) => Some(*n),
309        Mention::Url(u) => u.rsplit('/').next().and_then(|d| d.parse().ok()),
310    }) {
311        let token = format!("#{n}");
312        if hits
313            .iter()
314            .any(|h| h.signal == Signal::Pr && h.token == token)
315            || own_prs.contains(&n)
316            || !asked.insert(n)
317        {
318            continue;
319        }
320        if let Some(url) = open_pr(repo, n) {
321            hits.push(Hit {
322                owner: Owner::Pr,
323                id: token.clone(),
324                status: "open".into(),
325                signal: Signal::Pr,
326                token,
327                via: format!("an open pull request with no run record here ({url})"),
328            });
329        }
330    }
331    hits
332}
333
334/// Ask the forge, via `gh`, whether PR `n` is open. Best effort and bounded:
335/// any failure (no `gh`, no remote, offline, a slow answer) is `None`, i.e.
336/// today's behaviour. `GH_REPO` is dropped so the PR is looked up in `repo`'s
337/// own remote, not whatever the environment points at.
338fn gh_open_pr(repo: &Path, n: u64) -> Option<String> {
339    let mut child = Command::new("gh")
340        .quiet()
341        .args(["pr", "view", &n.to_string(), "--json", "state,url"])
342        .current_dir(repo)
343        .env_remove("GH_REPO")
344        .env("GH_PROMPT_DISABLED", "1")
345        .stdin(Stdio::null())
346        .stdout(Stdio::piped())
347        .stderr(Stdio::null())
348        .spawn()
349        .ok()?;
350    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
351    loop {
352        match child.try_wait().ok()? {
353            Some(status) if status.success() => break,
354            Some(_) => return None,
355            None if std::time::Instant::now() >= deadline => {
356                let _ = child.kill();
357                let _ = child.wait();
358                return None;
359            }
360            None => std::thread::sleep(std::time::Duration::from_millis(50)),
361        }
362    }
363    let mut raw = String::new();
364    std::io::Read::read_to_string(&mut child.stdout.take()?, &mut raw).ok()?;
365    let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
366    (v["state"] == "OPEN")
367        .then(|| v["url"].as_str().map(str::to_owned))
368        .flatten()
369}
370
371fn task_claims(t: &Task, runs_root: &Path, seen: &mut BTreeSet<String>) -> Vec<Claim> {
372    let status = t.status.as_str().to_owned();
373    let mut out = Vec::new();
374    if let Some(b) = &t.review_branch {
375        out.push(Claim {
376            owner: Owner::Task,
377            id: t.id.clone(),
378            status: status.clone(),
379            via: "its review branch".into(),
380            branch: Some(b.clone()),
381            base: None,
382            pr: None,
383        });
384    }
385    for rid in &t.runs {
386        if let Some(view) = RunView::read(runs_root, rid) {
387            seen.insert(rid.clone());
388            // A merged or closed pull request is not in flight, even when the
389            // task that owns it is.
390            out.extend(run_claims(
391                &view,
392                Owner::Task,
393                Some((&t.id, &status)),
394                &format!("produced by its run {}", crate::queue::short(rid)),
395            ));
396        }
397    }
398    out
399}
400
401/// The claims of one run, attributed to `owner` (the task, when there is one).
402fn run_claims(view: &RunView, owner: Owner, task: Option<(&str, &str)>, via: &str) -> Vec<Claim> {
403    let (id, status) = match task {
404        Some((id, status)) => (id.to_owned(), status.to_owned()),
405        None => (view.id.clone(), view.status.clone()),
406    };
407    let pr = view
408        .pr
409        .as_ref()
410        .filter(|p| p.state == "open" && p.number > 0)
411        .map(|p| (p.number, p.url.clone()));
412    let via_pr = |extra: &str| match &pr {
413        Some((n, _)) => format!("{via} (PR #{n} open){extra}"),
414        None => format!("{via}{extra}"),
415    };
416    let mut out: Vec<Claim> = view
417        .candidates
418        .iter()
419        .filter(|c| !c.branch.is_empty())
420        .map(|c| Claim {
421            owner: owner.clone(),
422            id: id.clone(),
423            status: status.clone(),
424            via: via_pr(""),
425            branch: Some(c.branch.clone()),
426            base: (!view.base_commit.is_empty()).then(|| view.base_commit.clone()),
427            pr: None,
428        })
429        .collect();
430    if pr.is_some() {
431        out.push(Claim {
432            owner,
433            id,
434            status,
435            via: via_pr(""),
436            branch: None,
437            base: None,
438            pr,
439        });
440    }
441    out
442}
443
444/// Repository identity: the canonical git common dir, so two worktrees of one
445/// repository are the same repository and a path spelled two ways is too.
446#[derive(Default)]
447struct Idents(HashMap<PathBuf, PathBuf>);
448
449impl Idents {
450    fn of(&mut self, path: &Path) -> PathBuf {
451        self.0
452            .entry(path.to_path_buf())
453            .or_insert_with(|| {
454                git(
455                    path,
456                    &["rev-parse", "--path-format=absolute", "--git-common-dir"],
457                )
458                .map(PathBuf::from)
459                .and_then(|p| p.canonicalize().ok())
460                .or_else(|| path.canonicalize().ok())
461                .unwrap_or_else(|| path.to_path_buf())
462            })
463            .clone()
464    }
465}
466
467fn git(cwd: &Path, args: &[&str]) -> Option<String> {
468    let out = Command::new("git")
469        .quiet()
470        .args(args)
471        .current_dir(cwd)
472        .stdin(Stdio::null())
473        .stderr(Stdio::null())
474        .env("GIT_TERMINAL_PROMPT", "0")
475        .output()
476        .ok()?;
477    out.status
478        .success()
479        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned())
480}
481
482fn git_ok(cwd: &Path, args: &[&str]) -> bool {
483    git(cwd, args).is_some()
484}
485
486fn is_ref_char(c: char) -> bool {
487    c.is_alphanumeric() || matches!(c, '_' | '-')
488}
489
490/// Does `text` contain `branch` as a whole word? `.` and `/` before it are
491/// fine (`origin/magi/x/A`), a name character on either side is not, so
492/// `magi/27b2/A` does not match inside `magi/27b2/AB`.
493fn names_branch(text: &str, branch: &str) -> bool {
494    if branch.len() < 3 {
495        return false;
496    }
497    text.match_indices(branch).any(|(i, _)| {
498        let before = text[..i].chars().next_back();
499        let after = text[i + branch.len()..].chars().next();
500        let after_ok = match after {
501            None => true,
502            Some('/') => false,
503            Some('.') => !text[i + branch.len() + 1..]
504                .chars()
505                .next()
506                .is_some_and(is_ref_char),
507            Some(c) => !is_ref_char(c),
508        };
509        before.is_none_or(|c| !is_ref_char(c) && c != '.') && after_ok
510    })
511}
512
513#[derive(Debug, PartialEq, Eq)]
514enum Mention {
515    Number(u64),
516    Url(String),
517}
518
519/// `#48`, `PR 48`, `PR #48`, `pull request 48`, and `.../pull/48` URLs.
520fn pr_numbers(text: &str) -> Vec<Mention> {
521    let mut out = Vec::new();
522    let bytes = text.as_bytes();
523    let digits = |from: usize| -> Option<(u64, usize)> {
524        let n = text[from..].bytes().take_while(u8::is_ascii_digit).count();
525        (n > 0 && n < 10)
526            .then(|| text[from..from + n].parse().ok().map(|v| (v, from + n)))
527            .flatten()
528    };
529    for (i, _) in text.match_indices('#') {
530        if let Some((n, _)) = digits(i + 1) {
531            let word_before = i > 0 && is_ref_char(bytes[i - 1] as char);
532            if !word_before {
533                out.push(Mention::Number(n));
534            }
535        }
536    }
537    let lower = text.to_ascii_lowercase();
538    for key in ["pull request ", "pr "] {
539        for (i, _) in lower.match_indices(key) {
540            if i > 0 && is_ref_char(bytes[i - 1] as char) {
541                continue;
542            }
543            let from = i + key.len();
544            let from = if text[from..].starts_with('#') {
545                from + 1
546            } else {
547                from
548            };
549            if let Some((n, _)) = digits(from) {
550                out.push(Mention::Number(n));
551            }
552        }
553    }
554    for (i, _) in text.match_indices("/pull/") {
555        if let Some((_, end)) = digits(i + 6) {
556            let start = text[..i]
557                .rfind(|c: char| c.is_whitespace() || matches!(c, '(' | '<' | '"' | '\''))
558                .map_or(0, |p| p + 1);
559            out.push(Mention::Url(text[start..end].to_owned()));
560        }
561    }
562    out
563}
564
565/// Hex words of 7..=40 chars in `text` that resolve to a commit in `repo`.
566fn sha_candidates(repo: &Path, text: &str) -> Vec<String> {
567    let mut seen = BTreeSet::new();
568    let mut out = Vec::new();
569    for word in text.split(|c: char| !c.is_ascii_alphanumeric()) {
570        if !(7..=40).contains(&word.len()) || !word.bytes().all(|b| b.is_ascii_hexdigit()) {
571            continue;
572        }
573        if seen.len() >= 16 || !seen.insert(word.to_ascii_lowercase()) {
574            continue;
575        }
576        if let Some(full) = git(
577            repo,
578            &[
579                "rev-parse",
580                "--verify",
581                "--quiet",
582                &format!("{word}^{{commit}}"),
583            ],
584        ) {
585            out.push(full);
586        }
587    }
588    out
589}
590
591/// Is `sha` reachable from `branch` but not from `base`? A SHA already in the
592/// base would match every branch there is.
593fn on_branch_only(repo: &Path, sha: &str, branch: &str, base: &str) -> bool {
594    let tip = format!("{branch}^{{commit}}");
595    git_ok(repo, &["rev-parse", "--verify", "--quiet", &tip])
596        && git_ok(repo, &["merge-base", "--is-ancestor", sha, branch])
597        && !git_ok(repo, &["merge-base", "--is-ancestor", sha, base])
598}
599
600fn short_sha(sha: &str) -> String {
601    sha.chars().take(7).collect()
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use crate::queue::{Source, Task};
608
609    struct Fx {
610        _tmp: tempfile::TempDir,
611        repo: PathBuf,
612        runs: PathBuf,
613        q: Queue,
614    }
615
616    fn sh(cwd: &Path, args: &[&str]) -> String {
617        let out = Command::new("git")
618            .quiet()
619            .args(["-c", "user.name=t", "-c", "user.email=t@t"])
620            .args(args)
621            .current_dir(cwd)
622            .output()
623            .unwrap();
624        assert!(out.status.success(), "git {args:?}: {out:?}");
625        String::from_utf8_lossy(&out.stdout).trim().to_owned()
626    }
627
628    /// A repo with `main` and a branch `magi/aaaa/A` one commit ahead.
629    /// Returns the fixture plus the (main, branch) tip SHAs.
630    fn fx() -> (Fx, String, String) {
631        let tmp = tempfile::tempdir().unwrap();
632        let repo = tmp.path().join("repo");
633        std::fs::create_dir_all(&repo).unwrap();
634        sh(&repo, &["init", "-q", "-b", "main"]);
635        std::fs::write(repo.join("a"), "1").unwrap();
636        sh(&repo, &["add", "."]);
637        sh(&repo, &["commit", "-q", "-m", "base"]);
638        let base = sh(&repo, &["rev-parse", "HEAD"]);
639        sh(&repo, &["checkout", "-q", "-b", "magi/aaaa/A"]);
640        std::fs::write(repo.join("a"), "2").unwrap();
641        sh(&repo, &["commit", "-q", "-am", "work"]);
642        let tip = sh(&repo, &["rev-parse", "HEAD"]);
643        sh(&repo, &["checkout", "-q", "main"]);
644        let runs = tmp.path().join("runs");
645        std::fs::create_dir_all(&runs).unwrap();
646        let q = Queue::at(tmp.path().join("queue"));
647        (
648            Fx {
649                _tmp: tmp,
650                repo,
651                runs,
652                q,
653            },
654            base,
655            tip,
656        )
657    }
658
659    fn write_run(f: &Fx, id: &str, status: &str, base: &str, pr: Option<(u64, &str)>) {
660        let dir = f.runs.join(id);
661        std::fs::create_dir_all(&dir).unwrap();
662        let pr = pr.map(|(n, s)| {
663            serde_json::json!({"url": format!("https://github.com/o/r/pull/{n}"), "number": n, "state": s})
664        });
665        let v = serde_json::json!({
666            "schema": 999, "id": id, "repo": f.repo, "status": status,
667            "base_commit": base, "candidates": [{"branch": "magi/aaaa/A"}], "pr": pr,
668        });
669        std::fs::write(dir.join("run.json"), v.to_string()).unwrap();
670    }
671
672    fn file_task(f: &Fx, status: TaskStatus, runs: &[&str]) -> Task {
673        let mut t = Task::new("t".into(), "x".into(), f.repo.clone(), Source::Human);
674        t.status = status;
675        t.runs = runs.iter().map(|s| (*s).to_owned()).collect();
676        f.q.put(&mut t).unwrap();
677        t
678    }
679
680    const RID: &str = "20260901-100000-aaaa";
681
682    fn run(f: &Fx, text: &str, review: Option<&str>) -> Vec<Hit> {
683        check_with(&f.q, &f.runs, &f.repo, text, review, None, &|_, _| None)
684    }
685
686    #[test]
687    fn branch_matches_a_live_run_and_its_task() {
688        let (f, base, _) = fx();
689        write_run(&f, RID, "reviewing", &base, None);
690        let t = file_task(&f, TaskStatus::Running, &[RID]);
691        let hits = run(&f, "land magi/aaaa/A onto a fresh branch.", None);
692        assert!(hits.iter().any(|h| h.owner == Owner::Task
693            && h.id == t.id
694            && h.signal == Signal::Branch
695            && h.token == "magi/aaaa/A"));
696        assert!(hits.iter().any(|h| h.owner == Owner::Run && h.id == RID));
697        let msg = Duplicate(hits).to_string();
698        assert!(
699            msg.contains("--force") && msg.contains("magi/aaaa/A"),
700            "{msg}"
701        );
702    }
703
704    #[test]
705    fn branch_must_match_whole_word() {
706        let (f, base, _) = fx();
707        write_run(&f, RID, "reviewing", &base, None);
708        assert!(run(&f, "see magi/aaaa/AB and magi/aaaa/A/x", None).is_empty());
709    }
710
711    #[test]
712    fn sha_on_the_branch_matches_but_one_in_base_does_not() {
713        let (f, base, tip) = fx();
714        write_run(&f, RID, "reviewing", &base, None);
715        let hits = run(&f, &format!("land commit {} please", &tip[..8]), None);
716        assert!(
717            hits.iter().any(|h| h.signal == Signal::Sha && h.id == RID),
718            "{hits:?}"
719        );
720        assert!(run(&f, &format!("see {}", &base[..9]), None).is_empty());
721        // Hex that resolves to nothing is ignored.
722        assert!(run(&f, "deadbeef and 1234567", None).is_empty());
723    }
724
725    #[test]
726    fn pr_number_matches_in_every_spelling() {
727        let (f, base, _) = fx();
728        write_run(&f, RID, "ready", &base, Some((48, "open")));
729        for text in [
730            "finish #48",
731            "PR 48 is stale",
732            "pr #48",
733            "pull request 48",
734            "https://github.com/o/r/pull/48",
735        ] {
736            let hits = run(&f, text, None);
737            assert!(
738                hits.iter().any(|h| h.signal == Signal::Pr),
739                "{text}: {hits:?}"
740            );
741        }
742        assert!(run(&f, "see #480 and PR 4 and issue48", None).is_empty());
743    }
744
745    #[test]
746    fn terminal_runs_and_done_tasks_do_not_match() {
747        let (f, base, tip) = fx();
748        write_run(&f, RID, "merged", &base, Some((48, "merged")));
749        file_task(&f, TaskStatus::Done, &[RID]);
750        let text = format!("magi/aaaa/A {} #48", &tip[..8]);
751        assert!(run(&f, &text, None).is_empty());
752    }
753
754    #[test]
755    fn terminal_run_with_open_pr_or_open_task_still_claims() {
756        let (f, base, _) = fx();
757        write_run(&f, RID, "ready", &base, Some((48, "open")));
758        assert!(!run(&f, "magi/aaaa/A", None).is_empty());
759        let (g, base, _) = fx();
760        write_run(&g, RID, "ready", &base, None);
761        assert!(run(&g, "magi/aaaa/A", None).is_empty());
762        let t = file_task(&g, TaskStatus::Held, &[RID]);
763        let hits = run(&g, "magi/aaaa/A", None);
764        assert!(hits.iter().any(|h| h.id == t.id), "{hits:?}");
765    }
766
767    #[test]
768    fn review_only_matches_a_branch_a_live_task_owns() {
769        let (f, base, _) = fx();
770        write_run(&f, RID, "ready", &base, None);
771        assert!(run(&f, "", Some("magi/aaaa/A")).is_empty());
772        let mut t = file_task(&f, TaskStatus::Queued, &[]);
773        t.review_branch = Some("magi/aaaa/A".into());
774        f.q.put(&mut t).unwrap();
775        let hits = run(&f, "", Some("magi/aaaa/A"));
776        assert!(
777            hits.iter()
778                .any(|h| h.id == t.id && h.signal == Signal::Branch)
779        );
780    }
781
782    #[test]
783    fn other_repository_and_edited_task_do_not_match() {
784        let (f, base, _) = fx();
785        write_run(&f, RID, "reviewing", &base, None);
786        let t = file_task(&f, TaskStatus::Running, &[RID]);
787        let other = f._tmp.path().join("other");
788        std::fs::create_dir_all(&other).unwrap();
789        sh(&other, &["init", "-q"]);
790        assert!(
791            check_with(&f.q, &f.runs, &other, "magi/aaaa/A", None, None, &|_, _| {
792                None
793            })
794            .is_empty()
795        );
796        // Editing the task that owns the run never collides with itself.
797        assert!(
798            check_with(
799                &f.q,
800                &f.runs,
801                &f.repo,
802                "magi/aaaa/A",
803                None,
804                Some(&t.id),
805                &|_, _| None
806            )
807            .is_empty()
808        );
809    }
810
811    #[test]
812    fn a_worktree_is_the_same_repository() {
813        let (f, base, _) = fx();
814        write_run(&f, RID, "reviewing", &base, None);
815        let wt = f._tmp.path().join("wt");
816        sh(
817            &f.repo,
818            &["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "other"],
819        );
820        assert!(
821            !check_with(&f.q, &f.runs, &wt, "magi/aaaa/A", None, None, &|_, _| None).is_empty()
822        );
823    }
824
825    #[test]
826    fn an_open_pr_without_a_run_record_matches_through_the_forge() {
827        let (f, _, _) = fx();
828        let open = |_: &Path, n: u64| (n == 48).then(|| "https://example.test/pull/48".to_owned());
829        let hit = |text: &str| check_with(&f.q, &f.runs, &f.repo, text, None, None, &open);
830        let hits = hit("finish PR #48");
831        assert_eq!(hits.len(), 1, "{hits:?}");
832        assert_eq!(hits[0].owner, Owner::Pr);
833        assert!(hits[0].to_string().contains("#48"));
834        // Closed / unknown / unnamed PRs never match.
835        assert!(hit("finish PR #49").is_empty());
836        assert!(hit("finish the work").is_empty());
837    }
838
839    #[test]
840    fn a_forge_hit_does_not_repeat_a_pr_a_run_already_explains() {
841        let (f, base, _) = fx();
842        write_run(&f, RID, "ready", &base, Some((48, "open")));
843        let open = |_: &Path, _: u64| Some("u".to_owned());
844        let hits = check_with(&f.q, &f.runs, &f.repo, "#48", None, None, &open);
845        assert!(hits.iter().all(|h| h.owner != Owner::Pr), "{hits:?}");
846        assert!(!hits.is_empty());
847    }
848
849    #[test]
850    fn an_edited_tasks_own_open_pr_is_not_a_forge_hit() {
851        let (f, base, _) = fx();
852        write_run(&f, RID, "ready", &base, Some((48, "open")));
853        let t = file_task(&f, TaskStatus::Running, &[RID]);
854        let open = |_: &Path, _: u64| Some("u".to_owned());
855        let hits = check_with(&f.q, &f.runs, &f.repo, "#48", None, Some(&t.id), &open);
856        assert!(hits.is_empty(), "{hits:?}");
857    }
858}