1use std::collections::{BTreeMap, BTreeSet, HashSet};
16use std::path::Path;
17
18use anyhow::{anyhow, bail, Context, Result};
19use serde::Serialize;
20use tracing::warn;
21
22use crate::claude::client::ClaudeClient;
23use crate::github_issues::{fetch_issues_refreshed, fetch_items_cached, IssueCache};
24use crate::jev::citations::{find_citations, first_citation, Citation};
25use crate::jev::client::JevClient;
26use crate::jev::error::is_auth_failure;
27use crate::jev::input::truncate_middle;
28use crate::jev::protocol::{Answer, NoulCriteria, Question, SystemOneRequest, Usage};
29use crate::provider::{Comment, IssueDoc, ItemKind, ItemRef};
30
31pub const DEFAULT_SUPPORTED: f64 = 0.5;
33pub const DEFAULT_REJECT_BELOW: f64 = 0.3;
35pub const DEFAULT_COVERAGE: f64 = 0.5;
37pub const MAX_STATEMENTS: usize = 100;
40
41fn resolve_cites_index(cites: &str, default_project: &str, sources: &[Source]) -> Option<usize> {
49 let (project, _kind, number) = first_citation(cites, default_project)?;
50 sources
51 .iter()
52 .position(|s| s.keys.contains(&(project.clone(), number)))
53}
54
55fn cites_judged_issue(cites: &str, judged: &IssueDoc) -> bool {
64 first_citation(cites, &judged.project).is_some_and(|(project, _kind, number)| {
65 project == judged.project && number == judged.number
66 })
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum CommentSelector {
74 Latest,
77 Id(u64),
79}
80
81pub fn parse_comment_selector(raw: &str) -> Result<CommentSelector> {
84 let raw = raw.trim();
85 if raw.eq_ignore_ascii_case("latest") {
86 return Ok(CommentSelector::Latest);
87 }
88 if let Ok(id) = raw.parse::<u64>() {
89 return Ok(CommentSelector::Id(id));
90 }
91 if let Some(digits) = raw.strip_prefix_last("#issuecomment-") {
92 if let Ok(id) = digits.parse::<u64>() {
93 return Ok(CommentSelector::Id(id));
94 }
95 }
96 bail!(
97 "`{raw}` is not a recognised comment selector \
98 (expected `latest`, a numeric comment id, or an issue-comment URL)"
99 );
100}
101
102trait StripPrefixLast {
104 fn strip_prefix_last<'a>(&'a self, sep: &str) -> Option<&'a str>;
105}
106impl StripPrefixLast for str {
107 fn strip_prefix_last<'a>(&'a self, sep: &str) -> Option<&'a str> {
108 self.rfind(sep).map(|i| &self[i + sep.len()..])
109 }
110}
111
112pub fn select_comment<'a>(
119 doc: &'a IssueDoc,
120 selector: &CommentSelector,
121 default_project: &str,
122) -> Result<&'a Comment> {
123 let judged = ItemRef {
124 provider: doc.provider,
125 project: doc.project.clone(),
126 kind: doc.kind,
127 number: doc.number,
128 };
129 match selector {
130 CommentSelector::Id(id) => {
131 doc.comments
132 .iter()
133 .find(|c| c.id == Some(*id))
134 .ok_or_else(|| {
135 anyhow!(
136 "issue {}#{} has no comment with id {id}",
137 doc.project,
138 doc.number
139 )
140 })
141 }
142 CommentSelector::Latest => doc
143 .comments
144 .iter()
145 .rev()
146 .find(|c| !find_citations(&c.body, default_project, &judged).is_empty())
147 .ok_or_else(|| {
148 anyhow!(
149 "issue {}#{} has no comment that cites another issue or pull request; \
150 pass --comment ID to select one explicitly",
151 doc.project,
152 doc.number
153 )
154 }),
155 }
156}
157
158#[derive(Debug, Clone)]
166pub struct Source {
167 pub label: String,
169 pub name: String,
171 pub items: Vec<String>,
173 pub keys: Vec<(String, u64)>,
176 pub text: String,
178}
179
180fn ref_label(project: &str, number: u64, judged_project: &str) -> String {
181 if project == judged_project {
182 format!("#{number}")
183 } else {
184 format!("{project}#{number}")
185 }
186}
187
188fn pr_label(project: &str, number: u64, judged_project: &str) -> String {
189 if project == judged_project {
190 format!("PR #{number}")
191 } else {
192 format!("PR {project}#{number}")
193 }
194}
195
196fn issue_heading(doc: &IssueDoc, judged_project: &str) -> String {
197 if doc.project == judged_project {
198 format!("# Issue #{}: {}", doc.number, doc.title)
199 } else {
200 format!("# Issue {}#{}: {}", doc.project, doc.number, doc.title)
201 }
202}
203
204fn pr_heading(doc: &IssueDoc, judged_project: &str) -> String {
205 if doc.project == judged_project {
206 format!("# Pull request #{}: {}", doc.number, doc.title)
207 } else {
208 format!(
209 "# Pull request {}#{}: {}",
210 doc.project, doc.number, doc.title
211 )
212 }
213}
214
215fn build_issue_source_text(
221 doc: &IssueDoc,
222 prs: &[IssueDoc],
223 judged_project: &str,
224 name: &str,
225) -> String {
226 let mut s = format!("{}\n\n{}\n", issue_heading(doc, judged_project), doc.body);
227 for c in &doc.comments {
228 s.push_str(&format!("\n**Comment by {}:**\n\n{}\n", c.author, c.body));
229 }
230 for pr in prs {
231 s.push_str(&format!(
232 "\n\n{}\n\n{}\n",
233 pr_heading(pr, judged_project),
234 pr.body
235 ));
236 }
237 format!("## SOURCE: {name}\n\n{}\n", s.trim())
238}
239
240fn build_pr_source_text(doc: &IssueDoc, judged_project: &str, name: &str) -> String {
245 let s = format!("{}\n\n{}\n", pr_heading(doc, judged_project), doc.body);
246 format!("## SOURCE: {name}\n\n{}\n", s.trim())
247}
248
249#[must_use]
259pub fn group_sources(
260 citations: &[Citation],
261 judged_project: &str,
262 fetched: &BTreeMap<(String, u64), IssueDoc>,
263) -> Vec<Source> {
264 let mut sources = Vec::new();
265 let mut consumed_prs: BTreeSet<(String, u64)> = BTreeSet::new();
266 let mut seen_issues: BTreeSet<(String, u64)> = BTreeSet::new();
267
268 for citation in citations {
269 let key = (citation.item_ref.project.clone(), citation.item_ref.number);
270 let Some(doc) = fetched.get(&key) else {
271 continue;
272 };
273 if doc.kind != ItemKind::Issue || !seen_issues.insert(key.clone()) {
274 continue;
275 }
276 let mut keys = vec![key.clone()];
277 let mut items = vec![ref_label(&doc.project, doc.number, judged_project)];
278 let mut pr_docs = Vec::new();
279 for pr_ref in &doc.closed_by {
280 let pr_key = (pr_ref.project.clone(), pr_ref.number);
281 if let Some(pr_doc) = fetched.get(&pr_key) {
282 items.push(pr_label(&pr_doc.project, pr_doc.number, judged_project));
283 pr_docs.push(pr_doc.clone());
284 keys.push(pr_key.clone());
285 consumed_prs.insert(pr_key);
286 }
287 }
288 let label = ref_label(&doc.project, doc.number, judged_project);
289 let name = if pr_docs.is_empty() {
290 label.clone()
291 } else {
292 format!("{label} and {}", items[1..].join(" and "))
293 };
294 let text = build_issue_source_text(doc, &pr_docs, judged_project, &name);
295 sources.push(Source {
296 label,
297 name,
298 items,
299 keys,
300 text,
301 });
302 }
303
304 let mut seen_prs: BTreeSet<(String, u64)> = BTreeSet::new();
305 for citation in citations {
306 let key = (citation.item_ref.project.clone(), citation.item_ref.number);
307 let Some(doc) = fetched.get(&key) else {
308 continue;
309 };
310 if doc.kind != ItemKind::ChangeRequest
311 || consumed_prs.contains(&key)
312 || !seen_prs.insert(key.clone())
313 {
314 continue;
315 }
316 let label = pr_label(&doc.project, doc.number, judged_project);
317 let text = build_pr_source_text(doc, judged_project, &label);
318 sources.push(Source {
319 label: label.clone(),
320 name: label.clone(),
321 items: vec![label],
322 keys: vec![key],
323 text,
324 });
325 }
326 sources
327}
328
329pub fn fetch_verify_input(
338 bin: &Path,
339 cache: &IssueCache,
340 default_project: &str,
341 judged: &ItemRef,
342 selector: &CommentSelector,
343) -> Result<(IssueDoc, Comment, Vec<Citation>, Vec<Source>)> {
344 let issue = fetch_issues_refreshed(bin, cache, std::slice::from_ref(judged))?
349 .into_iter()
350 .next()
351 .ok_or_else(|| anyhow!("issue {judged} missing from the parsed gh reply (bug)"))?;
352 let comment = select_comment(&issue, selector, default_project)?.clone();
353 let citations = find_citations(&comment.body, default_project, judged);
354 if citations.is_empty() {
355 return Ok((issue, comment, citations, Vec::new()));
356 }
357
358 let mut seen = HashSet::new();
359 let refs: Vec<ItemRef> = citations
360 .iter()
361 .map(|c| c.item_ref.clone())
362 .filter(|r| seen.insert((r.project.clone(), r.number)))
363 .collect();
364 let mut fetched: BTreeMap<(String, u64), IssueDoc> = BTreeMap::new();
365 for (item_ref, doc) in refs.iter().zip(fetch_items_cached(bin, cache, &refs)?) {
366 if let Some(doc) = doc {
367 fetched.insert((item_ref.project.clone(), item_ref.number), doc);
368 }
369 }
370
371 let mut seen_pr = HashSet::new();
372 let pr_refs: Vec<ItemRef> = fetched
373 .values()
374 .filter(|doc| doc.kind == ItemKind::Issue)
375 .flat_map(|doc| doc.closed_by.iter())
376 .filter(|pr| !fetched.contains_key(&(pr.project.clone(), pr.number)))
377 .filter(|pr| seen_pr.insert((pr.project.clone(), pr.number)))
378 .cloned()
379 .collect();
380 if !pr_refs.is_empty() {
381 for (item_ref, doc) in pr_refs
382 .iter()
383 .zip(fetch_items_cached(bin, cache, &pr_refs)?)
384 {
385 if let Some(doc) = doc {
386 fetched.insert((item_ref.project.clone(), item_ref.number), doc);
387 }
388 }
389 }
390
391 let sources = group_sources(&citations, &issue.project, &fetched);
392 Ok((issue, comment, citations, sources))
393}
394
395#[derive(Debug, Clone, serde::Deserialize)]
400struct SplitStatement {
401 text: String,
402 cites: Option<String>,
403}
404
405#[derive(Debug, Clone, serde::Deserialize)]
406struct SplitResponse {
407 statements: Vec<SplitStatement>,
408}
409
410const SPLIT_SYSTEM_PROMPT: &str =
414 "You split a maintainer's decision comment into single checkable \
415 factual statements. Reply with the requested object and nothing else.";
416
417fn build_split_user_prompt(comment_body: &str, citations: &[Citation]) -> String {
438 let cited = if citations.is_empty() {
439 "(none)".to_string()
440 } else {
441 citations
442 .iter()
443 .map(|c| c.raw.as_str())
444 .collect::<Vec<_>>()
445 .join(", ")
446 };
447 format!(
448 "Split the COMMENT below into single factual statements, so that each statement can be checked\n\
449 on its own against the item it cites.\n\n\
450 Rules:\n\
451 1. One fact per statement. Split \"A and B\" into two statements.\n\
452 2. Make every statement self-contained: name the item it is about (for example \"PR #1629 added\n\
453 ...\"), replace pronouns with what they refer to, and do not depend on other statements.\n\
454 3. Keep certainty exactly as written. \"was decided\", \"was considered\", \"is deferred\", \"is tracked\n\
455 separately\" and \"may happen later\" are different facts. Never make a claim stronger or weaker.\n\
456 4. Do not add, infer, summarise or correct anything. If the comment says something wrong, keep\n\
457 the wrong claim as written.\n\
458 5. Include every factual claim about a cited item, including conclusions such as \"so the open\n\
459 question is settled\"; restate exactly what the comment says was settled.\n\
460 6. Set \"cites\" to the one item the statement is about, written as it appears in the comment\n\
461 (for example \"#1614\" or \"PR #1629\"). If the statement is a decision about the judged issue\n\
462 itself rather than a claim about a cited item, set \"cites\" to null.\n\
463 A statement that a cited item resolved, settled or answered the judged issue's question is\n\
464 about the judged issue, so its \"cites\" is null. A statement about what a cited item did or\n\
465 decided cites that item, even when it also mentions another item (such as a tracking issue).\n\
466 7. Leave out greetings, opinions and instructions that make no factual claim.\n\n\
467 The COMMENT was posted on the judged issue, which the comment calls \"this issue\". Refer to it\n\
468 the same way; do not add its number.\n\n\
469 COMMENT:\n{comment}\n\n\
470 Items the comment cites: {cited}\n\n\
471 Reply with an object whose \"statements\" field is a list; each entry has a \"text\" string and\n\
472 a \"cites\" string or null. For example:\n\
473 {{\"statements\": [{{\"text\": \"PR #1629 added a tool.\", \"cites\": \"PR #1629\"}}]}}",
474 comment = comment_body.trim(),
475 )
476}
477
478fn strip_code_fence(s: &str) -> &str {
482 let s = s
483 .strip_prefix("```json")
484 .or_else(|| s.strip_prefix("```yaml"))
485 .or_else(|| s.strip_prefix("```"))
486 .map_or(s, str::trim_start);
487 s.strip_suffix("```").map_or(s, str::trim_end)
488}
489
490fn parse_split_response(raw: &str) -> Result<SplitResponse> {
491 let trimmed = raw.trim();
492 if let Ok(parsed) = serde_json::from_str(trimmed) {
493 return Ok(parsed);
494 }
495 serde_yaml::from_str(strip_code_fence(trimmed))
496 .with_context(|| format!("Failed to parse the statement splitter's response: {trimmed}"))
497}
498
499async fn split_statements(
503 ai: &ClaudeClient,
504 comment_body: &str,
505 citations: &[Citation],
506) -> Result<Vec<SplitStatement>> {
507 let user_prompt = build_split_user_prompt(comment_body, citations);
508 let raw = ai
509 .send_message(SPLIT_SYSTEM_PROMPT, &user_prompt)
510 .await
511 .context("Failed to split the decision comment into statements")?;
512 let parsed = parse_split_response(&raw)?;
513 if parsed.statements.is_empty() {
514 bail!("the statement splitter returned no statements");
515 }
516 if parsed.statements.len() > MAX_STATEMENTS {
517 bail!(
518 "the statement splitter returned {} statements, more than the {MAX_STATEMENTS} limit",
519 parsed.statements.len()
520 );
521 }
522 if parsed.statements.iter().any(|s| s.text.trim().is_empty()) {
523 bail!("the statement splitter returned an empty statement");
524 }
525 Ok(parsed.statements)
526}
527
528fn statement_question(text: &str) -> Question {
533 Question::Noul {
534 instructions: format!(
535 "Is the following single STATEMENT supported by the SOURCE text? Answer true only if the source \
536 states it or directly shows it. Answer false if the source contradicts it, if the source leaves \
537 it open, optional, deferred or merely possible, or if the source does not mention it. STATEMENT: {text}"
538 ),
539 criteria: Some(NoulCriteria {
540 true_means: Some("The source supports the statement".to_string()),
541 false_means: Some("The source contradicts it, leaves it open, or does not mention it".to_string()),
542 }),
543 }
544}
545
546fn coverage_question() -> Question {
550 Question::Noul {
551 instructions: "Do the numbered STATEMENTS together say everything the COMMENT claims, with nothing \
552 added, strengthened or weakened? Answer false if any claim in the comment is missing, if any \
553 statement adds something the comment does not say, or if any statement changes how certain or \
554 final a claim is (for example \"considered\" becoming \"decided\")."
555 .to_string(),
556 criteria: Some(NoulCriteria {
557 true_means: Some("The statements faithfully cover the comment".to_string()),
558 false_means: Some("A claim is missing, added, or changed in certainty".to_string()),
559 }),
560 }
561}
562
563fn build_coverage_state(comment_body: &str, statements: &[SplitStatement]) -> String {
564 let mut s = format!("## COMMENT\n\n{}\n\n## STATEMENTS\n\n", comment_body.trim());
565 for (i, st) in statements.iter().enumerate() {
566 s.push_str(&format!("{}. {}\n", i + 1, st.text));
567 }
568 s
569}
570
571#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
575#[serde(rename_all = "snake_case")]
576pub enum Verdict {
577 Accepted,
580 Rejected,
582 NeedsReview,
586}
587
588#[derive(Debug, Clone, Serialize)]
590pub struct CommentRef {
591 pub id: Option<u64>,
593 pub author: Option<String>,
595}
596
597#[derive(Debug, Clone, Serialize)]
599pub struct VerifiedStatement {
600 pub text: String,
602 #[serde(skip_serializing_if = "Option::is_none")]
606 pub source: Option<String>,
607 #[serde(skip_serializing_if = "Option::is_none")]
611 pub supported: Option<f64>,
612 #[serde(skip_serializing_if = "Option::is_none")]
614 pub reason: Option<String>,
615}
616
617#[derive(Debug, Clone, Serialize)]
619pub struct SourceReport {
620 pub source: String,
622 pub items: Vec<String>,
624 #[serde(skip_serializing_if = "std::ops::Not::not")]
626 pub truncated: bool,
627 #[serde(skip_serializing_if = "Option::is_none")]
629 pub error: Option<String>,
630}
631
632#[derive(Debug, Clone, Default, Serialize)]
640pub struct ReportModels {
641 #[serde(skip_serializing_if = "String::is_empty")]
643 pub jev: String,
644 #[serde(skip_serializing_if = "String::is_empty")]
646 pub ai: String,
647}
648
649#[derive(Debug, Clone, Default, Serialize)]
651pub struct ReportUsage {
652 pub jev: Usage,
654}
655
656#[derive(Debug, Clone, Serialize)]
658pub struct VerifyReport {
659 #[serde(rename = "issue")]
661 pub item_ref: String,
662 pub url: String,
664 pub comment: CommentRef,
666 pub verdict: Verdict,
668 #[serde(skip_serializing_if = "Vec::is_empty")]
670 pub reasons: Vec<String>,
671 #[serde(skip_serializing_if = "Option::is_none")]
673 pub coverage: Option<f64>,
674 pub sources: Vec<SourceReport>,
676 pub statements: Vec<VerifiedStatement>,
678 pub models: ReportModels,
680 pub usage: ReportUsage,
682}
683
684#[derive(Debug, Clone)]
686pub struct VerifyOptions {
687 pub jev_model: String,
689 pub supported: f64,
691 pub reject_below: f64,
693 pub coverage: f64,
695 pub max_input_chars: usize,
697}
698
699fn validate_verify_options(opts: &VerifyOptions) -> Result<()> {
700 if !(0.0..=1.0).contains(&opts.reject_below) || !(0.0..=1.0).contains(&opts.supported) {
701 bail!("--threshold and --reject-below must each be between 0 and 1");
702 }
703 if opts.reject_below > opts.supported {
704 bail!("--reject-below must not be greater than --threshold");
705 }
706 if !(0.0..=1.0).contains(&opts.coverage) {
707 bail!("--coverage-threshold must be between 0 and 1");
708 }
709 if opts.max_input_chars == 0 {
710 bail!("the input cap must be at least 1 character");
711 }
712 Ok(())
713}
714
715pub async fn run_verify(
730 jev: &JevClient,
731 ai: &ClaudeClient,
732 issue: &IssueDoc,
733 comment: &Comment,
734 citations: &[Citation],
735 sources: &[Source],
736 opts: &VerifyOptions,
737) -> Result<VerifyReport> {
738 validate_verify_options(opts)?;
739
740 let item_ref = format!("{}#{}", issue.project, issue.number);
741 let comment_ref = CommentRef {
742 id: comment.id,
743 author: Some(comment.author.clone()),
744 };
745
746 if citations.is_empty() || sources.is_empty() {
747 return Ok(VerifyReport {
748 item_ref,
749 url: issue.url.clone(),
750 comment: comment_ref,
751 verdict: Verdict::NeedsReview,
752 reasons: vec!["the comment cites nothing verifiable".to_string()],
753 coverage: None,
754 sources: Vec::new(),
755 statements: Vec::new(),
756 models: ReportModels::default(),
757 usage: ReportUsage::default(),
758 });
759 }
760
761 let split = split_statements(ai, &comment.body, citations).await?;
762 let ai_model = ai.get_ai_client_metadata().model;
763
764 let source_of: Vec<Option<usize>> = split
766 .iter()
767 .map(|s| {
768 s.cites
769 .as_deref()
770 .and_then(|c| resolve_cites_index(c, &issue.project, sources))
771 })
772 .collect();
773 let mut by_source: Vec<Vec<usize>> = vec![Vec::new(); sources.len()];
774 for (i, src) in source_of.iter().enumerate() {
775 if let Some(idx) = src {
776 by_source[*idx].push(i);
777 }
778 }
779
780 let mut jev_model = String::new();
781 let mut usage = Usage::default();
782 let mut supported: BTreeMap<usize, f64> = BTreeMap::new();
783 let mut source_reports = Vec::with_capacity(sources.len());
784
785 for (idx, source) in sources.iter().enumerate() {
786 let indices = &by_source[idx];
787 let (state, truncated) = truncate_middle(&source.text, opts.max_input_chars);
788 if indices.is_empty() {
789 source_reports.push(SourceReport {
793 source: source.name.clone(),
794 items: source.items.clone(),
795 truncated,
796 error: None,
797 });
798 continue;
799 }
800 let questions = indices
801 .iter()
802 .map(|&i| (format!("s{}", i + 1), statement_question(&split[i].text)))
803 .collect();
804 let request = SystemOneRequest {
805 state: serde_json::Value::String(state),
806 model: opts.jev_model.clone(),
807 questions,
808 };
809 match jev.system_one(&request).await {
810 Err(err) if is_auth_failure(&err) => {
811 return Err(err)
812 .with_context(|| format!("Failed to verify against source {}", source.label));
813 }
814 Err(err) => {
815 let error = format!("{err:#}");
816 warn!("Could not verify against source {}: {error}", source.label);
817 source_reports.push(SourceReport {
818 source: source.name.clone(),
819 items: source.items.clone(),
820 truncated,
821 error: Some(error),
822 });
823 }
824 Ok(response) => {
825 jev_model = response.model;
826 usage.input_tokens += response.usage.input_tokens;
827 usage.output_tokens += response.usage.output_tokens;
828 for &i in indices {
829 if let Some(Answer::Noul { noul }) =
830 response.answers.get(&format!("s{}", i + 1))
831 {
832 supported.insert(i, *noul);
833 }
834 }
835 source_reports.push(SourceReport {
836 source: source.name.clone(),
837 items: source.items.clone(),
838 truncated,
839 error: None,
840 });
841 }
842 }
843 }
844
845 let mut coverage = None;
846 let mut coverage_reason = None;
847 {
848 let state = build_coverage_state(&comment.body, &split);
849 let request = SystemOneRequest {
850 state: serde_json::Value::String(state),
851 model: opts.jev_model.clone(),
852 questions: BTreeMap::from([("coverage".to_string(), coverage_question())]),
853 };
854 match jev.system_one(&request).await {
855 Err(err) if is_auth_failure(&err) => {
856 return Err(err).context("Failed to check statement coverage");
857 }
858 Err(err) => {
859 let msg = format!("{err:#}");
860 warn!("Could not check statement coverage: {msg}");
861 coverage_reason = Some(format!("coverage unavailable: {msg}"));
862 }
863 Ok(response) => {
864 jev_model = response.model;
865 usage.input_tokens += response.usage.input_tokens;
866 usage.output_tokens += response.usage.output_tokens;
867 if let Some(Answer::Noul { noul }) = response.answers.get("coverage") {
868 coverage = Some(*noul);
869 } else {
870 warn!("The coverage call returned no usable `coverage` answer");
875 coverage_reason = Some(
876 "coverage unavailable: the reply carried no `coverage` answer".to_string(),
877 );
878 }
879 }
880 }
881 }
882
883 let mut statements = Vec::with_capacity(split.len());
884 let mut reasons = Vec::new();
885 let mut any_rejected = false;
886 let mut any_review = false;
887
888 for (i, statement) in split.iter().enumerate() {
889 let (source_label, score, reason) = match source_of[i] {
890 Some(idx) => {
891 let src = &sources[idx];
892 let score = supported.get(&i).copied();
893 let reason = match score {
894 None => {
895 any_review = true;
900 Some(format!(
901 "statement {} has no answer against {}",
902 i + 1,
903 src.label
904 ))
905 }
906 Some(s) if s < opts.reject_below => {
907 any_rejected = true;
908 Some(format!(
909 "statement {} scored {s:.2} (< {:.2}) against {}",
910 i + 1,
911 opts.reject_below,
912 src.label
913 ))
914 }
915 Some(s) if s < opts.supported => {
916 any_review = true;
917 Some(format!(
918 "statement {} scored {s:.2} (uncertain) against {}",
919 i + 1,
920 src.label
921 ))
922 }
923 Some(_) => None,
924 };
925 (Some(src.label.clone()), score, reason)
926 }
927 None => match &statement.cites {
928 None => (None, None, None),
929 Some(raw) if cites_judged_issue(raw, issue) => (None, None, None),
932 Some(raw) => {
933 any_review = true;
934 (
935 Some(raw.clone()),
936 None,
937 Some(format!(
938 "statement {} cites {raw:?}, which could not be resolved",
939 i + 1
940 )),
941 )
942 }
943 },
944 };
945 if let Some(msg) = &reason {
946 reasons.push(msg.clone());
947 }
948 statements.push(VerifiedStatement {
949 text: statement.text.clone(),
950 source: source_label,
951 supported: score,
952 reason,
953 });
954 }
955
956 if statements.iter().all(|s| s.supported.is_none()) {
957 any_review = true;
958 reasons.push("no statement could be checked against a resolved source".to_string());
959 }
960
961 match (coverage, &coverage_reason) {
962 (Some(cov), _) if cov < opts.coverage => {
963 any_review = true;
964 reasons.push(format!("coverage scored {cov:.2} (< {:.2})", opts.coverage));
965 }
966 (None, Some(msg)) => {
967 any_review = true;
968 reasons.push(msg.clone());
969 }
970 _ => {}
971 }
972
973 let verdict = if any_rejected {
974 Verdict::Rejected
975 } else if any_review {
976 Verdict::NeedsReview
977 } else {
978 Verdict::Accepted
979 };
980
981 Ok(VerifyReport {
982 item_ref,
983 url: issue.url.clone(),
984 comment: comment_ref,
985 verdict,
986 reasons,
987 coverage,
988 sources: source_reports,
989 statements,
990 models: ReportModels {
991 jev: jev_model,
992 ai: ai_model,
993 },
994 usage: ReportUsage { jev: usage },
995 })
996}
997
998#[cfg(test)]
999#[allow(clippy::unwrap_used, clippy::expect_used)]
1000mod tests {
1001 use super::*;
1002 use crate::claude::test_utils::ConfigurableMockAiClient;
1003 use crate::provider::{GitProvider, ItemState};
1004 use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
1005 use std::path::PathBuf;
1006
1007 fn judged(number: u64) -> ItemRef {
1008 ItemRef {
1009 provider: GitProvider::GitHub,
1010 project: "rust-works/omni-dev".to_string(),
1011 kind: ItemKind::Issue,
1012 number,
1013 }
1014 }
1015
1016 #[test]
1023 fn a_citation_in_backticks_is_still_a_source() {
1024 let citations = find_citations("settled by `#1614`", "rust-works/omni-dev", &judged(1));
1025 assert_eq!(citations.len(), 1);
1026 assert_eq!(citations[0].raw, "#1614");
1027 let issue = issue_doc(1614, "Title", "Body.", vec![], vec![]);
1028 let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1614), issue)]);
1029 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1030 assert_eq!(
1031 resolve_cites_index("`#1614`", "rust-works/omni-dev", &sources),
1032 Some(0)
1033 );
1034 }
1035
1036 #[test]
1037 fn resolve_cites_index_matches_a_sources_keys() {
1038 let citations = find_citations("see #1614", "rust-works/omni-dev", &judged(1));
1039 let issue = issue_doc(1614, "Title", "Body.", vec![], vec![]);
1040 let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1614), issue)]);
1041 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1042 assert_eq!(
1043 resolve_cites_index("#1614", "rust-works/omni-dev", &sources),
1044 Some(0)
1045 );
1046 }
1047
1048 #[test]
1051 fn resolve_cites_index_is_none_for_an_unparseable_cites_string() {
1052 let sources = Vec::new();
1053 assert_eq!(
1054 resolve_cites_index("#1-overview", "rust-works/omni-dev", &sources),
1055 None
1056 );
1057 }
1058
1059 #[test]
1060 fn cites_judged_issue_is_true_only_for_the_judged_number() {
1061 let issue = doc_with_comments(vec![]);
1062 assert!(cites_judged_issue("#1779", &issue));
1063 assert!(!cites_judged_issue("#1614", &issue));
1064 assert!(!cites_judged_issue("#1-overview", &issue));
1065 }
1066
1067 #[test]
1070 fn parses_latest_case_insensitively() {
1071 assert_eq!(
1072 parse_comment_selector("Latest").unwrap(),
1073 CommentSelector::Latest
1074 );
1075 }
1076
1077 #[test]
1078 fn parses_a_numeric_id() {
1079 assert_eq!(
1080 parse_comment_selector("123").unwrap(),
1081 CommentSelector::Id(123)
1082 );
1083 }
1084
1085 #[test]
1086 fn parses_an_issue_comment_url() {
1087 let sel = parse_comment_selector(
1088 "https://github.com/rust-works/omni-dev/issues/1779#issuecomment-5742808381",
1089 )
1090 .unwrap();
1091 assert_eq!(sel, CommentSelector::Id(5_742_808_381));
1092 }
1093
1094 #[test]
1095 fn rejects_garbage_selector() {
1096 assert!(parse_comment_selector("whenever").is_err());
1097 }
1098
1099 #[test]
1102 fn rejects_an_issue_comment_url_with_a_non_numeric_id() {
1103 assert!(parse_comment_selector(
1104 "https://github.com/rust-works/omni-dev/issues/1779#issuecomment-abc"
1105 )
1106 .is_err());
1107 }
1108
1109 fn doc_with_comments(comments: Vec<Comment>) -> IssueDoc {
1110 IssueDoc {
1111 provider: GitProvider::GitHub,
1112 project: "rust-works/omni-dev".to_string(),
1113 number: 1779,
1114 kind: ItemKind::Issue,
1115 title: "t".to_string(),
1116 state: ItemState::Open,
1117 body: "b".to_string(),
1118 comments,
1119 closed_by: vec![],
1120 url: "https://github.com/rust-works/omni-dev/issues/1779".to_string(),
1121 }
1122 }
1123
1124 fn comment(id: u64, author: &str, body: &str) -> Comment {
1125 Comment {
1126 author: author.to_string(),
1127 body: body.to_string(),
1128 id: Some(id),
1129 }
1130 }
1131
1132 #[test]
1133 fn select_comment_latest_picks_the_last_comment_that_cites_something() {
1134 let doc = doc_with_comments(vec![
1135 comment(1, "alice", "no citation here"),
1136 comment(2, "bob", "settled by #1614"),
1137 comment(3, "carol", "thanks!"),
1138 ]);
1139 let selected =
1140 select_comment(&doc, &CommentSelector::Latest, "rust-works/omni-dev").unwrap();
1141 assert_eq!(selected.id, Some(2));
1142 }
1143
1144 #[test]
1147 fn select_comment_latest_counts_a_citation_in_code() {
1148 let doc = doc_with_comments(vec![
1149 comment(1, "alice", "settled by `#1614`"),
1150 comment(2, "bob", "thanks!"),
1151 ]);
1152 let selected =
1153 select_comment(&doc, &CommentSelector::Latest, "rust-works/omni-dev").unwrap();
1154 assert_eq!(selected.id, Some(1));
1155 }
1156
1157 #[test]
1158 fn select_comment_latest_errors_when_none_cite_anything() {
1159 let doc = doc_with_comments(vec![comment(1, "alice", "thanks!")]);
1160 let err =
1161 select_comment(&doc, &CommentSelector::Latest, "rust-works/omni-dev").unwrap_err();
1162 assert!(err.to_string().contains("--comment ID"), "{err}");
1163 }
1164
1165 #[test]
1166 fn select_comment_by_id_errors_when_missing() {
1167 let doc = doc_with_comments(vec![comment(1, "alice", "#1614")]);
1168 let err =
1169 select_comment(&doc, &CommentSelector::Id(999), "rust-works/omni-dev").unwrap_err();
1170 assert!(err.to_string().contains("999"), "{err}");
1171 }
1172
1173 fn issue_doc(
1176 number: u64,
1177 title: &str,
1178 body: &str,
1179 comments: Vec<Comment>,
1180 closed_by: Vec<ItemRef>,
1181 ) -> IssueDoc {
1182 IssueDoc {
1183 provider: GitProvider::GitHub,
1184 project: "rust-works/omni-dev".to_string(),
1185 number,
1186 kind: ItemKind::Issue,
1187 title: title.to_string(),
1188 state: ItemState::Closed,
1189 body: body.to_string(),
1190 comments,
1191 closed_by,
1192 url: format!("https://github.com/rust-works/omni-dev/issues/{number}"),
1193 }
1194 }
1195
1196 fn pr_doc(number: u64, title: &str, body: &str) -> IssueDoc {
1197 IssueDoc {
1198 provider: GitProvider::GitHub,
1199 project: "rust-works/omni-dev".to_string(),
1200 number,
1201 kind: ItemKind::ChangeRequest,
1202 title: title.to_string(),
1203 state: ItemState::Closed,
1204 body: body.to_string(),
1205 comments: vec![],
1206 closed_by: vec![],
1207 url: format!("https://github.com/rust-works/omni-dev/pull/{number}"),
1208 }
1209 }
1210
1211 fn pr_ref(number: u64) -> ItemRef {
1212 ItemRef {
1213 provider: GitProvider::GitHub,
1214 project: "rust-works/omni-dev".to_string(),
1215 kind: ItemKind::ChangeRequest,
1216 number,
1217 }
1218 }
1219
1220 #[test]
1222 fn group_sources_merges_a_cited_issue_with_its_closing_pr() {
1223 let citations = find_citations("settled by #1614", "rust-works/omni-dev", &judged(1779));
1224 let issue = issue_doc(
1225 1614,
1226 "Sheets MCP tools",
1227 "Body text.",
1228 vec![comment(1, "newhoggy", "A human comment.")],
1229 vec![pr_ref(1629)],
1230 );
1231 let pr = pr_doc(1629, "Add drive_sheets_info", "PR body text.");
1232 let fetched = BTreeMap::from([
1233 (("rust-works/omni-dev".to_string(), 1614), issue),
1234 (("rust-works/omni-dev".to_string(), 1629), pr),
1235 ]);
1236 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1237 assert_eq!(sources.len(), 1);
1238 assert_eq!(sources[0].label, "#1614");
1239 assert_eq!(sources[0].name, "#1614 and PR #1629");
1240 assert_eq!(
1241 sources[0].keys,
1242 vec![
1243 ("rust-works/omni-dev".to_string(), 1614),
1244 ("rust-works/omni-dev".to_string(), 1629)
1245 ]
1246 );
1247 assert_eq!(
1248 sources[0].text,
1249 "## SOURCE: #1614 and PR #1629\n\n\
1250 # Issue #1614: Sheets MCP tools\n\n\
1251 Body text.\n\
1252 \n**Comment by newhoggy:**\n\nA human comment.\n\
1253 \n\n# Pull request #1629: Add drive_sheets_info\n\n\
1254 PR body text.\n"
1255 );
1256 }
1257
1258 #[test]
1261 fn group_sources_uses_a_qualified_label_for_a_cross_repo_closing_pr() {
1262 let citations = find_citations("settled by #1614", "rust-works/omni-dev", &judged(1779));
1263 let issue = issue_doc(
1264 1614,
1265 "Title",
1266 "Body.",
1267 vec![],
1268 vec![ItemRef {
1269 provider: GitProvider::GitHub,
1270 project: "other/repo".to_string(),
1271 kind: ItemKind::ChangeRequest,
1272 number: 42,
1273 }],
1274 );
1275 let mut pr = pr_doc(42, "Fix", "PR body.");
1276 pr.project = "other/repo".to_string();
1277 let fetched = BTreeMap::from([
1278 (("rust-works/omni-dev".to_string(), 1614), issue),
1279 (("other/repo".to_string(), 42), pr),
1280 ]);
1281 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1282 assert_eq!(sources.len(), 1);
1283 assert_eq!(sources[0].name, "#1614 and PR other/repo#42");
1284 assert!(sources[0]
1285 .text
1286 .contains("# Pull request other/repo#42: Fix"));
1287 }
1288
1289 #[test]
1292 fn group_sources_keeps_a_bare_label_when_there_is_no_closing_pr() {
1293 let citations = find_citations("see #1237", "rust-works/omni-dev", &judged(1));
1294 let issue = issue_doc(1237, "Title", "Body.", vec![], vec![]);
1295 let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1237), issue)]);
1296 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1297 assert_eq!(sources[0].name, "#1237");
1298 }
1299
1300 #[test]
1301 fn group_sources_makes_a_standalone_source_for_a_pr_not_closing_any_cited_issue() {
1302 let citations = find_citations("see PR #999", "rust-works/omni-dev", &judged(1));
1303 let pr = pr_doc(999, "Some PR", "PR body.");
1304 let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 999), pr)]);
1305 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1306 assert_eq!(sources.len(), 1);
1307 assert_eq!(sources[0].label, "PR #999");
1308 assert_eq!(
1309 sources[0].text,
1310 "## SOURCE: PR #999\n\n# Pull request #999: Some PR\n\nPR body.\n"
1311 );
1312 }
1313
1314 #[test]
1315 fn group_sources_uses_a_qualified_label_across_repos() {
1316 let citations = find_citations("see other/repo#5", "rust-works/omni-dev", &judged(1));
1317 let mut issue = issue_doc(5, "Cross repo", "Body.", vec![], vec![]);
1318 issue.project = "other/repo".to_string();
1319 let fetched = BTreeMap::from([(("other/repo".to_string(), 5), issue)]);
1320 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1321 assert_eq!(sources[0].label, "other/repo#5");
1322 }
1323
1324 #[test]
1325 fn group_sources_skips_a_citation_that_never_resolved() {
1326 let citations = find_citations("see #404", "rust-works/omni-dev", &judged(1));
1327 let sources = group_sources(&citations, "rust-works/omni-dev", &BTreeMap::new());
1328 assert!(sources.is_empty());
1329 }
1330
1331 #[test]
1334 fn statement_question_reproduces_the_tested_wording() {
1335 let Question::Noul {
1336 instructions,
1337 criteria,
1338 } = statement_question("PR #1629 added a tool.")
1339 else {
1340 panic!("expected a noul question");
1341 };
1342 assert_eq!(
1343 instructions,
1344 "Is the following single STATEMENT supported by the SOURCE text? Answer true only if the source \
1345 states it or directly shows it. Answer false if the source contradicts it, if the source leaves \
1346 it open, optional, deferred or merely possible, or if the source does not mention it. STATEMENT: \
1347 PR #1629 added a tool."
1348 );
1349 let criteria = criteria.unwrap();
1350 assert_eq!(
1351 criteria.true_means.unwrap(),
1352 "The source supports the statement"
1353 );
1354 assert_eq!(
1355 criteria.false_means.unwrap(),
1356 "The source contradicts it, leaves it open, or does not mention it"
1357 );
1358 }
1359
1360 #[test]
1361 fn coverage_question_reproduces_the_tested_wording() {
1362 let Question::Noul {
1363 instructions,
1364 criteria,
1365 } = coverage_question()
1366 else {
1367 panic!("expected a noul question");
1368 };
1369 assert_eq!(
1370 instructions,
1371 "Do the numbered STATEMENTS together say everything the COMMENT claims, with nothing \
1372 added, strengthened or weakened? Answer false if any claim in the comment is missing, if any \
1373 statement adds something the comment does not say, or if any statement changes how certain or \
1374 final a claim is (for example \"considered\" becoming \"decided\")."
1375 );
1376 let criteria = criteria.unwrap();
1377 assert_eq!(
1378 criteria.true_means.unwrap(),
1379 "The statements faithfully cover the comment"
1380 );
1381 assert_eq!(
1382 criteria.false_means.unwrap(),
1383 "A claim is missing, added, or changed in certainty"
1384 );
1385 }
1386
1387 #[test]
1388 fn build_coverage_state_numbers_statements_from_one() {
1389 let statements = vec![
1390 SplitStatement {
1391 text: "First fact.".to_string(),
1392 cites: None,
1393 },
1394 SplitStatement {
1395 text: "Second fact.".to_string(),
1396 cites: None,
1397 },
1398 ];
1399 let state = build_coverage_state("The comment body.", &statements);
1400 assert_eq!(
1401 state,
1402 "## COMMENT\n\nThe comment body.\n\n## STATEMENTS\n\n1. First fact.\n2. Second fact.\n"
1403 );
1404 }
1405
1406 #[test]
1407 fn split_prompt_names_every_citation_and_falls_back_to_none() {
1408 let citations = find_citations("see #1614 and PR #1629", "rust-works/omni-dev", &judged(1));
1409 let prompt = build_split_user_prompt("The comment.", &citations);
1410 assert!(
1411 prompt.contains("Items the comment cites: #1614, PR #1629"),
1412 "{prompt}"
1413 );
1414 assert!(prompt.contains("COMMENT:\nThe comment."), "{prompt}");
1415
1416 let prompt = build_split_user_prompt("no citations here", &[]);
1417 assert!(
1418 prompt.contains("Items the comment cites: (none)"),
1419 "{prompt}"
1420 );
1421 }
1422
1423 #[test]
1427 fn split_prompt_keeps_the_two_additions_that_fixed_live_failures() {
1428 let prompt = build_split_user_prompt("The comment.", &[]);
1429 assert!(
1432 prompt.contains("resolved, settled or answered the judged issue's question"),
1433 "{prompt}"
1434 );
1435 assert!(prompt.contains("do not add its number"), "{prompt}");
1436 assert!(prompt.contains(r#""statements""#), "{prompt}");
1438 assert!(prompt.contains(r#""cites""#), "{prompt}");
1439 }
1440
1441 #[test]
1442 fn strip_code_fence_removes_json_and_bare_fences() {
1443 assert_eq!(strip_code_fence("```json\n{\"a\":1}\n```"), "{\"a\":1}");
1444 assert_eq!(strip_code_fence("```\n{\"a\":1}\n```"), "{\"a\":1}");
1445 assert_eq!(strip_code_fence("{\"a\":1}"), "{\"a\":1}");
1446 }
1447
1448 #[test]
1451 fn parse_split_response_falls_back_to_yaml() {
1452 let raw =
1453 "```yaml\nstatements:\n - text: \"PR #1629 added a tool.\"\n cites: \"#1614\"\n```";
1454 let parsed = parse_split_response(raw).unwrap();
1455 assert_eq!(parsed.statements.len(), 1);
1456 assert_eq!(parsed.statements[0].text, "PR #1629 added a tool.");
1457 assert_eq!(parsed.statements[0].cites.as_deref(), Some("#1614"));
1458 }
1459
1460 #[test]
1461 fn parse_split_response_errors_when_neither_json_nor_yaml_parses() {
1462 let err = parse_split_response("This is not json or yaml.").unwrap_err();
1463 assert!(
1464 format!("{err:#}").contains("Failed to parse the statement splitter's response"),
1465 "{err:#}"
1466 );
1467 }
1468
1469 #[tokio::test]
1470 async fn split_statements_rejects_an_empty_reply() {
1471 let ai = ai_client(vec![Ok(serde_json::json!({"statements": []}).to_string())]);
1472 let err = split_statements(&ai, "comment", &[]).await.unwrap_err();
1473 assert!(err.to_string().contains("no statements"), "{err}");
1474 }
1475
1476 #[tokio::test]
1477 async fn split_statements_rejects_more_than_the_statement_limit() {
1478 let stmts: Vec<serde_json::Value> = (0..=MAX_STATEMENTS)
1479 .map(|i| serde_json::json!({"text": format!("statement {i}"), "cites": null}))
1480 .collect();
1481 let ai = ai_client(vec![Ok(
1482 serde_json::json!({"statements": stmts}).to_string()
1483 )]);
1484 let err = split_statements(&ai, "comment", &[]).await.unwrap_err();
1485 assert!(err.to_string().contains("limit"), "{err}");
1486 }
1487
1488 #[tokio::test]
1489 async fn split_statements_rejects_a_blank_statement() {
1490 let ai = ai_client(vec![Ok(split_response_json(&[(" ", None)]))]);
1491 let err = split_statements(&ai, "comment", &[]).await.unwrap_err();
1492 assert!(err.to_string().contains("empty statement"), "{err}");
1493 }
1494
1495 fn opts() -> VerifyOptions {
1498 VerifyOptions {
1499 jev_model: "jev-latest".to_string(),
1500 supported: DEFAULT_SUPPORTED,
1501 reject_below: DEFAULT_REJECT_BELOW,
1502 coverage: DEFAULT_COVERAGE,
1503 max_input_chars: 60_000,
1504 }
1505 }
1506
1507 #[test]
1508 fn rejects_reject_below_greater_than_threshold() {
1509 let mut o = opts();
1510 o.reject_below = 0.9;
1511 o.supported = 0.5;
1512 assert!(validate_verify_options(&o).is_err());
1513 }
1514
1515 #[test]
1516 fn rejects_out_of_range_knobs() {
1517 for f in [
1518 |o: &mut VerifyOptions| o.supported = 1.5,
1519 |o: &mut VerifyOptions| o.reject_below = -0.1,
1520 |o: &mut VerifyOptions| o.coverage = 2.0,
1521 |o: &mut VerifyOptions| o.max_input_chars = 0,
1522 ] {
1523 let mut o = opts();
1524 f(&mut o);
1525 assert!(validate_verify_options(&o).is_err());
1526 }
1527 }
1528
1529 fn fake_gh_no_citations(dir: &Path) -> (PathBuf, std::sync::MutexGuard<'static, ()>) {
1532 let guard = shim_lock();
1533 let issue = serde_json::json!({
1534 "title": "t", "body": "b", "state": "OPEN", "url": "u",
1535 "comments": {"totalCount": 1, "nodes": [
1536 {"databaseId": 1, "author": {"login": "newhoggy"}, "body": "thanks!"}
1537 ]},
1538 "closedByPullRequestsReferences": {"nodes": []}
1539 });
1540 let path = dir.join("fake-gh");
1541 write_exec_script(
1542 &path,
1543 &format!(
1544 "#!/bin/sh\ncat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {issue}}}}}}}\nJSON\n"
1545 ),
1546 );
1547 (path, guard)
1548 }
1549
1550 #[test]
1551 fn fetch_verify_input_short_circuits_when_the_comment_cites_nothing() {
1552 let dir = tempfile::tempdir().unwrap();
1553 let (bin, _shim) = fake_gh_no_citations(dir.path());
1554 let (issue, comment, citations, sources) = retry_on_etxtbsy(|| {
1555 fetch_verify_input(
1556 &bin,
1557 &IssueCache::disabled(),
1558 "rust-works/omni-dev",
1559 &judged(1),
1560 &CommentSelector::Id(1),
1561 )
1562 })
1563 .unwrap();
1564 assert_eq!(issue.number, 1);
1565 assert_eq!(comment.body, "thanks!");
1566 assert!(citations.is_empty());
1567 assert!(sources.is_empty());
1568 }
1569
1570 fn fake_gh_second_round_pr(dir: &Path) -> (PathBuf, std::sync::MutexGuard<'static, ()>) {
1573 let guard = shim_lock();
1574 let issue = serde_json::json!({
1575 "title": "t", "body": "b", "state": "OPEN", "url": "u",
1576 "comments": {"totalCount": 1, "nodes": [
1577 {"databaseId": 1, "author": {"login": "newhoggy"}, "body": "settled by #1614"}
1578 ]},
1579 "closedByPullRequestsReferences": {"nodes": []}
1580 });
1581 let cited_issue = serde_json::json!({
1582 "__typename": "Issue",
1583 "title": "cited", "body": "cited body", "state": "CLOSED", "url": "u2",
1584 "comments": {"totalCount": 0, "nodes": []},
1585 "closedByPullRequestsReferences": {"nodes": [{"number": 1629}]}
1586 });
1587 let closing_pr = serde_json::json!({
1588 "__typename": "PullRequest",
1589 "title": "closer", "body": "pr body", "state": "MERGED", "url": "u3"
1590 });
1591 let path = dir.join("fake-gh");
1592 write_exec_script(
1593 &path,
1594 &format!(
1595 "#!/bin/sh\ncase \"$4\" in\n\
1596 *'number:1614'*) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {cited_issue}}}}}}}\nJSON\n;;\n\
1597 *'number:1629'*) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {closing_pr}}}}}}}\nJSON\n;;\n\
1598 *) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {issue}}}}}}}\nJSON\n;;\n\
1599 esac\n",
1600 ),
1601 );
1602 (path, guard)
1603 }
1604
1605 #[test]
1606 fn fetch_verify_input_fetches_a_second_round_of_closing_prs() {
1607 let dir = tempfile::tempdir().unwrap();
1608 let (bin, _shim) = fake_gh_second_round_pr(dir.path());
1609 let (_issue, _comment, citations, sources) = retry_on_etxtbsy(|| {
1610 fetch_verify_input(
1611 &bin,
1612 &IssueCache::disabled(),
1613 "rust-works/omni-dev",
1614 &judged(1),
1615 &CommentSelector::Latest,
1616 )
1617 })
1618 .unwrap();
1619 assert_eq!(citations.len(), 1);
1620 assert_eq!(sources.len(), 1);
1621 assert_eq!(sources[0].name, "#1614 and PR #1629");
1622 assert_eq!(
1623 sources[0].keys,
1624 vec![
1625 ("rust-works/omni-dev".to_string(), 1614),
1626 ("rust-works/omni-dev".to_string(), 1629)
1627 ]
1628 );
1629 }
1630
1631 fn split_response_json(statements: &[(&str, Option<&str>)]) -> String {
1634 let stmts: Vec<serde_json::Value> = statements
1635 .iter()
1636 .map(|(text, cites)| serde_json::json!({"text": text, "cites": cites}))
1637 .collect();
1638 serde_json::json!({"statements": stmts}).to_string()
1639 }
1640
1641 fn ai_client(responses: Vec<Result<String>>) -> ClaudeClient {
1642 ClaudeClient::new(Box::new(ConfigurableMockAiClient::new(responses)))
1643 }
1644
1645 fn noul_json(value: f64) -> serde_json::Value {
1646 serde_json::json!({"type": "noul", "noul": value})
1647 }
1648
1649 async fn source_and_coverage_server(
1650 source_answers: serde_json::Value,
1651 coverage_noul: f64,
1652 ) -> wiremock::MockServer {
1653 let server = wiremock::MockServer::start().await;
1654 wiremock::Mock::given(wiremock::matchers::method("POST"))
1657 .and(wiremock::matchers::body_string_contains("## SOURCE:"))
1658 .respond_with(
1659 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1660 "model": "jev-1.13.0",
1661 "answers": source_answers,
1662 "usage": {"input_tokens": 10, "output_tokens": 5}
1663 })),
1664 )
1665 .mount(&server)
1666 .await;
1667 wiremock::Mock::given(wiremock::matchers::method("POST"))
1668 .and(wiremock::matchers::body_string_contains("## COMMENT"))
1669 .respond_with(
1670 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1671 "model": "jev-1.13.0",
1672 "answers": {"coverage": noul_json(coverage_noul)},
1673 "usage": {"input_tokens": 3, "output_tokens": 1}
1674 })),
1675 )
1676 .mount(&server)
1677 .await;
1678 server
1679 }
1680
1681 fn issue_and_source() -> (IssueDoc, Comment, Vec<Citation>, Vec<Source>) {
1682 let issue = doc_with_comments(vec![comment(1, "newhoggy", "settled by #1614")]);
1683 let citations = find_citations(
1684 &issue.comments[0].body,
1685 "rust-works/omni-dev",
1686 &judged(1779),
1687 );
1688 let cited = issue_doc(1614, "Cited issue", "Cited body.", vec![], vec![]);
1689 let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1614), cited)]);
1690 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1691 (
1692 issue,
1693 comment(1, "newhoggy", "settled by #1614"),
1694 citations,
1695 sources,
1696 )
1697 }
1698
1699 #[tokio::test]
1700 async fn run_verify_accepts_a_clean_comment() {
1701 let server =
1702 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.95)}), 0.9).await;
1703 let jev = JevClient::new(&server.uri(), "key").unwrap();
1704 let ai = ai_client(vec![Ok(split_response_json(&[(
1705 "PR #1629 exists.",
1706 Some("#1614"),
1707 )]))]);
1708 let (issue, comment, citations, sources) = issue_and_source();
1709
1710 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1711 .await
1712 .unwrap();
1713
1714 assert_eq!(report.verdict, Verdict::Accepted);
1715 assert!(report.reasons.is_empty(), "{:?}", report.reasons);
1716 assert_eq!(report.statements[0].supported, Some(0.95));
1717 assert_eq!(report.models.ai, "mock-model");
1718 assert_eq!(report.usage.jev.input_tokens, 13);
1719 }
1720
1721 #[tokio::test]
1722 async fn run_verify_rejects_when_a_statement_scores_below_reject_below() {
1723 let server =
1724 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.05)}), 0.9).await;
1725 let jev = JevClient::new(&server.uri(), "key").unwrap();
1726 let ai = ai_client(vec![Ok(split_response_json(&[(
1727 "A false claim.",
1728 Some("#1614"),
1729 )]))]);
1730 let (issue, comment, citations, sources) = issue_and_source();
1731
1732 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1733 .await
1734 .unwrap();
1735
1736 assert_eq!(report.verdict, Verdict::Rejected);
1737 assert!(
1738 report.reasons[0].contains("scored 0.05"),
1739 "{:?}",
1740 report.reasons
1741 );
1742 }
1743
1744 #[tokio::test]
1745 async fn run_verify_needs_review_when_a_statement_is_uncertain() {
1746 let server =
1747 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.4)}), 0.9).await;
1748 let jev = JevClient::new(&server.uri(), "key").unwrap();
1749 let ai = ai_client(vec![Ok(split_response_json(&[(
1750 "An uncertain claim.",
1751 Some("#1614"),
1752 )]))]);
1753 let (issue, comment, citations, sources) = issue_and_source();
1754
1755 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1756 .await
1757 .unwrap();
1758
1759 assert_eq!(report.verdict, Verdict::NeedsReview);
1760 }
1761
1762 #[tokio::test]
1763 async fn run_verify_needs_review_when_coverage_is_low() {
1764 let server =
1765 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.95)}), 0.1).await;
1766 let jev = JevClient::new(&server.uri(), "key").unwrap();
1767 let ai = ai_client(vec![Ok(split_response_json(&[(
1768 "A true claim.",
1769 Some("#1614"),
1770 )]))]);
1771 let (issue, comment, citations, sources) = issue_and_source();
1772
1773 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1774 .await
1775 .unwrap();
1776
1777 assert_eq!(report.verdict, Verdict::NeedsReview);
1778 assert!(
1779 report
1780 .reasons
1781 .iter()
1782 .any(|r| r.contains("coverage scored 0.10")),
1783 "{:?}",
1784 report.reasons
1785 );
1786 }
1787
1788 #[tokio::test]
1789 async fn run_verify_needs_review_when_no_citations_resolved() {
1790 let jev = JevClient::new("http://127.0.0.1:1", "key").unwrap();
1791 let ai = ai_client(vec![]);
1792 let issue = doc_with_comments(vec![comment(1, "newhoggy", "no citation here")]);
1793 let comment = comment(1, "newhoggy", "no citation here");
1794
1795 let report = run_verify(&jev, &ai, &issue, &comment, &[], &[], &opts())
1796 .await
1797 .unwrap();
1798
1799 assert_eq!(report.verdict, Verdict::NeedsReview);
1800 assert_eq!(report.reasons, ["the comment cites nothing verifiable"]);
1801 }
1802
1803 #[tokio::test]
1809 async fn run_verify_needs_review_when_only_one_of_two_sources_answers() {
1810 let server = wiremock::MockServer::start().await;
1811 wiremock::Mock::given(wiremock::matchers::method("POST"))
1813 .and(wiremock::matchers::body_string_contains("SOURCE: #1614"))
1814 .respond_with(
1815 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1816 "model": "jev-1.13.0",
1817 "answers": {"s1": noul_json(0.95)},
1818 "usage": {"input_tokens": 1, "output_tokens": 1}
1819 })),
1820 )
1821 .mount(&server)
1822 .await;
1823 wiremock::Mock::given(wiremock::matchers::method("POST"))
1824 .and(wiremock::matchers::body_string_contains("SOURCE: #1237"))
1825 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1826 .mount(&server)
1827 .await;
1828 wiremock::Mock::given(wiremock::matchers::method("POST"))
1829 .and(wiremock::matchers::body_string_contains("## COMMENT"))
1830 .respond_with(
1831 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1832 "model": "jev-1.13.0",
1833 "answers": {"coverage": noul_json(0.9)},
1834 "usage": {"input_tokens": 1, "output_tokens": 1}
1835 })),
1836 )
1837 .mount(&server)
1838 .await;
1839 let jev = JevClient::new(&server.uri(), "key").unwrap();
1840 let ai = ai_client(vec![Ok(split_response_json(&[
1841 ("A claim about #1614.", Some("#1614")),
1842 ("A claim about #1237.", Some("#1237")),
1843 ]))]);
1844
1845 let issue = doc_with_comments(vec![comment(1, "newhoggy", "see #1614 and #1237")]);
1846 let comment = comment(1, "newhoggy", "see #1614 and #1237");
1847 let citations = find_citations(&comment.body, "rust-works/omni-dev", &judged(1779));
1848 let fetched = BTreeMap::from([
1849 (
1850 ("rust-works/omni-dev".to_string(), 1614),
1851 issue_doc(1614, "One", "Body one.", vec![], vec![]),
1852 ),
1853 (
1854 ("rust-works/omni-dev".to_string(), 1237),
1855 issue_doc(1237, "Two", "Body two.", vec![], vec![]),
1856 ),
1857 ]);
1858 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1859 assert_eq!(sources.len(), 2);
1860
1861 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1862 .await
1863 .unwrap();
1864
1865 assert_eq!(report.verdict, Verdict::NeedsReview);
1866 assert_eq!(report.statements[0].supported, Some(0.95));
1867 assert!(report.statements[1].supported.is_none());
1868 assert!(
1869 report.reasons.iter().any(|r| r.contains("has no answer")),
1870 "{:?}",
1871 report.reasons
1872 );
1873 assert!(report.sources.iter().any(|s| s.error.is_some()));
1875 }
1876
1877 #[tokio::test]
1881 async fn run_verify_reports_a_source_with_no_attributed_statements() {
1882 let server = source_and_coverage_server(serde_json::json!({}), 0.9).await;
1883 let jev = JevClient::new(&server.uri(), "key").unwrap();
1884 let ai = ai_client(vec![Ok(split_response_json(&[(
1885 "A decision about this issue.",
1886 None,
1887 )]))]);
1888 let (issue, comment, citations, sources) = issue_and_source();
1889
1890 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1891 .await
1892 .unwrap();
1893
1894 assert_eq!(report.sources.len(), 1);
1895 assert_eq!(report.sources[0].source, "#1614");
1896 assert!(report.sources[0].error.is_none());
1897 }
1898
1899 #[tokio::test]
1903 async fn run_verify_treats_a_self_citation_as_a_judged_issue_statement() {
1904 let server =
1905 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.95)}), 0.9).await;
1906 let jev = JevClient::new(&server.uri(), "key").unwrap();
1907 let ai = ai_client(vec![Ok(split_response_json(&[
1908 ("A claim about #1614.", Some("#1614")),
1909 ("This issue's question is settled.", Some("#1779")),
1910 ]))]);
1911 let (issue, comment, citations, sources) = issue_and_source();
1912
1913 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1914 .await
1915 .unwrap();
1916
1917 assert_eq!(report.verdict, Verdict::Accepted);
1918 assert!(report.statements[1].source.is_none());
1919 assert!(report.statements[1].reason.is_none());
1920 assert!(report.reasons.is_empty(), "{:?}", report.reasons);
1921 }
1922
1923 #[test]
1925 fn report_models_omit_the_models_that_never_answered() {
1926 let value = serde_json::to_value(ReportModels::default()).unwrap();
1927 assert_eq!(value, serde_json::json!({}));
1928 }
1929
1930 #[tokio::test]
1931 async fn run_verify_flags_an_unresolved_citation() {
1932 let server = source_and_coverage_server(serde_json::json!({}), 0.9).await;
1933 let jev = JevClient::new(&server.uri(), "key").unwrap();
1934 let ai = ai_client(vec![Ok(split_response_json(&[(
1937 "Some claim.",
1938 Some("#9999"),
1939 )]))]);
1940 let (issue, comment, citations, sources) = issue_and_source();
1941
1942 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1943 .await
1944 .unwrap();
1945
1946 assert_eq!(report.verdict, Verdict::NeedsReview);
1947 assert!(report.statements[0]
1948 .reason
1949 .as_ref()
1950 .unwrap()
1951 .contains("could not be resolved"));
1952 }
1953
1954 #[tokio::test]
1955 async fn run_verify_stops_on_an_auth_failure() {
1956 let server = wiremock::MockServer::start().await;
1957 wiremock::Mock::given(wiremock::matchers::method("POST"))
1958 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("bad key"))
1959 .mount(&server)
1960 .await;
1961 let jev = JevClient::new(&server.uri(), "key").unwrap();
1962 let ai = ai_client(vec![Ok(split_response_json(&[(
1963 "A claim.",
1964 Some("#1614"),
1965 )]))]);
1966 let (issue, comment, citations, sources) = issue_and_source();
1967
1968 let err = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1969 .await
1970 .unwrap_err();
1971 assert!(format!("{err:#}").contains("Failed to verify against source #1614"));
1972 }
1973
1974 #[tokio::test]
1977 async fn run_verify_stops_on_a_coverage_auth_failure() {
1978 let server = wiremock::MockServer::start().await;
1979 wiremock::Mock::given(wiremock::matchers::method("POST"))
1980 .and(wiremock::matchers::body_string_contains("## SOURCE:"))
1981 .respond_with(
1982 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1983 "model": "jev-1.13.0",
1984 "answers": {"s1": noul_json(0.95)},
1985 "usage": {"input_tokens": 1, "output_tokens": 1}
1986 })),
1987 )
1988 .mount(&server)
1989 .await;
1990 wiremock::Mock::given(wiremock::matchers::method("POST"))
1991 .and(wiremock::matchers::body_string_contains("## COMMENT"))
1992 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("bad key"))
1993 .mount(&server)
1994 .await;
1995 let jev = JevClient::new(&server.uri(), "key").unwrap();
1996 let ai = ai_client(vec![Ok(split_response_json(&[(
1997 "A claim.",
1998 Some("#1614"),
1999 )]))]);
2000 let (issue, comment, citations, sources) = issue_and_source();
2001
2002 let err = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
2003 .await
2004 .unwrap_err();
2005 assert!(
2006 format!("{err:#}").contains("Failed to check statement coverage"),
2007 "{err:#}"
2008 );
2009 }
2010
2011 #[tokio::test]
2014 async fn run_verify_needs_review_when_the_coverage_call_fails() {
2015 let server = wiremock::MockServer::start().await;
2016 wiremock::Mock::given(wiremock::matchers::method("POST"))
2017 .and(wiremock::matchers::body_string_contains("## SOURCE:"))
2018 .respond_with(
2019 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2020 "model": "jev-1.13.0",
2021 "answers": {"s1": noul_json(0.95)},
2022 "usage": {"input_tokens": 1, "output_tokens": 1}
2023 })),
2024 )
2025 .mount(&server)
2026 .await;
2027 wiremock::Mock::given(wiremock::matchers::method("POST"))
2028 .and(wiremock::matchers::body_string_contains("## COMMENT"))
2029 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2030 .mount(&server)
2031 .await;
2032 let jev = JevClient::new(&server.uri(), "key").unwrap();
2033 let ai = ai_client(vec![Ok(split_response_json(&[(
2034 "A claim.",
2035 Some("#1614"),
2036 )]))]);
2037 let (issue, comment, citations, sources) = issue_and_source();
2038
2039 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
2040 .await
2041 .unwrap();
2042 assert_eq!(report.verdict, Verdict::NeedsReview);
2043 assert!(report.coverage.is_none());
2044 assert!(
2045 report
2046 .reasons
2047 .iter()
2048 .any(|r| r.contains("coverage unavailable")),
2049 "{:?}",
2050 report.reasons
2051 );
2052 }
2053
2054 #[tokio::test]
2057 async fn run_verify_needs_review_when_the_coverage_answer_is_missing() {
2058 let server = wiremock::MockServer::start().await;
2059 wiremock::Mock::given(wiremock::matchers::method("POST"))
2060 .and(wiremock::matchers::body_string_contains("## SOURCE:"))
2061 .respond_with(
2062 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2063 "model": "jev-1.13.0",
2064 "answers": {"s1": noul_json(0.95)},
2065 "usage": {"input_tokens": 1, "output_tokens": 1}
2066 })),
2067 )
2068 .mount(&server)
2069 .await;
2070 wiremock::Mock::given(wiremock::matchers::method("POST"))
2071 .and(wiremock::matchers::body_string_contains("## COMMENT"))
2072 .respond_with(
2073 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2074 "model": "jev-1.13.0",
2075 "answers": {},
2076 "usage": {"input_tokens": 1, "output_tokens": 1}
2077 })),
2078 )
2079 .mount(&server)
2080 .await;
2081 let jev = JevClient::new(&server.uri(), "key").unwrap();
2082 let ai = ai_client(vec![Ok(split_response_json(&[(
2083 "A claim.",
2084 Some("#1614"),
2085 )]))]);
2086 let (issue, comment, citations, sources) = issue_and_source();
2087
2088 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
2089 .await
2090 .unwrap();
2091 assert_eq!(report.verdict, Verdict::NeedsReview);
2092 assert!(report.coverage.is_none());
2093 assert!(
2094 report
2095 .reasons
2096 .iter()
2097 .any(|r| r.contains("no `coverage` answer")),
2098 "{:?}",
2099 report.reasons
2100 );
2101 }
2102}