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, fetch_items};
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 default_project: &str,
340 judged: &ItemRef,
341 selector: &CommentSelector,
342) -> Result<(IssueDoc, Comment, Vec<Citation>, Vec<Source>)> {
343 let issue = fetch_issues(bin, std::slice::from_ref(judged))?
344 .into_iter()
345 .next()
346 .ok_or_else(|| anyhow!("issue {judged} missing from the parsed gh reply (bug)"))?;
347 let comment = select_comment(&issue, selector, default_project)?.clone();
348 let citations = find_citations(&comment.body, default_project, judged);
349 if citations.is_empty() {
350 return Ok((issue, comment, citations, Vec::new()));
351 }
352
353 let mut seen = HashSet::new();
354 let refs: Vec<ItemRef> = citations
355 .iter()
356 .map(|c| c.item_ref.clone())
357 .filter(|r| seen.insert((r.project.clone(), r.number)))
358 .collect();
359 let mut fetched: BTreeMap<(String, u64), IssueDoc> = BTreeMap::new();
360 for (item_ref, doc) in refs.iter().zip(fetch_items(bin, &refs)?) {
361 if let Some(doc) = doc {
362 fetched.insert((item_ref.project.clone(), item_ref.number), doc);
363 }
364 }
365
366 let mut seen_pr = HashSet::new();
367 let pr_refs: Vec<ItemRef> = fetched
368 .values()
369 .filter(|doc| doc.kind == ItemKind::Issue)
370 .flat_map(|doc| doc.closed_by.iter())
371 .filter(|pr| !fetched.contains_key(&(pr.project.clone(), pr.number)))
372 .filter(|pr| seen_pr.insert((pr.project.clone(), pr.number)))
373 .cloned()
374 .collect();
375 if !pr_refs.is_empty() {
376 for (item_ref, doc) in pr_refs.iter().zip(fetch_items(bin, &pr_refs)?) {
377 if let Some(doc) = doc {
378 fetched.insert((item_ref.project.clone(), item_ref.number), doc);
379 }
380 }
381 }
382
383 let sources = group_sources(&citations, &issue.project, &fetched);
384 Ok((issue, comment, citations, sources))
385}
386
387#[derive(Debug, Clone, serde::Deserialize)]
392struct SplitStatement {
393 text: String,
394 cites: Option<String>,
395}
396
397#[derive(Debug, Clone, serde::Deserialize)]
398struct SplitResponse {
399 statements: Vec<SplitStatement>,
400}
401
402const SPLIT_SYSTEM_PROMPT: &str =
406 "You split a maintainer's decision comment into single checkable \
407 factual statements. Reply with the requested object and nothing else.";
408
409fn build_split_user_prompt(comment_body: &str, citations: &[Citation]) -> String {
430 let cited = if citations.is_empty() {
431 "(none)".to_string()
432 } else {
433 citations
434 .iter()
435 .map(|c| c.raw.as_str())
436 .collect::<Vec<_>>()
437 .join(", ")
438 };
439 format!(
440 "Split the COMMENT below into single factual statements, so that each statement can be checked\n\
441 on its own against the item it cites.\n\n\
442 Rules:\n\
443 1. One fact per statement. Split \"A and B\" into two statements.\n\
444 2. Make every statement self-contained: name the item it is about (for example \"PR #1629 added\n\
445 ...\"), replace pronouns with what they refer to, and do not depend on other statements.\n\
446 3. Keep certainty exactly as written. \"was decided\", \"was considered\", \"is deferred\", \"is tracked\n\
447 separately\" and \"may happen later\" are different facts. Never make a claim stronger or weaker.\n\
448 4. Do not add, infer, summarise or correct anything. If the comment says something wrong, keep\n\
449 the wrong claim as written.\n\
450 5. Include every factual claim about a cited item, including conclusions such as \"so the open\n\
451 question is settled\"; restate exactly what the comment says was settled.\n\
452 6. Set \"cites\" to the one item the statement is about, written as it appears in the comment\n\
453 (for example \"#1614\" or \"PR #1629\"). If the statement is a decision about the judged issue\n\
454 itself rather than a claim about a cited item, set \"cites\" to null.\n\
455 A statement that a cited item resolved, settled or answered the judged issue's question is\n\
456 about the judged issue, so its \"cites\" is null. A statement about what a cited item did or\n\
457 decided cites that item, even when it also mentions another item (such as a tracking issue).\n\
458 7. Leave out greetings, opinions and instructions that make no factual claim.\n\n\
459 The COMMENT was posted on the judged issue, which the comment calls \"this issue\". Refer to it\n\
460 the same way; do not add its number.\n\n\
461 COMMENT:\n{comment}\n\n\
462 Items the comment cites: {cited}\n\n\
463 Reply with an object whose \"statements\" field is a list; each entry has a \"text\" string and\n\
464 a \"cites\" string or null. For example:\n\
465 {{\"statements\": [{{\"text\": \"PR #1629 added a tool.\", \"cites\": \"PR #1629\"}}]}}",
466 comment = comment_body.trim(),
467 )
468}
469
470fn strip_code_fence(s: &str) -> &str {
474 let s = s
475 .strip_prefix("```json")
476 .or_else(|| s.strip_prefix("```yaml"))
477 .or_else(|| s.strip_prefix("```"))
478 .map_or(s, str::trim_start);
479 s.strip_suffix("```").map_or(s, str::trim_end)
480}
481
482fn parse_split_response(raw: &str) -> Result<SplitResponse> {
483 let trimmed = raw.trim();
484 if let Ok(parsed) = serde_json::from_str(trimmed) {
485 return Ok(parsed);
486 }
487 serde_yaml::from_str(strip_code_fence(trimmed))
488 .with_context(|| format!("Failed to parse the statement splitter's response: {trimmed}"))
489}
490
491async fn split_statements(
495 ai: &ClaudeClient,
496 comment_body: &str,
497 citations: &[Citation],
498) -> Result<Vec<SplitStatement>> {
499 let user_prompt = build_split_user_prompt(comment_body, citations);
500 let raw = ai
501 .send_message(SPLIT_SYSTEM_PROMPT, &user_prompt)
502 .await
503 .context("Failed to split the decision comment into statements")?;
504 let parsed = parse_split_response(&raw)?;
505 if parsed.statements.is_empty() {
506 bail!("the statement splitter returned no statements");
507 }
508 if parsed.statements.len() > MAX_STATEMENTS {
509 bail!(
510 "the statement splitter returned {} statements, more than the {MAX_STATEMENTS} limit",
511 parsed.statements.len()
512 );
513 }
514 if parsed.statements.iter().any(|s| s.text.trim().is_empty()) {
515 bail!("the statement splitter returned an empty statement");
516 }
517 Ok(parsed.statements)
518}
519
520fn statement_question(text: &str) -> Question {
525 Question::Noul {
526 instructions: format!(
527 "Is the following single STATEMENT supported by the SOURCE text? Answer true only if the source \
528 states it or directly shows it. Answer false if the source contradicts it, if the source leaves \
529 it open, optional, deferred or merely possible, or if the source does not mention it. STATEMENT: {text}"
530 ),
531 criteria: Some(NoulCriteria {
532 true_means: Some("The source supports the statement".to_string()),
533 false_means: Some("The source contradicts it, leaves it open, or does not mention it".to_string()),
534 }),
535 }
536}
537
538fn coverage_question() -> Question {
542 Question::Noul {
543 instructions: "Do the numbered STATEMENTS together say everything the COMMENT claims, with nothing \
544 added, strengthened or weakened? Answer false if any claim in the comment is missing, if any \
545 statement adds something the comment does not say, or if any statement changes how certain or \
546 final a claim is (for example \"considered\" becoming \"decided\")."
547 .to_string(),
548 criteria: Some(NoulCriteria {
549 true_means: Some("The statements faithfully cover the comment".to_string()),
550 false_means: Some("A claim is missing, added, or changed in certainty".to_string()),
551 }),
552 }
553}
554
555fn build_coverage_state(comment_body: &str, statements: &[SplitStatement]) -> String {
556 let mut s = format!("## COMMENT\n\n{}\n\n## STATEMENTS\n\n", comment_body.trim());
557 for (i, st) in statements.iter().enumerate() {
558 s.push_str(&format!("{}. {}\n", i + 1, st.text));
559 }
560 s
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
567#[serde(rename_all = "snake_case")]
568pub enum Verdict {
569 Accepted,
572 Rejected,
574 NeedsReview,
578}
579
580#[derive(Debug, Clone, Serialize)]
582pub struct CommentRef {
583 pub id: Option<u64>,
585 pub author: Option<String>,
587}
588
589#[derive(Debug, Clone, Serialize)]
591pub struct VerifiedStatement {
592 pub text: String,
594 #[serde(skip_serializing_if = "Option::is_none")]
598 pub source: Option<String>,
599 #[serde(skip_serializing_if = "Option::is_none")]
603 pub supported: Option<f64>,
604 #[serde(skip_serializing_if = "Option::is_none")]
606 pub reason: Option<String>,
607}
608
609#[derive(Debug, Clone, Serialize)]
611pub struct SourceReport {
612 pub source: String,
614 pub items: Vec<String>,
616 #[serde(skip_serializing_if = "std::ops::Not::not")]
618 pub truncated: bool,
619 #[serde(skip_serializing_if = "Option::is_none")]
621 pub error: Option<String>,
622}
623
624#[derive(Debug, Clone, Default, Serialize)]
632pub struct ReportModels {
633 #[serde(skip_serializing_if = "String::is_empty")]
635 pub jev: String,
636 #[serde(skip_serializing_if = "String::is_empty")]
638 pub ai: String,
639}
640
641#[derive(Debug, Clone, Default, Serialize)]
643pub struct ReportUsage {
644 pub jev: Usage,
646}
647
648#[derive(Debug, Clone, Serialize)]
650pub struct VerifyReport {
651 #[serde(rename = "issue")]
653 pub item_ref: String,
654 pub url: String,
656 pub comment: CommentRef,
658 pub verdict: Verdict,
660 #[serde(skip_serializing_if = "Vec::is_empty")]
662 pub reasons: Vec<String>,
663 #[serde(skip_serializing_if = "Option::is_none")]
665 pub coverage: Option<f64>,
666 pub sources: Vec<SourceReport>,
668 pub statements: Vec<VerifiedStatement>,
670 pub models: ReportModels,
672 pub usage: ReportUsage,
674}
675
676#[derive(Debug, Clone)]
678pub struct VerifyOptions {
679 pub jev_model: String,
681 pub supported: f64,
683 pub reject_below: f64,
685 pub coverage: f64,
687 pub max_input_chars: usize,
689}
690
691fn validate_verify_options(opts: &VerifyOptions) -> Result<()> {
692 if !(0.0..=1.0).contains(&opts.reject_below) || !(0.0..=1.0).contains(&opts.supported) {
693 bail!("--threshold and --reject-below must each be between 0 and 1");
694 }
695 if opts.reject_below > opts.supported {
696 bail!("--reject-below must not be greater than --threshold");
697 }
698 if !(0.0..=1.0).contains(&opts.coverage) {
699 bail!("--coverage-threshold must be between 0 and 1");
700 }
701 if opts.max_input_chars == 0 {
702 bail!("the input cap must be at least 1 character");
703 }
704 Ok(())
705}
706
707pub async fn run_verify(
722 jev: &JevClient,
723 ai: &ClaudeClient,
724 issue: &IssueDoc,
725 comment: &Comment,
726 citations: &[Citation],
727 sources: &[Source],
728 opts: &VerifyOptions,
729) -> Result<VerifyReport> {
730 validate_verify_options(opts)?;
731
732 let item_ref = format!("{}#{}", issue.project, issue.number);
733 let comment_ref = CommentRef {
734 id: comment.id,
735 author: Some(comment.author.clone()),
736 };
737
738 if citations.is_empty() || sources.is_empty() {
739 return Ok(VerifyReport {
740 item_ref,
741 url: issue.url.clone(),
742 comment: comment_ref,
743 verdict: Verdict::NeedsReview,
744 reasons: vec!["the comment cites nothing verifiable".to_string()],
745 coverage: None,
746 sources: Vec::new(),
747 statements: Vec::new(),
748 models: ReportModels::default(),
749 usage: ReportUsage::default(),
750 });
751 }
752
753 let split = split_statements(ai, &comment.body, citations).await?;
754 let ai_model = ai.get_ai_client_metadata().model;
755
756 let source_of: Vec<Option<usize>> = split
758 .iter()
759 .map(|s| {
760 s.cites
761 .as_deref()
762 .and_then(|c| resolve_cites_index(c, &issue.project, sources))
763 })
764 .collect();
765 let mut by_source: Vec<Vec<usize>> = vec![Vec::new(); sources.len()];
766 for (i, src) in source_of.iter().enumerate() {
767 if let Some(idx) = src {
768 by_source[*idx].push(i);
769 }
770 }
771
772 let mut jev_model = String::new();
773 let mut usage = Usage::default();
774 let mut supported: BTreeMap<usize, f64> = BTreeMap::new();
775 let mut source_reports = Vec::with_capacity(sources.len());
776
777 for (idx, source) in sources.iter().enumerate() {
778 let indices = &by_source[idx];
779 let (state, truncated) = truncate_middle(&source.text, opts.max_input_chars);
780 if indices.is_empty() {
781 source_reports.push(SourceReport {
785 source: source.name.clone(),
786 items: source.items.clone(),
787 truncated,
788 error: None,
789 });
790 continue;
791 }
792 let questions = indices
793 .iter()
794 .map(|&i| (format!("s{}", i + 1), statement_question(&split[i].text)))
795 .collect();
796 let request = SystemOneRequest {
797 state: serde_json::Value::String(state),
798 model: opts.jev_model.clone(),
799 questions,
800 };
801 match jev.system_one(&request).await {
802 Err(err) if is_auth_failure(&err) => {
803 return Err(err)
804 .with_context(|| format!("Failed to verify against source {}", source.label));
805 }
806 Err(err) => {
807 let error = format!("{err:#}");
808 warn!("Could not verify against source {}: {error}", source.label);
809 source_reports.push(SourceReport {
810 source: source.name.clone(),
811 items: source.items.clone(),
812 truncated,
813 error: Some(error),
814 });
815 }
816 Ok(response) => {
817 jev_model = response.model;
818 usage.input_tokens += response.usage.input_tokens;
819 usage.output_tokens += response.usage.output_tokens;
820 for &i in indices {
821 if let Some(Answer::Noul { noul }) =
822 response.answers.get(&format!("s{}", i + 1))
823 {
824 supported.insert(i, *noul);
825 }
826 }
827 source_reports.push(SourceReport {
828 source: source.name.clone(),
829 items: source.items.clone(),
830 truncated,
831 error: None,
832 });
833 }
834 }
835 }
836
837 let mut coverage = None;
838 let mut coverage_reason = None;
839 {
840 let state = build_coverage_state(&comment.body, &split);
841 let request = SystemOneRequest {
842 state: serde_json::Value::String(state),
843 model: opts.jev_model.clone(),
844 questions: BTreeMap::from([("coverage".to_string(), coverage_question())]),
845 };
846 match jev.system_one(&request).await {
847 Err(err) if is_auth_failure(&err) => {
848 return Err(err).context("Failed to check statement coverage");
849 }
850 Err(err) => {
851 let msg = format!("{err:#}");
852 warn!("Could not check statement coverage: {msg}");
853 coverage_reason = Some(format!("coverage unavailable: {msg}"));
854 }
855 Ok(response) => {
856 jev_model = response.model;
857 usage.input_tokens += response.usage.input_tokens;
858 usage.output_tokens += response.usage.output_tokens;
859 if let Some(Answer::Noul { noul }) = response.answers.get("coverage") {
860 coverage = Some(*noul);
861 } else {
862 warn!("The coverage call returned no usable `coverage` answer");
867 coverage_reason = Some(
868 "coverage unavailable: the reply carried no `coverage` answer".to_string(),
869 );
870 }
871 }
872 }
873 }
874
875 let mut statements = Vec::with_capacity(split.len());
876 let mut reasons = Vec::new();
877 let mut any_rejected = false;
878 let mut any_review = false;
879
880 for (i, statement) in split.iter().enumerate() {
881 let (source_label, score, reason) = match source_of[i] {
882 Some(idx) => {
883 let src = &sources[idx];
884 let score = supported.get(&i).copied();
885 let reason = match score {
886 None => {
887 any_review = true;
892 Some(format!(
893 "statement {} has no answer against {}",
894 i + 1,
895 src.label
896 ))
897 }
898 Some(s) if s < opts.reject_below => {
899 any_rejected = true;
900 Some(format!(
901 "statement {} scored {s:.2} (< {:.2}) against {}",
902 i + 1,
903 opts.reject_below,
904 src.label
905 ))
906 }
907 Some(s) if s < opts.supported => {
908 any_review = true;
909 Some(format!(
910 "statement {} scored {s:.2} (uncertain) against {}",
911 i + 1,
912 src.label
913 ))
914 }
915 Some(_) => None,
916 };
917 (Some(src.label.clone()), score, reason)
918 }
919 None => match &statement.cites {
920 None => (None, None, None),
921 Some(raw) if cites_judged_issue(raw, issue) => (None, None, None),
924 Some(raw) => {
925 any_review = true;
926 (
927 Some(raw.clone()),
928 None,
929 Some(format!(
930 "statement {} cites {raw:?}, which could not be resolved",
931 i + 1
932 )),
933 )
934 }
935 },
936 };
937 if let Some(msg) = &reason {
938 reasons.push(msg.clone());
939 }
940 statements.push(VerifiedStatement {
941 text: statement.text.clone(),
942 source: source_label,
943 supported: score,
944 reason,
945 });
946 }
947
948 if statements.iter().all(|s| s.supported.is_none()) {
949 any_review = true;
950 reasons.push("no statement could be checked against a resolved source".to_string());
951 }
952
953 match (coverage, &coverage_reason) {
954 (Some(cov), _) if cov < opts.coverage => {
955 any_review = true;
956 reasons.push(format!("coverage scored {cov:.2} (< {:.2})", opts.coverage));
957 }
958 (None, Some(msg)) => {
959 any_review = true;
960 reasons.push(msg.clone());
961 }
962 _ => {}
963 }
964
965 let verdict = if any_rejected {
966 Verdict::Rejected
967 } else if any_review {
968 Verdict::NeedsReview
969 } else {
970 Verdict::Accepted
971 };
972
973 Ok(VerifyReport {
974 item_ref,
975 url: issue.url.clone(),
976 comment: comment_ref,
977 verdict,
978 reasons,
979 coverage,
980 sources: source_reports,
981 statements,
982 models: ReportModels {
983 jev: jev_model,
984 ai: ai_model,
985 },
986 usage: ReportUsage { jev: usage },
987 })
988}
989
990#[cfg(test)]
991#[allow(clippy::unwrap_used, clippy::expect_used)]
992mod tests {
993 use super::*;
994 use crate::claude::test_utils::ConfigurableMockAiClient;
995 use crate::provider::{GitProvider, ItemState};
996 use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
997 use std::path::PathBuf;
998
999 fn judged(number: u64) -> ItemRef {
1000 ItemRef {
1001 provider: GitProvider::GitHub,
1002 project: "rust-works/omni-dev".to_string(),
1003 kind: ItemKind::Issue,
1004 number,
1005 }
1006 }
1007
1008 #[test]
1011 fn resolve_cites_index_matches_a_sources_keys() {
1012 let citations = find_citations("see #1614", "rust-works/omni-dev", &judged(1));
1013 let issue = issue_doc(1614, "Title", "Body.", vec![], vec![]);
1014 let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1614), issue)]);
1015 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1016 assert_eq!(
1017 resolve_cites_index("#1614", "rust-works/omni-dev", &sources),
1018 Some(0)
1019 );
1020 }
1021
1022 #[test]
1025 fn resolve_cites_index_is_none_for_an_unparseable_cites_string() {
1026 let sources = Vec::new();
1027 assert_eq!(
1028 resolve_cites_index("#1-overview", "rust-works/omni-dev", &sources),
1029 None
1030 );
1031 }
1032
1033 #[test]
1034 fn cites_judged_issue_is_true_only_for_the_judged_number() {
1035 let issue = doc_with_comments(vec![]);
1036 assert!(cites_judged_issue("#1779", &issue));
1037 assert!(!cites_judged_issue("#1614", &issue));
1038 assert!(!cites_judged_issue("#1-overview", &issue));
1039 }
1040
1041 #[test]
1044 fn parses_latest_case_insensitively() {
1045 assert_eq!(
1046 parse_comment_selector("Latest").unwrap(),
1047 CommentSelector::Latest
1048 );
1049 }
1050
1051 #[test]
1052 fn parses_a_numeric_id() {
1053 assert_eq!(
1054 parse_comment_selector("123").unwrap(),
1055 CommentSelector::Id(123)
1056 );
1057 }
1058
1059 #[test]
1060 fn parses_an_issue_comment_url() {
1061 let sel = parse_comment_selector(
1062 "https://github.com/rust-works/omni-dev/issues/1779#issuecomment-5742808381",
1063 )
1064 .unwrap();
1065 assert_eq!(sel, CommentSelector::Id(5_742_808_381));
1066 }
1067
1068 #[test]
1069 fn rejects_garbage_selector() {
1070 assert!(parse_comment_selector("whenever").is_err());
1071 }
1072
1073 #[test]
1076 fn rejects_an_issue_comment_url_with_a_non_numeric_id() {
1077 assert!(parse_comment_selector(
1078 "https://github.com/rust-works/omni-dev/issues/1779#issuecomment-abc"
1079 )
1080 .is_err());
1081 }
1082
1083 fn doc_with_comments(comments: Vec<Comment>) -> IssueDoc {
1084 IssueDoc {
1085 provider: GitProvider::GitHub,
1086 project: "rust-works/omni-dev".to_string(),
1087 number: 1779,
1088 kind: ItemKind::Issue,
1089 title: "t".to_string(),
1090 state: ItemState::Open,
1091 body: "b".to_string(),
1092 comments,
1093 closed_by: vec![],
1094 url: "https://github.com/rust-works/omni-dev/issues/1779".to_string(),
1095 }
1096 }
1097
1098 fn comment(id: u64, author: &str, body: &str) -> Comment {
1099 Comment {
1100 author: author.to_string(),
1101 body: body.to_string(),
1102 id: Some(id),
1103 }
1104 }
1105
1106 #[test]
1107 fn select_comment_latest_picks_the_last_comment_that_cites_something() {
1108 let doc = doc_with_comments(vec![
1109 comment(1, "alice", "no citation here"),
1110 comment(2, "bob", "settled by #1614"),
1111 comment(3, "carol", "thanks!"),
1112 ]);
1113 let selected =
1114 select_comment(&doc, &CommentSelector::Latest, "rust-works/omni-dev").unwrap();
1115 assert_eq!(selected.id, Some(2));
1116 }
1117
1118 #[test]
1119 fn select_comment_latest_errors_when_none_cite_anything() {
1120 let doc = doc_with_comments(vec![comment(1, "alice", "thanks!")]);
1121 let err =
1122 select_comment(&doc, &CommentSelector::Latest, "rust-works/omni-dev").unwrap_err();
1123 assert!(err.to_string().contains("--comment ID"), "{err}");
1124 }
1125
1126 #[test]
1127 fn select_comment_by_id_errors_when_missing() {
1128 let doc = doc_with_comments(vec![comment(1, "alice", "#1614")]);
1129 let err =
1130 select_comment(&doc, &CommentSelector::Id(999), "rust-works/omni-dev").unwrap_err();
1131 assert!(err.to_string().contains("999"), "{err}");
1132 }
1133
1134 fn issue_doc(
1137 number: u64,
1138 title: &str,
1139 body: &str,
1140 comments: Vec<Comment>,
1141 closed_by: Vec<ItemRef>,
1142 ) -> IssueDoc {
1143 IssueDoc {
1144 provider: GitProvider::GitHub,
1145 project: "rust-works/omni-dev".to_string(),
1146 number,
1147 kind: ItemKind::Issue,
1148 title: title.to_string(),
1149 state: ItemState::Closed,
1150 body: body.to_string(),
1151 comments,
1152 closed_by,
1153 url: format!("https://github.com/rust-works/omni-dev/issues/{number}"),
1154 }
1155 }
1156
1157 fn pr_doc(number: u64, title: &str, body: &str) -> IssueDoc {
1158 IssueDoc {
1159 provider: GitProvider::GitHub,
1160 project: "rust-works/omni-dev".to_string(),
1161 number,
1162 kind: ItemKind::ChangeRequest,
1163 title: title.to_string(),
1164 state: ItemState::Closed,
1165 body: body.to_string(),
1166 comments: vec![],
1167 closed_by: vec![],
1168 url: format!("https://github.com/rust-works/omni-dev/pull/{number}"),
1169 }
1170 }
1171
1172 fn pr_ref(number: u64) -> ItemRef {
1173 ItemRef {
1174 provider: GitProvider::GitHub,
1175 project: "rust-works/omni-dev".to_string(),
1176 kind: ItemKind::ChangeRequest,
1177 number,
1178 }
1179 }
1180
1181 #[test]
1183 fn group_sources_merges_a_cited_issue_with_its_closing_pr() {
1184 let citations = find_citations("settled by #1614", "rust-works/omni-dev", &judged(1779));
1185 let issue = issue_doc(
1186 1614,
1187 "Sheets MCP tools",
1188 "Body text.",
1189 vec![comment(1, "newhoggy", "A human comment.")],
1190 vec![pr_ref(1629)],
1191 );
1192 let pr = pr_doc(1629, "Add drive_sheets_info", "PR body text.");
1193 let fetched = BTreeMap::from([
1194 (("rust-works/omni-dev".to_string(), 1614), issue),
1195 (("rust-works/omni-dev".to_string(), 1629), pr),
1196 ]);
1197 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1198 assert_eq!(sources.len(), 1);
1199 assert_eq!(sources[0].label, "#1614");
1200 assert_eq!(sources[0].name, "#1614 and PR #1629");
1201 assert_eq!(
1202 sources[0].keys,
1203 vec![
1204 ("rust-works/omni-dev".to_string(), 1614),
1205 ("rust-works/omni-dev".to_string(), 1629)
1206 ]
1207 );
1208 assert_eq!(
1209 sources[0].text,
1210 "## SOURCE: #1614 and PR #1629\n\n\
1211 # Issue #1614: Sheets MCP tools\n\n\
1212 Body text.\n\
1213 \n**Comment by newhoggy:**\n\nA human comment.\n\
1214 \n\n# Pull request #1629: Add drive_sheets_info\n\n\
1215 PR body text.\n"
1216 );
1217 }
1218
1219 #[test]
1222 fn group_sources_uses_a_qualified_label_for_a_cross_repo_closing_pr() {
1223 let citations = find_citations("settled by #1614", "rust-works/omni-dev", &judged(1779));
1224 let issue = issue_doc(
1225 1614,
1226 "Title",
1227 "Body.",
1228 vec![],
1229 vec![ItemRef {
1230 provider: GitProvider::GitHub,
1231 project: "other/repo".to_string(),
1232 kind: ItemKind::ChangeRequest,
1233 number: 42,
1234 }],
1235 );
1236 let mut pr = pr_doc(42, "Fix", "PR body.");
1237 pr.project = "other/repo".to_string();
1238 let fetched = BTreeMap::from([
1239 (("rust-works/omni-dev".to_string(), 1614), issue),
1240 (("other/repo".to_string(), 42), pr),
1241 ]);
1242 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1243 assert_eq!(sources.len(), 1);
1244 assert_eq!(sources[0].name, "#1614 and PR other/repo#42");
1245 assert!(sources[0]
1246 .text
1247 .contains("# Pull request other/repo#42: Fix"));
1248 }
1249
1250 #[test]
1253 fn group_sources_keeps_a_bare_label_when_there_is_no_closing_pr() {
1254 let citations = find_citations("see #1237", "rust-works/omni-dev", &judged(1));
1255 let issue = issue_doc(1237, "Title", "Body.", vec![], vec![]);
1256 let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1237), issue)]);
1257 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1258 assert_eq!(sources[0].name, "#1237");
1259 }
1260
1261 #[test]
1262 fn group_sources_makes_a_standalone_source_for_a_pr_not_closing_any_cited_issue() {
1263 let citations = find_citations("see PR #999", "rust-works/omni-dev", &judged(1));
1264 let pr = pr_doc(999, "Some PR", "PR body.");
1265 let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 999), pr)]);
1266 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1267 assert_eq!(sources.len(), 1);
1268 assert_eq!(sources[0].label, "PR #999");
1269 assert_eq!(
1270 sources[0].text,
1271 "## SOURCE: PR #999\n\n# Pull request #999: Some PR\n\nPR body.\n"
1272 );
1273 }
1274
1275 #[test]
1276 fn group_sources_uses_a_qualified_label_across_repos() {
1277 let citations = find_citations("see other/repo#5", "rust-works/omni-dev", &judged(1));
1278 let mut issue = issue_doc(5, "Cross repo", "Body.", vec![], vec![]);
1279 issue.project = "other/repo".to_string();
1280 let fetched = BTreeMap::from([(("other/repo".to_string(), 5), issue)]);
1281 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1282 assert_eq!(sources[0].label, "other/repo#5");
1283 }
1284
1285 #[test]
1286 fn group_sources_skips_a_citation_that_never_resolved() {
1287 let citations = find_citations("see #404", "rust-works/omni-dev", &judged(1));
1288 let sources = group_sources(&citations, "rust-works/omni-dev", &BTreeMap::new());
1289 assert!(sources.is_empty());
1290 }
1291
1292 #[test]
1295 fn statement_question_reproduces_the_tested_wording() {
1296 let Question::Noul {
1297 instructions,
1298 criteria,
1299 } = statement_question("PR #1629 added a tool.")
1300 else {
1301 panic!("expected a noul question");
1302 };
1303 assert_eq!(
1304 instructions,
1305 "Is the following single STATEMENT supported by the SOURCE text? Answer true only if the source \
1306 states it or directly shows it. Answer false if the source contradicts it, if the source leaves \
1307 it open, optional, deferred or merely possible, or if the source does not mention it. STATEMENT: \
1308 PR #1629 added a tool."
1309 );
1310 let criteria = criteria.unwrap();
1311 assert_eq!(
1312 criteria.true_means.unwrap(),
1313 "The source supports the statement"
1314 );
1315 assert_eq!(
1316 criteria.false_means.unwrap(),
1317 "The source contradicts it, leaves it open, or does not mention it"
1318 );
1319 }
1320
1321 #[test]
1322 fn coverage_question_reproduces_the_tested_wording() {
1323 let Question::Noul {
1324 instructions,
1325 criteria,
1326 } = coverage_question()
1327 else {
1328 panic!("expected a noul question");
1329 };
1330 assert_eq!(
1331 instructions,
1332 "Do the numbered STATEMENTS together say everything the COMMENT claims, with nothing \
1333 added, strengthened or weakened? Answer false if any claim in the comment is missing, if any \
1334 statement adds something the comment does not say, or if any statement changes how certain or \
1335 final a claim is (for example \"considered\" becoming \"decided\")."
1336 );
1337 let criteria = criteria.unwrap();
1338 assert_eq!(
1339 criteria.true_means.unwrap(),
1340 "The statements faithfully cover the comment"
1341 );
1342 assert_eq!(
1343 criteria.false_means.unwrap(),
1344 "A claim is missing, added, or changed in certainty"
1345 );
1346 }
1347
1348 #[test]
1349 fn build_coverage_state_numbers_statements_from_one() {
1350 let statements = vec![
1351 SplitStatement {
1352 text: "First fact.".to_string(),
1353 cites: None,
1354 },
1355 SplitStatement {
1356 text: "Second fact.".to_string(),
1357 cites: None,
1358 },
1359 ];
1360 let state = build_coverage_state("The comment body.", &statements);
1361 assert_eq!(
1362 state,
1363 "## COMMENT\n\nThe comment body.\n\n## STATEMENTS\n\n1. First fact.\n2. Second fact.\n"
1364 );
1365 }
1366
1367 #[test]
1368 fn split_prompt_names_every_citation_and_falls_back_to_none() {
1369 let citations = find_citations("see #1614 and PR #1629", "rust-works/omni-dev", &judged(1));
1370 let prompt = build_split_user_prompt("The comment.", &citations);
1371 assert!(
1372 prompt.contains("Items the comment cites: #1614, PR #1629"),
1373 "{prompt}"
1374 );
1375 assert!(prompt.contains("COMMENT:\nThe comment."), "{prompt}");
1376
1377 let prompt = build_split_user_prompt("no citations here", &[]);
1378 assert!(
1379 prompt.contains("Items the comment cites: (none)"),
1380 "{prompt}"
1381 );
1382 }
1383
1384 #[test]
1388 fn split_prompt_keeps_the_two_additions_that_fixed_live_failures() {
1389 let prompt = build_split_user_prompt("The comment.", &[]);
1390 assert!(
1393 prompt.contains("resolved, settled or answered the judged issue's question"),
1394 "{prompt}"
1395 );
1396 assert!(prompt.contains("do not add its number"), "{prompt}");
1397 assert!(prompt.contains(r#""statements""#), "{prompt}");
1399 assert!(prompt.contains(r#""cites""#), "{prompt}");
1400 }
1401
1402 #[test]
1403 fn strip_code_fence_removes_json_and_bare_fences() {
1404 assert_eq!(strip_code_fence("```json\n{\"a\":1}\n```"), "{\"a\":1}");
1405 assert_eq!(strip_code_fence("```\n{\"a\":1}\n```"), "{\"a\":1}");
1406 assert_eq!(strip_code_fence("{\"a\":1}"), "{\"a\":1}");
1407 }
1408
1409 #[test]
1412 fn parse_split_response_falls_back_to_yaml() {
1413 let raw =
1414 "```yaml\nstatements:\n - text: \"PR #1629 added a tool.\"\n cites: \"#1614\"\n```";
1415 let parsed = parse_split_response(raw).unwrap();
1416 assert_eq!(parsed.statements.len(), 1);
1417 assert_eq!(parsed.statements[0].text, "PR #1629 added a tool.");
1418 assert_eq!(parsed.statements[0].cites.as_deref(), Some("#1614"));
1419 }
1420
1421 #[test]
1422 fn parse_split_response_errors_when_neither_json_nor_yaml_parses() {
1423 let err = parse_split_response("This is not json or yaml.").unwrap_err();
1424 assert!(
1425 format!("{err:#}").contains("Failed to parse the statement splitter's response"),
1426 "{err:#}"
1427 );
1428 }
1429
1430 #[tokio::test]
1431 async fn split_statements_rejects_an_empty_reply() {
1432 let ai = ai_client(vec![Ok(serde_json::json!({"statements": []}).to_string())]);
1433 let err = split_statements(&ai, "comment", &[]).await.unwrap_err();
1434 assert!(err.to_string().contains("no statements"), "{err}");
1435 }
1436
1437 #[tokio::test]
1438 async fn split_statements_rejects_more_than_the_statement_limit() {
1439 let stmts: Vec<serde_json::Value> = (0..=MAX_STATEMENTS)
1440 .map(|i| serde_json::json!({"text": format!("statement {i}"), "cites": null}))
1441 .collect();
1442 let ai = ai_client(vec![Ok(
1443 serde_json::json!({"statements": stmts}).to_string()
1444 )]);
1445 let err = split_statements(&ai, "comment", &[]).await.unwrap_err();
1446 assert!(err.to_string().contains("limit"), "{err}");
1447 }
1448
1449 #[tokio::test]
1450 async fn split_statements_rejects_a_blank_statement() {
1451 let ai = ai_client(vec![Ok(split_response_json(&[(" ", None)]))]);
1452 let err = split_statements(&ai, "comment", &[]).await.unwrap_err();
1453 assert!(err.to_string().contains("empty statement"), "{err}");
1454 }
1455
1456 fn opts() -> VerifyOptions {
1459 VerifyOptions {
1460 jev_model: "jev-latest".to_string(),
1461 supported: DEFAULT_SUPPORTED,
1462 reject_below: DEFAULT_REJECT_BELOW,
1463 coverage: DEFAULT_COVERAGE,
1464 max_input_chars: 60_000,
1465 }
1466 }
1467
1468 #[test]
1469 fn rejects_reject_below_greater_than_threshold() {
1470 let mut o = opts();
1471 o.reject_below = 0.9;
1472 o.supported = 0.5;
1473 assert!(validate_verify_options(&o).is_err());
1474 }
1475
1476 #[test]
1477 fn rejects_out_of_range_knobs() {
1478 for f in [
1479 |o: &mut VerifyOptions| o.supported = 1.5,
1480 |o: &mut VerifyOptions| o.reject_below = -0.1,
1481 |o: &mut VerifyOptions| o.coverage = 2.0,
1482 |o: &mut VerifyOptions| o.max_input_chars = 0,
1483 ] {
1484 let mut o = opts();
1485 f(&mut o);
1486 assert!(validate_verify_options(&o).is_err());
1487 }
1488 }
1489
1490 fn fake_gh_no_citations(dir: &Path) -> (PathBuf, std::sync::MutexGuard<'static, ()>) {
1493 let guard = shim_lock();
1494 let issue = serde_json::json!({
1495 "title": "t", "body": "b", "state": "OPEN", "url": "u",
1496 "comments": {"totalCount": 1, "nodes": [
1497 {"databaseId": 1, "author": {"login": "newhoggy"}, "body": "thanks!"}
1498 ]},
1499 "closedByPullRequestsReferences": {"nodes": []}
1500 });
1501 let path = dir.join("fake-gh");
1502 write_exec_script(
1503 &path,
1504 &format!(
1505 "#!/bin/sh\ncat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {issue}}}}}}}\nJSON\n"
1506 ),
1507 );
1508 (path, guard)
1509 }
1510
1511 #[test]
1512 fn fetch_verify_input_short_circuits_when_the_comment_cites_nothing() {
1513 let dir = tempfile::tempdir().unwrap();
1514 let (bin, _shim) = fake_gh_no_citations(dir.path());
1515 let (issue, comment, citations, sources) = retry_on_etxtbsy(|| {
1516 fetch_verify_input(
1517 &bin,
1518 "rust-works/omni-dev",
1519 &judged(1),
1520 &CommentSelector::Id(1),
1521 )
1522 })
1523 .unwrap();
1524 assert_eq!(issue.number, 1);
1525 assert_eq!(comment.body, "thanks!");
1526 assert!(citations.is_empty());
1527 assert!(sources.is_empty());
1528 }
1529
1530 fn fake_gh_second_round_pr(dir: &Path) -> (PathBuf, std::sync::MutexGuard<'static, ()>) {
1533 let guard = shim_lock();
1534 let issue = serde_json::json!({
1535 "title": "t", "body": "b", "state": "OPEN", "url": "u",
1536 "comments": {"totalCount": 1, "nodes": [
1537 {"databaseId": 1, "author": {"login": "newhoggy"}, "body": "settled by #1614"}
1538 ]},
1539 "closedByPullRequestsReferences": {"nodes": []}
1540 });
1541 let cited_issue = serde_json::json!({
1542 "__typename": "Issue",
1543 "title": "cited", "body": "cited body", "state": "CLOSED", "url": "u2",
1544 "comments": {"totalCount": 0, "nodes": []},
1545 "closedByPullRequestsReferences": {"nodes": [{"number": 1629}]}
1546 });
1547 let closing_pr = serde_json::json!({
1548 "__typename": "PullRequest",
1549 "title": "closer", "body": "pr body", "state": "MERGED", "url": "u3"
1550 });
1551 let path = dir.join("fake-gh");
1552 write_exec_script(
1553 &path,
1554 &format!(
1555 "#!/bin/sh\ncase \"$4\" in\n\
1556 *'number:1614'*) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {cited_issue}}}}}}}\nJSON\n;;\n\
1557 *'number:1629'*) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {closing_pr}}}}}}}\nJSON\n;;\n\
1558 *) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {issue}}}}}}}\nJSON\n;;\n\
1559 esac\n",
1560 ),
1561 );
1562 (path, guard)
1563 }
1564
1565 #[test]
1566 fn fetch_verify_input_fetches_a_second_round_of_closing_prs() {
1567 let dir = tempfile::tempdir().unwrap();
1568 let (bin, _shim) = fake_gh_second_round_pr(dir.path());
1569 let (_issue, _comment, citations, sources) = retry_on_etxtbsy(|| {
1570 fetch_verify_input(
1571 &bin,
1572 "rust-works/omni-dev",
1573 &judged(1),
1574 &CommentSelector::Latest,
1575 )
1576 })
1577 .unwrap();
1578 assert_eq!(citations.len(), 1);
1579 assert_eq!(sources.len(), 1);
1580 assert_eq!(sources[0].name, "#1614 and PR #1629");
1581 assert_eq!(
1582 sources[0].keys,
1583 vec![
1584 ("rust-works/omni-dev".to_string(), 1614),
1585 ("rust-works/omni-dev".to_string(), 1629)
1586 ]
1587 );
1588 }
1589
1590 fn split_response_json(statements: &[(&str, Option<&str>)]) -> String {
1593 let stmts: Vec<serde_json::Value> = statements
1594 .iter()
1595 .map(|(text, cites)| serde_json::json!({"text": text, "cites": cites}))
1596 .collect();
1597 serde_json::json!({"statements": stmts}).to_string()
1598 }
1599
1600 fn ai_client(responses: Vec<Result<String>>) -> ClaudeClient {
1601 ClaudeClient::new(Box::new(ConfigurableMockAiClient::new(responses)))
1602 }
1603
1604 fn noul_json(value: f64) -> serde_json::Value {
1605 serde_json::json!({"type": "noul", "noul": value})
1606 }
1607
1608 async fn source_and_coverage_server(
1609 source_answers: serde_json::Value,
1610 coverage_noul: f64,
1611 ) -> wiremock::MockServer {
1612 let server = wiremock::MockServer::start().await;
1613 wiremock::Mock::given(wiremock::matchers::method("POST"))
1616 .and(wiremock::matchers::body_string_contains("## SOURCE:"))
1617 .respond_with(
1618 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1619 "model": "jev-1.13.0",
1620 "answers": source_answers,
1621 "usage": {"input_tokens": 10, "output_tokens": 5}
1622 })),
1623 )
1624 .mount(&server)
1625 .await;
1626 wiremock::Mock::given(wiremock::matchers::method("POST"))
1627 .and(wiremock::matchers::body_string_contains("## COMMENT"))
1628 .respond_with(
1629 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1630 "model": "jev-1.13.0",
1631 "answers": {"coverage": noul_json(coverage_noul)},
1632 "usage": {"input_tokens": 3, "output_tokens": 1}
1633 })),
1634 )
1635 .mount(&server)
1636 .await;
1637 server
1638 }
1639
1640 fn issue_and_source() -> (IssueDoc, Comment, Vec<Citation>, Vec<Source>) {
1641 let issue = doc_with_comments(vec![comment(1, "newhoggy", "settled by #1614")]);
1642 let citations = find_citations(
1643 &issue.comments[0].body,
1644 "rust-works/omni-dev",
1645 &judged(1779),
1646 );
1647 let cited = issue_doc(1614, "Cited issue", "Cited body.", vec![], vec![]);
1648 let fetched = BTreeMap::from([(("rust-works/omni-dev".to_string(), 1614), cited)]);
1649 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1650 (
1651 issue,
1652 comment(1, "newhoggy", "settled by #1614"),
1653 citations,
1654 sources,
1655 )
1656 }
1657
1658 #[tokio::test]
1659 async fn run_verify_accepts_a_clean_comment() {
1660 let server =
1661 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.95)}), 0.9).await;
1662 let jev = JevClient::new(&server.uri(), "key").unwrap();
1663 let ai = ai_client(vec![Ok(split_response_json(&[(
1664 "PR #1629 exists.",
1665 Some("#1614"),
1666 )]))]);
1667 let (issue, comment, citations, sources) = issue_and_source();
1668
1669 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1670 .await
1671 .unwrap();
1672
1673 assert_eq!(report.verdict, Verdict::Accepted);
1674 assert!(report.reasons.is_empty(), "{:?}", report.reasons);
1675 assert_eq!(report.statements[0].supported, Some(0.95));
1676 assert_eq!(report.models.ai, "mock-model");
1677 assert_eq!(report.usage.jev.input_tokens, 13);
1678 }
1679
1680 #[tokio::test]
1681 async fn run_verify_rejects_when_a_statement_scores_below_reject_below() {
1682 let server =
1683 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.05)}), 0.9).await;
1684 let jev = JevClient::new(&server.uri(), "key").unwrap();
1685 let ai = ai_client(vec![Ok(split_response_json(&[(
1686 "A false claim.",
1687 Some("#1614"),
1688 )]))]);
1689 let (issue, comment, citations, sources) = issue_and_source();
1690
1691 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1692 .await
1693 .unwrap();
1694
1695 assert_eq!(report.verdict, Verdict::Rejected);
1696 assert!(
1697 report.reasons[0].contains("scored 0.05"),
1698 "{:?}",
1699 report.reasons
1700 );
1701 }
1702
1703 #[tokio::test]
1704 async fn run_verify_needs_review_when_a_statement_is_uncertain() {
1705 let server =
1706 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.4)}), 0.9).await;
1707 let jev = JevClient::new(&server.uri(), "key").unwrap();
1708 let ai = ai_client(vec![Ok(split_response_json(&[(
1709 "An uncertain claim.",
1710 Some("#1614"),
1711 )]))]);
1712 let (issue, comment, citations, sources) = issue_and_source();
1713
1714 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1715 .await
1716 .unwrap();
1717
1718 assert_eq!(report.verdict, Verdict::NeedsReview);
1719 }
1720
1721 #[tokio::test]
1722 async fn run_verify_needs_review_when_coverage_is_low() {
1723 let server =
1724 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.95)}), 0.1).await;
1725 let jev = JevClient::new(&server.uri(), "key").unwrap();
1726 let ai = ai_client(vec![Ok(split_response_json(&[(
1727 "A true 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::NeedsReview);
1737 assert!(
1738 report
1739 .reasons
1740 .iter()
1741 .any(|r| r.contains("coverage scored 0.10")),
1742 "{:?}",
1743 report.reasons
1744 );
1745 }
1746
1747 #[tokio::test]
1748 async fn run_verify_needs_review_when_no_citations_resolved() {
1749 let jev = JevClient::new("http://127.0.0.1:1", "key").unwrap();
1750 let ai = ai_client(vec![]);
1751 let issue = doc_with_comments(vec![comment(1, "newhoggy", "no citation here")]);
1752 let comment = comment(1, "newhoggy", "no citation here");
1753
1754 let report = run_verify(&jev, &ai, &issue, &comment, &[], &[], &opts())
1755 .await
1756 .unwrap();
1757
1758 assert_eq!(report.verdict, Verdict::NeedsReview);
1759 assert_eq!(report.reasons, ["the comment cites nothing verifiable"]);
1760 }
1761
1762 #[tokio::test]
1768 async fn run_verify_needs_review_when_only_one_of_two_sources_answers() {
1769 let server = wiremock::MockServer::start().await;
1770 wiremock::Mock::given(wiremock::matchers::method("POST"))
1772 .and(wiremock::matchers::body_string_contains("SOURCE: #1614"))
1773 .respond_with(
1774 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1775 "model": "jev-1.13.0",
1776 "answers": {"s1": noul_json(0.95)},
1777 "usage": {"input_tokens": 1, "output_tokens": 1}
1778 })),
1779 )
1780 .mount(&server)
1781 .await;
1782 wiremock::Mock::given(wiremock::matchers::method("POST"))
1783 .and(wiremock::matchers::body_string_contains("SOURCE: #1237"))
1784 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1785 .mount(&server)
1786 .await;
1787 wiremock::Mock::given(wiremock::matchers::method("POST"))
1788 .and(wiremock::matchers::body_string_contains("## COMMENT"))
1789 .respond_with(
1790 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1791 "model": "jev-1.13.0",
1792 "answers": {"coverage": noul_json(0.9)},
1793 "usage": {"input_tokens": 1, "output_tokens": 1}
1794 })),
1795 )
1796 .mount(&server)
1797 .await;
1798 let jev = JevClient::new(&server.uri(), "key").unwrap();
1799 let ai = ai_client(vec![Ok(split_response_json(&[
1800 ("A claim about #1614.", Some("#1614")),
1801 ("A claim about #1237.", Some("#1237")),
1802 ]))]);
1803
1804 let issue = doc_with_comments(vec![comment(1, "newhoggy", "see #1614 and #1237")]);
1805 let comment = comment(1, "newhoggy", "see #1614 and #1237");
1806 let citations = find_citations(&comment.body, "rust-works/omni-dev", &judged(1779));
1807 let fetched = BTreeMap::from([
1808 (
1809 ("rust-works/omni-dev".to_string(), 1614),
1810 issue_doc(1614, "One", "Body one.", vec![], vec![]),
1811 ),
1812 (
1813 ("rust-works/omni-dev".to_string(), 1237),
1814 issue_doc(1237, "Two", "Body two.", vec![], vec![]),
1815 ),
1816 ]);
1817 let sources = group_sources(&citations, "rust-works/omni-dev", &fetched);
1818 assert_eq!(sources.len(), 2);
1819
1820 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1821 .await
1822 .unwrap();
1823
1824 assert_eq!(report.verdict, Verdict::NeedsReview);
1825 assert_eq!(report.statements[0].supported, Some(0.95));
1826 assert!(report.statements[1].supported.is_none());
1827 assert!(
1828 report.reasons.iter().any(|r| r.contains("has no answer")),
1829 "{:?}",
1830 report.reasons
1831 );
1832 assert!(report.sources.iter().any(|s| s.error.is_some()));
1834 }
1835
1836 #[tokio::test]
1840 async fn run_verify_reports_a_source_with_no_attributed_statements() {
1841 let server = source_and_coverage_server(serde_json::json!({}), 0.9).await;
1842 let jev = JevClient::new(&server.uri(), "key").unwrap();
1843 let ai = ai_client(vec![Ok(split_response_json(&[(
1844 "A decision about this issue.",
1845 None,
1846 )]))]);
1847 let (issue, comment, citations, sources) = issue_and_source();
1848
1849 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1850 .await
1851 .unwrap();
1852
1853 assert_eq!(report.sources.len(), 1);
1854 assert_eq!(report.sources[0].source, "#1614");
1855 assert!(report.sources[0].error.is_none());
1856 }
1857
1858 #[tokio::test]
1862 async fn run_verify_treats_a_self_citation_as_a_judged_issue_statement() {
1863 let server =
1864 source_and_coverage_server(serde_json::json!({"s1": noul_json(0.95)}), 0.9).await;
1865 let jev = JevClient::new(&server.uri(), "key").unwrap();
1866 let ai = ai_client(vec![Ok(split_response_json(&[
1867 ("A claim about #1614.", Some("#1614")),
1868 ("This issue's question is settled.", Some("#1779")),
1869 ]))]);
1870 let (issue, comment, citations, sources) = issue_and_source();
1871
1872 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1873 .await
1874 .unwrap();
1875
1876 assert_eq!(report.verdict, Verdict::Accepted);
1877 assert!(report.statements[1].source.is_none());
1878 assert!(report.statements[1].reason.is_none());
1879 assert!(report.reasons.is_empty(), "{:?}", report.reasons);
1880 }
1881
1882 #[test]
1884 fn report_models_omit_the_models_that_never_answered() {
1885 let value = serde_json::to_value(ReportModels::default()).unwrap();
1886 assert_eq!(value, serde_json::json!({}));
1887 }
1888
1889 #[tokio::test]
1890 async fn run_verify_flags_an_unresolved_citation() {
1891 let server = source_and_coverage_server(serde_json::json!({}), 0.9).await;
1892 let jev = JevClient::new(&server.uri(), "key").unwrap();
1893 let ai = ai_client(vec![Ok(split_response_json(&[(
1896 "Some claim.",
1897 Some("#9999"),
1898 )]))]);
1899 let (issue, comment, citations, sources) = issue_and_source();
1900
1901 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1902 .await
1903 .unwrap();
1904
1905 assert_eq!(report.verdict, Verdict::NeedsReview);
1906 assert!(report.statements[0]
1907 .reason
1908 .as_ref()
1909 .unwrap()
1910 .contains("could not be resolved"));
1911 }
1912
1913 #[tokio::test]
1914 async fn run_verify_stops_on_an_auth_failure() {
1915 let server = wiremock::MockServer::start().await;
1916 wiremock::Mock::given(wiremock::matchers::method("POST"))
1917 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("bad key"))
1918 .mount(&server)
1919 .await;
1920 let jev = JevClient::new(&server.uri(), "key").unwrap();
1921 let ai = ai_client(vec![Ok(split_response_json(&[(
1922 "A claim.",
1923 Some("#1614"),
1924 )]))]);
1925 let (issue, comment, citations, sources) = issue_and_source();
1926
1927 let err = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1928 .await
1929 .unwrap_err();
1930 assert!(format!("{err:#}").contains("Failed to verify against source #1614"));
1931 }
1932
1933 #[tokio::test]
1936 async fn run_verify_stops_on_a_coverage_auth_failure() {
1937 let server = wiremock::MockServer::start().await;
1938 wiremock::Mock::given(wiremock::matchers::method("POST"))
1939 .and(wiremock::matchers::body_string_contains("## SOURCE:"))
1940 .respond_with(
1941 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1942 "model": "jev-1.13.0",
1943 "answers": {"s1": noul_json(0.95)},
1944 "usage": {"input_tokens": 1, "output_tokens": 1}
1945 })),
1946 )
1947 .mount(&server)
1948 .await;
1949 wiremock::Mock::given(wiremock::matchers::method("POST"))
1950 .and(wiremock::matchers::body_string_contains("## COMMENT"))
1951 .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("bad key"))
1952 .mount(&server)
1953 .await;
1954 let jev = JevClient::new(&server.uri(), "key").unwrap();
1955 let ai = ai_client(vec![Ok(split_response_json(&[(
1956 "A claim.",
1957 Some("#1614"),
1958 )]))]);
1959 let (issue, comment, citations, sources) = issue_and_source();
1960
1961 let err = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1962 .await
1963 .unwrap_err();
1964 assert!(
1965 format!("{err:#}").contains("Failed to check statement coverage"),
1966 "{err:#}"
1967 );
1968 }
1969
1970 #[tokio::test]
1973 async fn run_verify_needs_review_when_the_coverage_call_fails() {
1974 let server = wiremock::MockServer::start().await;
1975 wiremock::Mock::given(wiremock::matchers::method("POST"))
1976 .and(wiremock::matchers::body_string_contains("## SOURCE:"))
1977 .respond_with(
1978 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1979 "model": "jev-1.13.0",
1980 "answers": {"s1": noul_json(0.95)},
1981 "usage": {"input_tokens": 1, "output_tokens": 1}
1982 })),
1983 )
1984 .mount(&server)
1985 .await;
1986 wiremock::Mock::given(wiremock::matchers::method("POST"))
1987 .and(wiremock::matchers::body_string_contains("## COMMENT"))
1988 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1989 .mount(&server)
1990 .await;
1991 let jev = JevClient::new(&server.uri(), "key").unwrap();
1992 let ai = ai_client(vec![Ok(split_response_json(&[(
1993 "A claim.",
1994 Some("#1614"),
1995 )]))]);
1996 let (issue, comment, citations, sources) = issue_and_source();
1997
1998 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
1999 .await
2000 .unwrap();
2001 assert_eq!(report.verdict, Verdict::NeedsReview);
2002 assert!(report.coverage.is_none());
2003 assert!(
2004 report
2005 .reasons
2006 .iter()
2007 .any(|r| r.contains("coverage unavailable")),
2008 "{:?}",
2009 report.reasons
2010 );
2011 }
2012
2013 #[tokio::test]
2016 async fn run_verify_needs_review_when_the_coverage_answer_is_missing() {
2017 let server = wiremock::MockServer::start().await;
2018 wiremock::Mock::given(wiremock::matchers::method("POST"))
2019 .and(wiremock::matchers::body_string_contains("## SOURCE:"))
2020 .respond_with(
2021 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2022 "model": "jev-1.13.0",
2023 "answers": {"s1": noul_json(0.95)},
2024 "usage": {"input_tokens": 1, "output_tokens": 1}
2025 })),
2026 )
2027 .mount(&server)
2028 .await;
2029 wiremock::Mock::given(wiremock::matchers::method("POST"))
2030 .and(wiremock::matchers::body_string_contains("## COMMENT"))
2031 .respond_with(
2032 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2033 "model": "jev-1.13.0",
2034 "answers": {},
2035 "usage": {"input_tokens": 1, "output_tokens": 1}
2036 })),
2037 )
2038 .mount(&server)
2039 .await;
2040 let jev = JevClient::new(&server.uri(), "key").unwrap();
2041 let ai = ai_client(vec![Ok(split_response_json(&[(
2042 "A claim.",
2043 Some("#1614"),
2044 )]))]);
2045 let (issue, comment, citations, sources) = issue_and_source();
2046
2047 let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts())
2048 .await
2049 .unwrap();
2050 assert_eq!(report.verdict, Verdict::NeedsReview);
2051 assert!(report.coverage.is_none());
2052 assert!(
2053 report
2054 .reasons
2055 .iter()
2056 .any(|r| r.contains("no `coverage` answer")),
2057 "{:?}",
2058 report.reasons
2059 );
2060 }
2061}