1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Signal {
41 Branch,
43 Sha,
45 Pr,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum Owner {
52 Task,
54 Run,
56 Pr,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Hit {
63 pub owner: Owner,
65 pub id: String,
67 pub status: String,
69 pub signal: Signal,
71 pub token: String,
73 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#[derive(Debug, Clone)]
102pub struct Duplicate(pub Vec<Hit>);
103
104impl Duplicate {
105 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#[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 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#[derive(Debug, Clone)]
184struct Claim {
185 owner: Owner,
186 id: String,
187 status: String,
188 via: String,
189 branch: Option<String>,
190 base: Option<String>,
192 pr: Option<(u64, String)>,
194}
195
196pub 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
218pub 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 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
334fn 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 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
401fn 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#[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
490fn 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
519fn 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
565fn 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
591fn 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 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 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 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 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}