1use std::collections::{BTreeSet, HashMap};
28use std::fmt;
29use std::future::Future;
30use std::path::{Path, PathBuf};
31use std::pin::Pin;
32use std::process::{Command, Stdio};
33use std::time::{Duration, Instant};
34
35use anyhow::{Context as _, Result, bail};
36use serde::Deserialize;
37
38use crate::agent;
39use crate::config::Config;
40use crate::prompt;
41
42use crate::land::PrLifecycle;
43use crate::proc::Quiet as _;
44use crate::queue::{Queue, Task, TaskStatus};
45use crate::run::RunStatus;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Signal {
50 Branch,
52 Sha,
54 Pr,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Owner {
61 Task,
63 Run,
65 Pr,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Hit {
72 pub owner: Owner,
74 pub id: String,
76 pub status: String,
78 pub signal: Signal,
80 pub token: String,
82 pub via: String,
84 pub about: String,
87}
88
89impl fmt::Display for Hit {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 let kind = match self.owner {
92 Owner::Task => "task",
93 Owner::Run => "run",
94 Owner::Pr => "pull request",
95 };
96 let what = match self.signal {
97 Signal::Branch => "names branch",
98 Signal::Sha => "names commit",
99 Signal::Pr => "names pull request",
100 };
101 write!(
102 f,
103 "{kind} {} ({}): this work {what} {}, {}",
104 crate::queue::short(&self.id),
105 self.status,
106 self.token,
107 self.via
108 )
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct Duplicate {
115 pub hits: Vec<Hit>,
117 pub judge: Option<String>,
120}
121
122impl Duplicate {
123 pub fn new(hits: Vec<Hit>) -> Self {
125 Self { hits, judge: None }
126 }
127
128 pub fn render(&self, override_hint: &str) -> String {
130 let mut out = String::from("this looks like work that is already in flight:");
131 for h in &self.hits {
132 out.push_str("\n - ");
133 out.push_str(&h.to_string());
134 }
135 if let Some(j) = &self.judge {
136 out.push('\n');
137 out.push_str(j);
138 }
139 out.push('\n');
140 out.push_str(override_hint);
141 out
142 }
143}
144
145impl fmt::Display for Duplicate {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.write_str(&self.render(
148 "If it is not a duplicate, pass --force to file it anyway \
149 (an agent should report this to the operator instead).",
150 ))
151 }
152}
153
154impl std::error::Error for Duplicate {}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct Judgement {
159 pub duplicate: bool,
161 pub reason: String,
163 pub agent: String,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Screened {
170 Clean,
172 Cleared(Judgement),
174 Unjudged(String),
176}
177
178pub type JudgeFuture = Pin<Box<dyn Future<Output = Result<Judgement>> + Send>>;
181
182const JUDGE_BUDGET: Duration = Duration::from_secs(45);
184const JUDGE_TURN: Duration = Duration::from_secs(30);
186
187fn parse_judgement(text: &str) -> Result<(bool, String)> {
191 #[derive(Deserialize)]
192 struct Raw {
193 duplicate: bool,
194 reason: String,
195 }
196 let mut body = text.trim();
200 if let Some(rest) = body.strip_prefix("```") {
201 let rest = rest.strip_prefix("json").unwrap_or(rest);
202 body = rest.trim().strip_suffix("```").unwrap_or(rest).trim();
203 }
204 let raw: Raw =
205 serde_json::from_str(body).context("the judge's reply is not a single JSON object")?;
206 let reason = raw.reason.split_whitespace().collect::<Vec<_>>().join(" ");
207 if reason.is_empty() {
208 bail!("the judge gave no reason");
209 }
210 Ok((raw.duplicate, reason.chars().take(300).collect()))
211}
212
213pub async fn chain_judge(
217 cfg: &Config,
218 repo: &Path,
219 instruction: String,
220 hits: Vec<Hit>,
221) -> Result<Judgement> {
222 let chain = agent::pick_chain(
223 &cfg.agents,
224 cfg.roles.chatter.as_ref(),
225 &agent::installed,
226 "dupes judge",
227 )?;
228 let claims: Vec<(String, String)> = hits
229 .iter()
230 .map(|h| (h.to_string(), h.about.clone()))
231 .collect();
232 let body = prompt::dupes_judge(&instruction, &claims);
233 let artifacts = std::env::temp_dir().join(format!("magi-dupes-{:016x}", crate::rng::entropy()));
234 let started = Instant::now();
235 let mut last: anyhow::Error = anyhow::anyhow!("no judge agent ran");
236 let mut result = None;
237 for spec in &chain {
238 let left = JUDGE_BUDGET.saturating_sub(started.elapsed());
239 if left.is_zero() {
240 break;
241 }
242 let mut seat = agent::SeatState::new("dupes", &spec.id, crate::rng::entropy());
243 let inv = agent::Invocation {
244 cwd: repo,
245 prompt: &body,
246 timeout: left.min(JUDGE_TURN),
247 allow_write: false,
248 unsandboxed: false,
249 sessions: false,
250 artifacts: &artifacts,
251 stem: &format!("judge-{}", spec.id),
252 run: "dupes",
253 node: "dupes",
254 cache_dir: None,
255 attachments: &[],
256 writable: &[],
257 };
258 let out = agent::invoke(spec, &mut seat, &inv).await;
259 if agent::chain_advances(&out) {
260 last = match out {
261 Err(e) => e.context(format!("judge `{}` failed", spec.id)),
262 Ok(o) => anyhow::anyhow!(
263 "judge `{}` gave no usable reply (exit {:?}, timed out {}, quota {})",
264 spec.id,
265 o.exit_code,
266 o.timed_out,
267 o.quota_exhausted()
268 ),
269 };
270 continue;
271 }
272 result = Some(
273 out.and_then(|o| parse_judgement(&o.text))
274 .map(|(duplicate, reason)| Judgement {
275 duplicate,
276 reason,
277 agent: spec.id.clone(),
278 }),
279 );
280 break;
281 }
282 let _ = std::fs::remove_dir_all(&artifacts);
283 result.unwrap_or(Err(last))
284}
285
286pub async fn screen(
301 hits: Vec<Hit>,
302 text: &str,
303 review_branch: Option<&str>,
304 judge: &(dyn Fn(String, Vec<Hit>) -> JudgeFuture + Sync),
305) -> Result<Screened, Duplicate> {
306 if hits.is_empty() {
307 return Ok(Screened::Clean);
308 }
309 let unjudged = |why: String| {
310 tracing::warn!(hits = hits.len(), %why, "duplicate check: judgement unavailable, letting the work through");
311 Ok(Screened::Unjudged(why))
312 };
313 let mut subject = squeeze(text, prompt::DUPES_JUDGE_MAX_CHARS);
314 if let Some(b) = review_branch {
315 if !subject.is_empty() {
316 subject.push_str("\n\n");
317 }
318 subject.push_str(&format!(
319 "(This is a review-only request for branch `{b}`: it would do work on that branch.)"
320 ));
321 }
322 match judge(subject, hits.clone()).await {
323 Ok(j) if !j.duplicate => {
324 tracing::info!(
325 agent = %j.agent,
326 reason = %j.reason,
327 hits = hits.len(),
328 "duplicate check: the judge says this is not duplicate work"
329 );
330 Ok(Screened::Cleared(j))
331 }
332 Ok(j) => Err(Duplicate {
333 hits,
334 judge: Some(format!("judge ({}): duplicate - {}", j.agent, j.reason)),
335 }),
336 Err(e) => unjudged(format!("{e:#}")),
337 }
338}
339
340pub async fn screen_with_config(
343 hits: Vec<Hit>,
344 text: &str,
345 review_branch: Option<&str>,
346 repo: &Path,
347 cfg: Option<&Config>,
348) -> Result<Screened, Duplicate> {
349 let judge = |instruction: String, hits: Vec<Hit>| -> JudgeFuture {
350 let cfg = cfg.cloned();
351 let repo = repo.to_path_buf();
352 Box::pin(async move {
353 match cfg {
354 Some(cfg) => {
355 let dir = repo.clone();
356 let hits = tokio::task::spawn_blocking(move || {
357 let mut hits = hits;
358 describe_forge_hits(&dir, &mut hits);
359 hits
360 })
361 .await
362 .context("describing the pull request")?;
363 chain_judge(&cfg, &repo, instruction, hits).await
364 }
365 None => bail!("no readable configuration to resolve a judge agent from"),
366 }
367 })
368 };
369 screen(hits, text, review_branch, &judge).await
370}
371
372#[derive(Debug, Default, Deserialize)]
375struct RunView {
376 #[serde(default)]
377 id: String,
378 #[serde(default)]
379 repo: PathBuf,
380 #[serde(default)]
381 status: String,
382 #[serde(default)]
383 base_commit: String,
384 #[serde(default)]
385 instruction: String,
386 #[serde(default)]
387 candidates: Vec<CandView>,
388 #[serde(default)]
389 pr: Option<PrView>,
390 #[serde(default)]
392 released_to: Option<String>,
393 #[serde(default)]
394 pr_head: Option<String>,
395 #[serde(default)]
396 pr_taken_over_by: Option<String>,
397}
398
399#[derive(Debug, Default, Deserialize)]
400struct CandView {
401 #[serde(default)]
402 branch: String,
403}
404
405#[derive(Debug, Default, Deserialize)]
406struct PrView {
407 #[serde(default)]
408 url: String,
409 #[serde(default)]
410 number: u64,
411 #[serde(default)]
412 state: String,
413}
414
415impl RunView {
416 fn read(runs_root: &Path, id: &str) -> Option<Self> {
417 let raw = std::fs::read_to_string(runs_root.join(id).join("run.json")).ok()?;
418 serde_json::from_str(&raw).ok()
419 }
420
421 fn terminal(&self) -> bool {
424 serde_json::from_value::<RunStatus>(serde_json::Value::String(self.status.clone()))
425 .map(RunStatus::done)
426 .unwrap_or(false)
427 }
428
429 fn pr_open(&self) -> bool {
430 self.pr.as_ref().is_some_and(|p| p.state == "open")
431 }
432}
433
434struct Staleness<'a> {
451 runs_root: &'a Path,
452 lookup: &'a dyn Fn(&Path, u64) -> Option<PrLifecycle>,
453 cache: HashMap<u64, bool>,
454 asked: usize,
455 failed: bool,
456}
457
458const MAX_FORGE_LOOKUPS: usize = 5;
461
462impl<'a> Staleness<'a> {
463 fn new(runs_root: &'a Path, lookup: &'a dyn Fn(&Path, u64) -> Option<PrLifecycle>) -> Self {
464 Self {
465 runs_root,
466 lookup,
467 cache: HashMap::new(),
468 asked: 0,
469 failed: false,
470 }
471 }
472
473 fn pr_released(&mut self, repo: &Path, view: &RunView) -> bool {
476 if let Some(next) = &view.pr_taken_over_by
477 && next != &view.id
478 && RunView::read(self.runs_root, next).is_some()
479 {
480 return true;
481 }
482 let Some(pr) = view.pr.as_ref().filter(|p| p.state == "open") else {
483 return false;
484 };
485 if let Some(next) = &view.released_to
486 && next != &view.id
487 && RunView::read(self.runs_root, next).is_some()
488 {
489 return true;
490 }
491 if pr.number == 0 {
492 return false;
493 }
494 if let Some(known) = self.cache.get(&pr.number) {
495 return *known;
496 }
497 if self.failed || self.asked >= MAX_FORGE_LOOKUPS {
498 return false;
499 }
500 self.asked += 1;
501 let settled = match (self.lookup)(repo, pr.number) {
502 Some(PrLifecycle::Merged | PrLifecycle::Closed) => true,
503 Some(PrLifecycle::Open) => false,
504 None => {
505 self.failed = true;
506 false
507 }
508 };
509 self.cache.insert(pr.number, settled);
510 settled
511 }
512}
513
514#[derive(Debug, Clone)]
516struct Claim {
517 owner: Owner,
518 id: String,
519 status: String,
520 via: String,
521 about: String,
523 branch: Option<String>,
524 base: Option<String>,
526 pr: Option<(u64, String)>,
528}
529
530pub fn check(
534 queue: &Queue,
535 runs_root: &Path,
536 repo: &Path,
537 text: &str,
538 review_branch: Option<&str>,
539 ignore_task: Option<&str>,
540) -> Vec<Hit> {
541 check_with(
542 queue,
543 runs_root,
544 repo,
545 text,
546 review_branch,
547 ignore_task,
548 &gh_open_pr,
549 &gh_pr_state,
550 )
551}
552
553#[allow(clippy::too_many_arguments)]
560pub fn check_with(
561 queue: &Queue,
562 runs_root: &Path,
563 repo: &Path,
564 text: &str,
565 review_branch: Option<&str>,
566 ignore_task: Option<&str>,
567 open_pr: &dyn Fn(&Path, u64) -> Option<String>,
568 pr_state: &dyn Fn(&Path, u64) -> Option<PrLifecycle>,
569) -> Vec<Hit> {
570 let mut stale = Staleness::new(runs_root, pr_state);
571 let mut idents = Idents::default();
572 let here = idents.of(repo);
573 let tasks = queue.list();
574 let own_runs: BTreeSet<String> = tasks
575 .iter()
576 .filter(|t| Some(t.id.as_str()) == ignore_task)
577 .flat_map(|t| t.runs.iter().cloned())
578 .collect();
579 let own_prs: BTreeSet<u64> = own_runs
581 .iter()
582 .filter_map(|id| RunView::read(runs_root, id))
583 .filter_map(|v| v.pr.map(|p| p.number))
584 .collect();
585
586 let mut claims: Vec<Claim> = Vec::new();
587 let mut from_task: BTreeSet<String> = BTreeSet::new();
588 for t in tasks
589 .iter()
590 .filter(|t| t.status != TaskStatus::Done && Some(t.id.as_str()) != ignore_task)
591 .filter(|t| idents.of(&t.repo) == here)
592 {
593 claims.extend(task_claims(t, runs_root, &mut from_task, repo, &mut stale));
594 }
595 for id in crate::run::list_ids_in(runs_root) {
596 if own_runs.contains(&id) {
597 continue;
598 }
599 let Some(view) = RunView::read(runs_root, &id) else {
600 continue;
601 };
602 if from_task.contains(&id) && view.pr_head.is_some() {
605 continue;
606 }
607 if (view.terminal() && !view.pr_open()) || idents.of(&view.repo) != here {
608 continue;
609 }
610 let released = stale.pr_released(repo, &view);
611 if view.terminal() && released {
614 continue;
615 }
616 claims.extend(run_claims(
617 &view,
618 Owner::Run,
619 None,
620 "its own run",
621 !released,
622 ("", &view.instruction),
623 ));
624 }
625
626 let mut hits: Vec<Hit> = Vec::new();
627 let mut push = |c: &Claim, signal: Signal, token: String| {
628 let hit = Hit {
629 owner: c.owner.clone(),
630 id: c.id.clone(),
631 status: c.status.clone(),
632 signal,
633 token,
634 via: c.via.clone(),
635 about: c.about.clone(),
636 };
637 if !hits.contains(&hit) {
638 hits.push(hit);
639 }
640 };
641
642 let prs = pr_numbers(text);
643 let shas = sha_candidates(repo, text);
644 for c in &claims {
645 if let Some(b) = &c.branch {
646 if names_branch(text, b) || review_branch == Some(b.as_str()) {
647 push(c, Signal::Branch, b.clone());
648 }
649 if let Some(base) = &c.base {
650 for sha in &shas {
651 if on_branch_only(repo, sha, b, base) {
652 push(c, Signal::Sha, short_sha(sha));
653 }
654 }
655 }
656 }
657 if let Some((n, url)) = &c.pr {
658 if prs.contains(&Mention::Number(*n))
659 || prs.iter().any(|p| matches!(p, Mention::Url(u) if u == url))
660 {
661 push(c, Signal::Pr, format!("#{n}"));
662 }
663 }
664 }
665 let mut asked = BTreeSet::new();
666 for n in prs.iter().filter_map(|m| match m {
667 Mention::Number(n) => Some(*n),
668 Mention::Url(u) => u.rsplit('/').next().and_then(|d| d.parse().ok()),
669 }) {
670 let token = format!("#{n}");
671 if hits
672 .iter()
673 .any(|h| h.signal == Signal::Pr && h.token == token)
674 || own_prs.contains(&n)
675 || !asked.insert(n)
676 {
677 continue;
678 }
679 if let Some(url) = open_pr(repo, n) {
680 hits.push(Hit {
681 owner: Owner::Pr,
682 id: token.clone(),
683 status: "open".into(),
684 signal: Signal::Pr,
685 token,
686 via: format!("an open pull request with no run record here ({url})"),
687 about: String::new(),
688 });
689 }
690 }
691 hits
692}
693
694fn gh_open_pr(repo: &Path, n: u64) -> Option<String> {
699 let v = gh_pr_view(repo, n, "state,url")?;
700 (v["state"] == "OPEN")
701 .then(|| v["url"].as_str().map(str::to_owned))
702 .flatten()
703}
704
705fn gh_pr_state(repo: &Path, n: u64) -> Option<PrLifecycle> {
708 match gh_pr_view(repo, n, "state,url")?["state"].as_str()? {
709 "OPEN" => Some(PrLifecycle::Open),
710 "MERGED" => Some(PrLifecycle::Merged),
711 "CLOSED" => Some(PrLifecycle::Closed),
712 _ => None,
713 }
714}
715
716fn gh_pr_view(repo: &Path, n: u64, fields: &str) -> Option<serde_json::Value> {
717 let mut child = Command::new("gh")
718 .quiet()
719 .args(["pr", "view", &n.to_string(), "--json", fields])
720 .current_dir(repo)
721 .env_remove("GH_REPO")
722 .env("GH_PROMPT_DISABLED", "1")
723 .stdin(Stdio::null())
724 .stdout(Stdio::piped())
725 .stderr(Stdio::null())
726 .spawn()
727 .ok()?;
728 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
729 loop {
730 match child.try_wait().ok()? {
731 Some(status) if status.success() => break,
732 Some(_) => return None,
733 None if std::time::Instant::now() >= deadline => {
734 let _ = child.kill();
735 let _ = child.wait();
736 return None;
737 }
738 None => std::thread::sleep(std::time::Duration::from_millis(50)),
739 }
740 }
741 let mut raw = String::new();
742 std::io::Read::read_to_string(&mut child.stdout.take()?, &mut raw).ok()?;
743 serde_json::from_str(&raw).ok()
744}
745
746fn describe_forge_hits(repo: &Path, hits: &mut [Hit]) {
751 for h in hits
752 .iter_mut()
753 .filter(|h| h.owner == Owner::Pr && h.about.is_empty())
754 {
755 let Some(n) = h.token.trim_start_matches('#').parse::<u64>().ok() else {
756 continue;
757 };
758 if let Some(v) = gh_pr_view(repo, n, "title,body") {
759 h.about = about_of(
760 v["title"].as_str().unwrap_or(""),
761 v["body"].as_str().unwrap_or(""),
762 );
763 }
764 }
765}
766
767fn squeeze(s: &str, max: usize) -> String {
771 let n = s.chars().count();
772 if n <= max {
773 return s.to_owned();
774 }
775 let half = max / 2;
776 let head: String = s.chars().take(half).collect();
777 let tail: String = s.chars().skip(n - half).collect();
778 format!(
779 "{head}\n[... {} characters omitted ...]\n{tail}",
780 n - 2 * half
781 )
782}
783
784fn about_of(title: &str, instruction: &str) -> String {
789 let body = instruction.split_whitespace().collect::<Vec<_>>().join(" ");
790 let title = title.trim();
791 let full = match (title.is_empty(), body.is_empty()) {
792 (true, _) => body,
793 (false, true) => title.to_owned(),
794 (false, false) => format!("{title}: {body}"),
795 };
796 squeeze(&full, ABOUT_MAX_CHARS)
797}
798
799const ABOUT_MAX_CHARS: usize = prompt::DUPES_JUDGE_MAX_CHARS;
803
804fn task_claims(
805 t: &Task,
806 runs_root: &Path,
807 seen: &mut BTreeSet<String>,
808 repo: &Path,
809 stale: &mut Staleness<'_>,
810) -> Vec<Claim> {
811 let status = t.status.as_str().to_owned();
812 let mut out = Vec::new();
813 if let Some(b) = &t.review_branch {
814 out.push(Claim {
815 owner: Owner::Task,
816 id: t.id.clone(),
817 status: status.clone(),
818 via: "its review branch".into(),
819 about: about_of(&t.title, &t.instruction),
820 branch: Some(b.clone()),
821 base: None,
822 pr: None,
823 });
824 }
825 for rid in &t.runs {
826 if let Some(view) = RunView::read(runs_root, rid) {
827 seen.insert(rid.clone());
828 let live_pr = !(view.terminal() && stale.pr_released(repo, &view));
831 if view.terminal() && !live_pr && view.pr_taken_over_by.is_some() {
832 continue;
833 }
834 out.extend(run_claims(
835 &view,
836 Owner::Task,
837 Some((&t.id, &status)),
838 &format!("produced by its run {}", crate::queue::short(rid)),
839 live_pr,
840 (&t.title, &t.instruction),
841 ));
842 }
843 }
844 out
845}
846
847fn run_claims(
852 view: &RunView,
853 owner: Owner,
854 task: Option<(&str, &str)>,
855 via: &str,
856 live_pr: bool,
857 (title, instruction): (&str, &str),
858) -> Vec<Claim> {
859 let about = about_of(title, instruction);
860 let (id, status) = match task {
861 Some((id, status)) => (id.to_owned(), status.to_owned()),
862 None => (view.id.clone(), view.status.clone()),
863 };
864 let pr = view
865 .pr
866 .as_ref()
867 .filter(|p| live_pr && p.state == "open" && p.number > 0)
868 .map(|p| (p.number, p.url.clone()));
869 let via_pr = |extra: &str| match &pr {
870 Some((n, _)) => format!("{via} (PR #{n} open){extra}"),
871 None => format!("{via}{extra}"),
872 };
873 let mut out: Vec<Claim> = view
874 .candidates
875 .iter()
876 .filter(|c| !c.branch.is_empty())
877 .map(|c| Claim {
878 owner: owner.clone(),
879 id: id.clone(),
880 status: status.clone(),
881 via: via_pr(""),
882 about: about.clone(),
883 branch: Some(c.branch.clone()),
884 base: (!view.base_commit.is_empty()).then(|| view.base_commit.clone()),
885 pr: None,
886 })
887 .collect();
888 if live_pr && let Some(branch) = &view.pr_head {
889 out.push(Claim {
890 owner: owner.clone(),
891 id: id.clone(),
892 status: status.clone(),
893 via: via_pr(""),
894 about: about.clone(),
895 branch: Some(branch.clone()),
896 base: None,
897 pr: None,
898 });
899 }
900 if pr.is_some() {
901 out.push(Claim {
902 owner,
903 id,
904 status,
905 via: via_pr(""),
906 about,
907 branch: None,
908 base: None,
909 pr,
910 });
911 }
912 out
913}
914
915#[derive(Default)]
918struct Idents(HashMap<PathBuf, PathBuf>);
919
920impl Idents {
921 fn of(&mut self, path: &Path) -> PathBuf {
922 self.0
923 .entry(path.to_path_buf())
924 .or_insert_with(|| {
925 git(
926 path,
927 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
928 )
929 .map(PathBuf::from)
930 .and_then(|p| p.canonicalize().ok())
931 .or_else(|| path.canonicalize().ok())
932 .unwrap_or_else(|| path.to_path_buf())
933 })
934 .clone()
935 }
936}
937
938fn git(cwd: &Path, args: &[&str]) -> Option<String> {
939 let out = Command::new("git")
940 .quiet()
941 .args(args)
942 .current_dir(cwd)
943 .stdin(Stdio::null())
944 .stderr(Stdio::null())
945 .env("GIT_TERMINAL_PROMPT", "0")
946 .output()
947 .ok()?;
948 out.status
949 .success()
950 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned())
951}
952
953fn git_ok(cwd: &Path, args: &[&str]) -> bool {
954 git(cwd, args).is_some()
955}
956
957fn is_ref_char(c: char) -> bool {
958 c.is_alphanumeric() || matches!(c, '_' | '-')
959}
960
961fn names_branch(text: &str, branch: &str) -> bool {
965 if branch.len() < 3 {
966 return false;
967 }
968 text.match_indices(branch).any(|(i, _)| {
969 let before = text[..i].chars().next_back();
970 let after = text[i + branch.len()..].chars().next();
971 let after_ok = match after {
972 None => true,
973 Some('/') => false,
974 Some('.') => !text[i + branch.len() + 1..]
975 .chars()
976 .next()
977 .is_some_and(is_ref_char),
978 Some(c) => !is_ref_char(c),
979 };
980 before.is_none_or(|c| !is_ref_char(c) && c != '.') && after_ok
981 })
982}
983
984#[derive(Debug, PartialEq, Eq)]
985enum Mention {
986 Number(u64),
987 Url(String),
988}
989
990fn pr_numbers(text: &str) -> Vec<Mention> {
992 let mut out = Vec::new();
993 let bytes = text.as_bytes();
994 let digits = |from: usize| -> Option<(u64, usize)> {
995 let n = text[from..].bytes().take_while(u8::is_ascii_digit).count();
996 (n > 0 && n < 10)
997 .then(|| text[from..from + n].parse().ok().map(|v| (v, from + n)))
998 .flatten()
999 };
1000 for (i, _) in text.match_indices('#') {
1001 if let Some((n, _)) = digits(i + 1) {
1002 let word_before = i > 0 && is_ref_char(bytes[i - 1] as char);
1003 if !word_before {
1004 out.push(Mention::Number(n));
1005 }
1006 }
1007 }
1008 let lower = text.to_ascii_lowercase();
1009 for key in ["pull request ", "pr "] {
1010 for (i, _) in lower.match_indices(key) {
1011 if i > 0 && is_ref_char(bytes[i - 1] as char) {
1012 continue;
1013 }
1014 let from = i + key.len();
1015 let from = if text[from..].starts_with('#') {
1016 from + 1
1017 } else {
1018 from
1019 };
1020 if let Some((n, _)) = digits(from) {
1021 out.push(Mention::Number(n));
1022 }
1023 }
1024 }
1025 for (i, _) in text.match_indices("/pull/") {
1026 if let Some((_, end)) = digits(i + 6) {
1027 let start = text[..i]
1028 .rfind(|c: char| c.is_whitespace() || matches!(c, '(' | '<' | '"' | '\''))
1029 .map_or(0, |p| p + 1);
1030 out.push(Mention::Url(text[start..end].to_owned()));
1031 }
1032 }
1033 out
1034}
1035
1036fn sha_candidates(repo: &Path, text: &str) -> Vec<String> {
1038 let mut seen = BTreeSet::new();
1039 let mut out = Vec::new();
1040 for word in text.split(|c: char| !c.is_ascii_alphanumeric()) {
1041 if !(7..=40).contains(&word.len()) || !word.bytes().all(|b| b.is_ascii_hexdigit()) {
1042 continue;
1043 }
1044 if seen.len() >= 16 || !seen.insert(word.to_ascii_lowercase()) {
1045 continue;
1046 }
1047 if let Some(full) = git(
1048 repo,
1049 &[
1050 "rev-parse",
1051 "--verify",
1052 "--quiet",
1053 &format!("{word}^{{commit}}"),
1054 ],
1055 ) {
1056 out.push(full);
1057 }
1058 }
1059 out
1060}
1061
1062fn on_branch_only(repo: &Path, sha: &str, branch: &str, base: &str) -> bool {
1065 let tip = format!("{branch}^{{commit}}");
1066 git_ok(repo, &["rev-parse", "--verify", "--quiet", &tip])
1067 && git_ok(repo, &["merge-base", "--is-ancestor", sha, branch])
1068 && !git_ok(repo, &["merge-base", "--is-ancestor", sha, base])
1069}
1070
1071fn short_sha(sha: &str) -> String {
1072 sha.chars().take(7).collect()
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use super::*;
1078 use crate::queue::{Source, Task};
1079
1080 struct Fx {
1081 _tmp: tempfile::TempDir,
1082 repo: PathBuf,
1083 runs: PathBuf,
1084 q: Queue,
1085 }
1086
1087 fn sh(cwd: &Path, args: &[&str]) -> String {
1088 let out = Command::new("git")
1089 .quiet()
1090 .args(["-c", "user.name=t", "-c", "user.email=t@t"])
1091 .args(args)
1092 .current_dir(cwd)
1093 .output()
1094 .unwrap();
1095 assert!(out.status.success(), "git {args:?}: {out:?}");
1096 String::from_utf8_lossy(&out.stdout).trim().to_owned()
1097 }
1098
1099 fn fx() -> (Fx, String, String) {
1102 let tmp = tempfile::tempdir().unwrap();
1103 let repo = tmp.path().join("repo");
1104 std::fs::create_dir_all(&repo).unwrap();
1105 sh(&repo, &["init", "-q", "-b", "main"]);
1106 std::fs::write(repo.join("a"), "1").unwrap();
1107 sh(&repo, &["add", "."]);
1108 sh(&repo, &["commit", "-q", "-m", "base"]);
1109 let base = sh(&repo, &["rev-parse", "HEAD"]);
1110 sh(&repo, &["checkout", "-q", "-b", "magi/aaaa/A"]);
1111 std::fs::write(repo.join("a"), "2").unwrap();
1112 sh(&repo, &["commit", "-q", "-am", "work"]);
1113 let tip = sh(&repo, &["rev-parse", "HEAD"]);
1114 sh(&repo, &["checkout", "-q", "main"]);
1115 let runs = tmp.path().join("runs");
1116 std::fs::create_dir_all(&runs).unwrap();
1117 let q = Queue::at(tmp.path().join("queue"));
1118 (
1119 Fx {
1120 _tmp: tmp,
1121 repo,
1122 runs,
1123 q,
1124 },
1125 base,
1126 tip,
1127 )
1128 }
1129
1130 fn write_run(f: &Fx, id: &str, status: &str, base: &str, pr: Option<(u64, &str)>) {
1131 let dir = f.runs.join(id);
1132 std::fs::create_dir_all(&dir).unwrap();
1133 let pr = pr.map(|(n, s)| {
1134 serde_json::json!({"url": format!("https://github.com/o/r/pull/{n}"), "number": n, "state": s})
1135 });
1136 let v = serde_json::json!({
1137 "schema": 999, "id": id, "repo": f.repo, "status": status,
1138 "base_commit": base, "candidates": [{"branch": "magi/aaaa/A"}], "pr": pr,
1139 });
1140 std::fs::write(dir.join("run.json"), v.to_string()).unwrap();
1141 }
1142
1143 fn file_task(f: &Fx, status: TaskStatus, runs: &[&str]) -> Task {
1144 let mut t = Task::new("t".into(), "x".into(), f.repo.clone(), Source::Human);
1145 t.status = status;
1146 t.runs = runs.iter().map(|s| (*s).to_owned()).collect();
1147 f.q.put(&mut t).unwrap();
1148 t
1149 }
1150
1151 const RID: &str = "20260901-100000-aaaa";
1152
1153 fn run(f: &Fx, text: &str, review: Option<&str>) -> Vec<Hit> {
1154 check_with(
1155 &f.q,
1156 &f.runs,
1157 &f.repo,
1158 text,
1159 review,
1160 None,
1161 &|_, _| None,
1162 &|_, _| None,
1163 )
1164 }
1165
1166 #[test]
1167 fn branch_matches_a_live_run_and_its_task() {
1168 let (f, base, _) = fx();
1169 write_run(&f, RID, "reviewing", &base, None);
1170 let t = file_task(&f, TaskStatus::Running, &[RID]);
1171 let hits = run(&f, "land magi/aaaa/A onto a fresh branch.", None);
1172 assert!(hits.iter().any(|h| h.owner == Owner::Task
1173 && h.id == t.id
1174 && h.signal == Signal::Branch
1175 && h.token == "magi/aaaa/A"));
1176 assert!(hits.iter().any(|h| h.owner == Owner::Run && h.id == RID));
1177 let msg = Duplicate::new(hits).to_string();
1178 assert!(
1179 msg.contains("--force") && msg.contains("magi/aaaa/A"),
1180 "{msg}"
1181 );
1182 }
1183
1184 #[test]
1185 fn branch_must_match_whole_word() {
1186 let (f, base, _) = fx();
1187 write_run(&f, RID, "reviewing", &base, None);
1188 assert!(run(&f, "see magi/aaaa/AB and magi/aaaa/A/x", None).is_empty());
1189 }
1190
1191 #[test]
1192 fn sha_on_the_branch_matches_but_one_in_base_does_not() {
1193 let (f, base, tip) = fx();
1194 write_run(&f, RID, "reviewing", &base, None);
1195 let hits = run(&f, &format!("land commit {} please", &tip[..8]), None);
1196 assert!(
1197 hits.iter().any(|h| h.signal == Signal::Sha && h.id == RID),
1198 "{hits:?}"
1199 );
1200 assert!(run(&f, &format!("see {}", &base[..9]), None).is_empty());
1201 assert!(run(&f, "deadbeef and 1234567", None).is_empty());
1203 }
1204
1205 #[test]
1206 fn pr_number_matches_in_every_spelling() {
1207 let (f, base, _) = fx();
1208 write_run(&f, RID, "ready", &base, Some((48, "open")));
1209 for text in [
1210 "finish #48",
1211 "PR 48 is stale",
1212 "pr #48",
1213 "pull request 48",
1214 "https://github.com/o/r/pull/48",
1215 ] {
1216 let hits = run(&f, text, None);
1217 assert!(
1218 hits.iter().any(|h| h.signal == Signal::Pr),
1219 "{text}: {hits:?}"
1220 );
1221 }
1222 assert!(run(&f, "see #480 and PR 4 and issue48", None).is_empty());
1223 }
1224
1225 #[test]
1226 fn terminal_runs_and_done_tasks_do_not_match() {
1227 let (f, base, tip) = fx();
1228 write_run(&f, RID, "merged", &base, Some((48, "merged")));
1229 file_task(&f, TaskStatus::Done, &[RID]);
1230 let text = format!("magi/aaaa/A {} #48", &tip[..8]);
1231 assert!(run(&f, &text, None).is_empty());
1232 }
1233
1234 #[test]
1235 fn terminal_run_with_open_pr_or_open_task_still_claims() {
1236 let (f, base, _) = fx();
1241 write_run(&f, RID, "ready", &base, Some((48, "open")));
1242 assert!(!run(&f, "magi/aaaa/A", None).is_empty());
1243 let (g, base, _) = fx();
1244 write_run(&g, RID, "ready", &base, None);
1245 assert!(run(&g, "magi/aaaa/A", None).is_empty());
1246 let t = file_task(&g, TaskStatus::Held, &[RID]);
1247 let hits = run(&g, "magi/aaaa/A", None);
1248 assert!(hits.iter().any(|h| h.id == t.id), "{hits:?}");
1249 }
1250
1251 #[test]
1252 fn review_only_matches_a_branch_a_live_task_owns() {
1253 let (f, base, _) = fx();
1254 write_run(&f, RID, "ready", &base, None);
1255 assert!(run(&f, "", Some("magi/aaaa/A")).is_empty());
1256 let mut t = file_task(&f, TaskStatus::Queued, &[]);
1257 t.review_branch = Some("magi/aaaa/A".into());
1258 f.q.put(&mut t).unwrap();
1259 let hits = run(&f, "", Some("magi/aaaa/A"));
1260 assert!(
1261 hits.iter()
1262 .any(|h| h.id == t.id && h.signal == Signal::Branch)
1263 );
1264 }
1265
1266 #[test]
1267 fn other_repository_and_edited_task_do_not_match() {
1268 let (f, base, _) = fx();
1269 write_run(&f, RID, "reviewing", &base, None);
1270 let t = file_task(&f, TaskStatus::Running, &[RID]);
1271 let other = f._tmp.path().join("other");
1272 std::fs::create_dir_all(&other).unwrap();
1273 sh(&other, &["init", "-q"]);
1274 assert!(
1275 check_with(
1276 &f.q,
1277 &f.runs,
1278 &other,
1279 "magi/aaaa/A",
1280 None,
1281 None,
1282 &|_, _| None,
1283 &|_, _| None,
1284 )
1285 .is_empty()
1286 );
1287 assert!(
1289 check_with(
1290 &f.q,
1291 &f.runs,
1292 &f.repo,
1293 "magi/aaaa/A",
1294 None,
1295 Some(&t.id),
1296 &|_, _| None,
1297 &|_, _| None,
1298 )
1299 .is_empty()
1300 );
1301 }
1302
1303 #[test]
1304 fn a_worktree_is_the_same_repository() {
1305 let (f, base, _) = fx();
1306 write_run(&f, RID, "reviewing", &base, None);
1307 let wt = f._tmp.path().join("wt");
1308 sh(
1309 &f.repo,
1310 &["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "other"],
1311 );
1312 assert!(
1313 !check_with(
1314 &f.q,
1315 &f.runs,
1316 &wt,
1317 "magi/aaaa/A",
1318 None,
1319 None,
1320 &|_, _| None,
1321 &|_, _| None,
1322 )
1323 .is_empty()
1324 );
1325 }
1326
1327 #[test]
1328 fn an_open_pr_without_a_run_record_matches_through_the_forge() {
1329 let (f, _, _) = fx();
1330 let open = |_: &Path, n: u64| (n == 48).then(|| "https://example.test/pull/48".to_owned());
1331 let hit = |text: &str| {
1332 check_with(&f.q, &f.runs, &f.repo, text, None, None, &open, &|_, _| {
1333 None
1334 })
1335 };
1336 let hits = hit("finish PR #48");
1337 assert_eq!(hits.len(), 1, "{hits:?}");
1338 assert_eq!(hits[0].owner, Owner::Pr);
1339 assert!(hits[0].to_string().contains("#48"));
1340 assert!(hit("finish PR #49").is_empty());
1342 assert!(hit("finish the work").is_empty());
1343 }
1344
1345 #[test]
1346 fn a_forge_hit_does_not_repeat_a_pr_a_run_already_explains() {
1347 let (f, base, _) = fx();
1348 write_run(&f, RID, "ready", &base, Some((48, "open")));
1349 let open = |_: &Path, _: u64| Some("u".to_owned());
1350 let hits = check_with(&f.q, &f.runs, &f.repo, "#48", None, None, &open, &|_, _| {
1351 None
1352 });
1353 assert!(hits.iter().all(|h| h.owner != Owner::Pr), "{hits:?}");
1354 assert!(!hits.is_empty());
1355 }
1356
1357 #[test]
1358 fn an_edited_tasks_own_open_pr_is_not_a_forge_hit() {
1359 let (f, base, _) = fx();
1360 write_run(&f, RID, "ready", &base, Some((48, "open")));
1361 let t = file_task(&f, TaskStatus::Running, &[RID]);
1362 let open = |_: &Path, _: u64| Some("u".to_owned());
1363 let hits = check_with(
1364 &f.q,
1365 &f.runs,
1366 &f.repo,
1367 "#48",
1368 None,
1369 Some(&t.id),
1370 &open,
1371 &|_, _| None,
1372 );
1373 assert!(hits.is_empty(), "{hits:?}");
1374 }
1375
1376 fn with_forge(f: &Fx, text: &str, state: Option<PrLifecycle>) -> Vec<Hit> {
1377 check_with(
1378 &f.q,
1379 &f.runs,
1380 &f.repo,
1381 text,
1382 None,
1383 None,
1384 &|_, _| None,
1385 &move |_, _| state,
1386 )
1387 }
1388
1389 fn release(f: &Fx, id: &str, to: &str) {
1390 let path = f.runs.join(id).join("run.json");
1391 let mut v: serde_json::Value =
1392 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1393 v["released_to"] = serde_json::json!(to);
1394 std::fs::write(path, v.to_string()).unwrap();
1395 }
1396
1397 #[test]
1398 fn a_task_public_head_and_pr_are_claimed_only_by_the_successor() {
1399 let (f, base, _) = fx();
1400 write_run(&f, RID, "blocked", &base, Some((48, "open")));
1401 let next = "20260901-000001-bbbb";
1402 write_run(&f, next, "landing", &base, Some((48, "open")));
1403 for id in [RID, next] {
1404 let path = f.runs.join(id).join("run.json");
1405 let mut value: serde_json::Value =
1406 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1407 value["pr_head"] = serde_json::json!("magi/task-abcd-1234");
1408 if id == RID {
1409 value["pr_taken_over_by"] = serde_json::json!(next);
1410 }
1411 std::fs::write(path, value.to_string()).unwrap();
1412 }
1413 for text in ["update #48", "update magi/task-abcd-1234"] {
1414 let hits = with_forge(&f, text, Some(PrLifecycle::Open));
1415 assert!(!hits.is_empty());
1416 assert!(hits.iter().all(|h| h.id == next), "{hits:?}");
1417 }
1418 let task = file_task(&f, TaskStatus::Held, &[RID, next]);
1419 let hits = with_forge(&f, "update magi/task-abcd-1234", Some(PrLifecycle::Open));
1420 assert_eq!(hits.len(), 1, "{hits:?}");
1421 assert_eq!(hits[0].id, task.id);
1422 }
1423
1424 #[test]
1425 fn stale_open_pr_that_the_forge_says_is_merged_or_closed_does_not_claim() {
1426 for state in [PrLifecycle::Merged, PrLifecycle::Closed] {
1427 let (f, base, _) = fx();
1428 write_run(&f, RID, "superseded", &base, Some((48, "open")));
1429 assert!(with_forge(&f, "follow up on #48", Some(state)).is_empty());
1430 assert!(with_forge(&f, "magi/aaaa/A", Some(state)).is_empty());
1431 file_task(&f, TaskStatus::Held, &[RID]);
1433 let hits = with_forge(&f, "follow up on #48", Some(state));
1434 assert!(hits.iter().all(|h| h.signal != Signal::Pr), "{hits:?}");
1435 }
1436 }
1437
1438 #[test]
1439 fn stale_open_pr_with_an_unreadable_forge_still_claims() {
1440 let (f, base, _) = fx();
1441 write_run(&f, RID, "superseded", &base, Some((48, "open")));
1442 let hits = with_forge(&f, "follow up on #48", None);
1443 assert!(hits.iter().any(|h| h.signal == Signal::Pr), "{hits:?}");
1444 }
1445
1446 #[test]
1447 fn a_genuinely_open_pr_still_claims() {
1448 let (f, base, _) = fx();
1449 write_run(&f, RID, "blocked", &base, Some((48, "open")));
1450 let hits = with_forge(&f, "follow up on #48", Some(PrLifecycle::Open));
1451 assert!(hits.iter().any(|h| h.signal == Signal::Pr), "{hits:?}");
1452 }
1453
1454 #[test]
1455 fn a_released_run_defers_to_its_successor_without_asking_the_forge() {
1456 let (f, base, _) = fx();
1457 let next = "20260901-110000-bbbb";
1458 write_run(&f, RID, "superseded", &base, Some((48, "open")));
1459 write_run(&f, next, "merged", &base, Some((48, "merged")));
1460 release(&f, RID, next);
1461 let asked = std::cell::Cell::new(0);
1462 let hits = check_with(
1463 &f.q,
1464 &f.runs,
1465 &f.repo,
1466 "follow up on #48",
1467 None,
1468 None,
1469 &|_, _| None,
1470 &|_, _| {
1471 asked.set(asked.get() + 1);
1472 None
1473 },
1474 );
1475 assert!(hits.is_empty(), "{hits:?}");
1476 assert_eq!(asked.get(), 0);
1477 std::fs::remove_dir_all(f.runs.join(next)).unwrap();
1479 let hits = with_forge(&f, "follow up on #48", None);
1480 assert!(hits.iter().any(|h| h.signal == Signal::Pr), "{hits:?}");
1481 }
1482
1483 #[test]
1484 fn forge_lookups_are_cached_and_stop_after_a_failure() {
1485 let (f, base, _) = fx();
1486 for (i, id) in ["20260901-100000-aaa1", "20260901-100000-aaa2"]
1487 .iter()
1488 .enumerate()
1489 {
1490 write_run(&f, id, "blocked", &base, Some((48 + i as u64, "open")));
1491 }
1492 let asked = std::cell::Cell::new(0);
1493 check_with(
1494 &f.q,
1495 &f.runs,
1496 &f.repo,
1497 "x",
1498 None,
1499 None,
1500 &|_, _| None,
1501 &|_, _| {
1502 asked.set(asked.get() + 1);
1503 None
1504 },
1505 );
1506 assert_eq!(
1507 asked.get(),
1508 1,
1509 "an unreadable forge is asked once, not per PR"
1510 );
1511 }
1512
1513 use std::sync::Arc;
1514 use std::sync::atomic::{AtomicUsize, Ordering};
1515
1516 fn verdict(duplicate: bool) -> Result<Judgement> {
1517 Ok(Judgement {
1518 duplicate,
1519 reason: "because".into(),
1520 agent: "j".into(),
1521 })
1522 }
1523
1524 fn judged(
1527 f: &Fx,
1528 text: &str,
1529 answer: impl Fn() -> Result<Judgement> + Send + Sync + 'static,
1530 ) -> (Result<Screened, Duplicate>, usize) {
1531 let calls = Arc::new(AtomicUsize::new(0));
1532 let seen = calls.clone();
1533 let judge = move |_: String, _: Vec<Hit>| -> JudgeFuture {
1534 seen.fetch_add(1, Ordering::SeqCst);
1535 let r = answer();
1536 Box::pin(async move { r })
1537 };
1538 let hits = run(f, text, None);
1539 let out = tokio::runtime::Builder::new_current_thread()
1540 .build()
1541 .unwrap()
1542 .block_on(screen(hits, text, None, &judge));
1543 (out, calls.load(Ordering::SeqCst))
1544 }
1545
1546 const NAMES: &str = "seen on magi/aaaa/A, which does not touch this file";
1547
1548 fn fx_with_run() -> Fx {
1549 let (f, base, _) = fx();
1550 write_run(&f, RID, "reviewing", &base, None);
1551 f
1552 }
1553
1554 #[test]
1555 fn a_no_ruling_lets_the_work_through() {
1556 let f = fx_with_run();
1557 let (out, calls) = judged(&f, NAMES, || verdict(false));
1558 assert!(matches!(out, Ok(Screened::Cleared(_))));
1559 assert_eq!(calls, 1);
1560 }
1561
1562 #[test]
1563 fn a_yes_ruling_refuses_and_says_why() {
1564 let f = fx_with_run();
1565 let (out, _) = judged(&f, NAMES, || verdict(true));
1566 let msg = out.unwrap_err().to_string();
1567 assert!(
1568 msg.contains("duplicate - because") && msg.contains("--force"),
1569 "{msg}"
1570 );
1571 assert!(msg.contains("magi/aaaa/A"), "{msg}");
1572 }
1573
1574 #[test]
1575 fn a_failing_judge_lets_it_through_unjudged() {
1576 let f = fx_with_run();
1577 let (out, calls) = judged(&f, NAMES, || Err(anyhow::anyhow!("quota")));
1578 match out {
1579 Ok(Screened::Unjudged(why)) => assert!(why.contains("quota"), "{why}"),
1580 other => panic!("{other:?}"),
1581 }
1582 assert_eq!(calls, 1);
1583 }
1584
1585 #[test]
1586 fn garbage_replies_do_not_parse() {
1587 assert!(parse_judgement("sure, go ahead").is_err());
1588 assert!(parse_judgement(r#"{"duplicate":"maybe","reason":"x"}"#).is_err());
1589 assert!(parse_judgement(r#"{"duplicate":false}"#).is_err());
1590 assert!(parse_judgement(r#"{"duplicate":false,"reason":" "}"#).is_err());
1591 let two = "{\"duplicate\":false,\"reason\":\"c\"}\n{\"duplicate\":true}";
1592 assert!(parse_judgement(two).is_err());
1593 assert!(parse_judgement("ok {\"duplicate\":false,\"reason\":\"c\"}").is_err());
1594 let (d, why) = parse_judgement("{\"duplicate\":true,\"reason\":\"same\\nPR\"}").unwrap();
1595 assert_eq!((d, why.as_str()), (true, "same PR"));
1596 }
1597
1598 #[test]
1599 fn a_text_too_long_is_squeezed_and_still_judged() {
1600 let f = fx_with_run();
1601 let long = format!("{NAMES} {}", "x".repeat(prompt::DUPES_JUDGE_MAX_CHARS));
1602 let (out, calls) = judged(&f, &long, || verdict(false));
1603 assert!(matches!(out, Ok(Screened::Cleared(_))));
1604 assert_eq!(calls, 1);
1605 }
1606
1607 #[test]
1608 fn no_hit_never_asks_the_judge() {
1609 let (f, _, _) = fx();
1610 let (out, calls) = judged(&f, "nothing named here", || verdict(true));
1611 assert_eq!(out.unwrap(), Screened::Clean);
1612 assert_eq!(calls, 0);
1613 }
1614
1615 #[test]
1616 fn without_a_config_a_hit_passes_unjudged() {
1617 let f = fx_with_run();
1618 let hits = run(&f, NAMES, None);
1619 let out = tokio::runtime::Builder::new_current_thread()
1620 .build()
1621 .unwrap()
1622 .block_on(screen_with_config(hits, NAMES, None, &f.repo, None));
1623 assert!(matches!(out, Ok(Screened::Unjudged(_))));
1624 }
1625
1626 #[test]
1627 fn about_carries_title_and_whole_instruction() {
1628 assert_eq!(
1629 about_of("Retries", "# Task\nFix auth\nretries"),
1630 "Retries: # Task Fix auth retries"
1631 );
1632 assert_eq!(about_of("", "x"), "x");
1633 let mid = format!("{} MIDDLE {}", "a ".repeat(2000), "b ".repeat(500));
1634 assert!(about_of("", &mid).contains("MIDDLE"));
1635 let long = format!("{} TAIL", "y ".repeat(5000));
1636 let a = about_of("", &long);
1637 assert!(a.ends_with("TAIL") && a.chars().count() < 6100, "{a}");
1638 }
1639}