use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use crate::jev::citations::Citation;
use crate::jev::client::JevClient;
use crate::jev::config::JevConfig;
use crate::jev::verify::{
fetch_verify_input, parse_comment_selector, run_verify, CommentSelector, Source, VerifyOptions,
DEFAULT_COVERAGE, DEFAULT_REJECT_BELOW, DEFAULT_SUPPORTED,
};
use crate::provider::{Comment, IssueDoc};
use super::common::{format_output, JevFormat};
#[derive(Parser)]
#[command(
long_about = "Checks a decision comment against the issues or pull requests it cites.\n\n\
Reads the issue's decision comment (by default, its most recent comment that cites another \
issue or pull request), splits it into single factual statements with the configured AI \
backend, and checks each statement against the source it names with Jev — one call per cited \
source, one yes/no question per statement attributed to it — plus one Jev call asking whether \
the split statements together cover everything the comment claims.\n\n\
The verdict is `rejected` if any statement scores below --reject-below, `accepted` if every \
statement clears --threshold and coverage did too, and `needs_review` otherwise: an uncertain \
statement, an unresolved citation, low coverage, or a comment that cites nothing verifiable. \
`verify-decision` checks only that a comment matches its sources; whether the decision is the \
right one remains a person's call."
)]
pub struct VerifyDecisionCommand {
#[arg(value_name = "ISSUE")]
pub issue: String,
#[arg(long, value_name = "ID|latest", default_value = "latest")]
pub comment: String,
#[arg(long = "threshold", value_name = "PROBABILITY", default_value_t = DEFAULT_SUPPORTED)]
pub threshold: f64,
#[arg(long, value_name = "PROBABILITY", default_value_t = DEFAULT_REJECT_BELOW)]
pub reject_below: f64,
#[arg(long, value_name = "PROBABILITY", default_value_t = DEFAULT_COVERAGE)]
pub coverage_threshold: f64,
#[arg(long, value_name = "CHARS", default_value_t = crate::jev::route::DEFAULT_MAX_INPUT_CHARS)]
pub max_input_chars: usize,
#[arg(short = 'o', long, value_enum, default_value_t = JevFormat::Json)]
pub(super) output: JevFormat,
#[arg(long, value_name = "MODEL")]
pub jev_model: Option<String>,
#[command(flatten)]
pub repo: crate::cli::repo_arg::RepoArg,
#[command(flatten)]
pub ai: crate::cli::ai_backend_args::AiBackendArgs,
}
impl VerifyDecisionCommand {
pub async fn execute(self) -> Result<()> {
self.ai.apply();
let mut jev_config = JevConfig::from_env()?;
if let Some(model) = self.jev_model {
jev_config.model = model;
}
let jev = JevClient::from_config(&jev_config)?;
crate::utils::preflight::check_ai_credentials(None)?;
let ai = crate::claude::create_default_claude_client(None, None).await?;
let selector = parse_comment_selector(&self.comment)?;
let bin = crate::pr_status::resolve_gh_binary();
let cwd = self
.repo
.path()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let issue_arg = self.issue;
let (issue, comment, citations, sources) =
tokio::task::spawn_blocking(move || fetch_input(&bin, &cwd, &issue_arg, &selector))
.await
.context("Issue fetch task panicked")??;
let opts = VerifyOptions {
jev_model: jev_config.model,
supported: self.threshold,
reject_below: self.reject_below,
coverage: self.coverage_threshold,
max_input_chars: self.max_input_chars,
};
let report = run_verify(&jev, &ai, &issue, &comment, &citations, &sources, &opts).await?;
print!("{}", format_output(&report, self.output)?);
Ok(())
}
}
fn fetch_input(
bin: &Path,
cwd: &Path,
issue_arg: &str,
selector: &CommentSelector,
) -> Result<(IssueDoc, Comment, Vec<Citation>, Vec<Source>)> {
let default_project = if crate::github_issues::needs_default_project(issue_arg) {
Some(
crate::github_issues::resolve_current_project(bin, cwd).context(
"Failed to find the current GitHub repository; pass owner/repo#N or use -C/--repo",
)?,
)
} else {
None
};
let judged = crate::github_issues::parse_issue_arg(issue_arg, default_project.as_deref())?;
let default_project = default_project.unwrap_or_else(|| judged.project.clone());
fetch_verify_input(bin, &default_project, &judged, selector)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cli::Cli;
use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
use std::path::PathBuf;
fn parse(args: &[&str]) -> Result<VerifyDecisionCommand, clap::Error> {
let argv = ["omni-dev", "ai", "jev", "verify-decision"]
.iter()
.chain(args)
.copied();
let cli = Cli::try_parse_from(argv)?;
let crate::cli::Commands::Ai(ai) = cli.command else {
panic!("expected ai");
};
let crate::cli::ai::AiSubcommands::Jev(jev) = ai.command else {
panic!("expected ai jev");
};
let super::super::JevSubcommands::VerifyDecision(cmd) = jev.command else {
panic!("expected ai jev verify-decision");
};
Ok(cmd)
}
#[test]
fn parses_issue_and_defaults() {
let cmd = parse(&["#1779"]).unwrap();
assert_eq!(cmd.issue, "#1779");
assert_eq!(cmd.comment, "latest");
assert!((cmd.threshold - DEFAULT_SUPPORTED).abs() < f64::EPSILON);
assert!((cmd.reject_below - DEFAULT_REJECT_BELOW).abs() < f64::EPSILON);
assert!((cmd.coverage_threshold - DEFAULT_COVERAGE).abs() < f64::EPSILON);
assert_eq!(cmd.output, JevFormat::Json);
}
#[test]
fn accepts_repo_and_ai_backend_flags() {
let cmd = parse(&[
"#1779",
"-C",
"/tmp/r",
"--ai-backend",
"claude-cli",
"--model",
"opus",
"--comment",
"12345",
"--threshold",
"0.6",
])
.unwrap();
assert_eq!(cmd.repo.path(), Some(Path::new("/tmp/r")));
assert_eq!(
cmd.ai.ai_backend,
Some(crate::claude::backend::AiBackend::ClaudeCli)
);
assert_eq!(cmd.comment, "12345");
}
#[test]
fn requires_an_issue_argument() {
assert!(parse(&[]).is_err());
}
fn fake_gh(dir: &Path) -> (PathBuf, std::sync::MutexGuard<'static, ()>) {
let guard = shim_lock();
let issue = serde_json::json!({
"title": "t", "body": "settled by #1614", "state": "OPEN", "url": "u",
"comments": {"totalCount": 1, "nodes": [
{"databaseId": 1, "author": {"login": "newhoggy"}, "body": "settled by #1614"}
]},
"closedByPullRequestsReferences": {"nodes": []}
});
let cited = serde_json::json!({
"__typename": "Issue",
"title": "cited", "body": "cited body", "state": "CLOSED", "url": "u2",
"comments": {"totalCount": 0, "nodes": []},
"closedByPullRequestsReferences": {"nodes": []}
});
let path = dir.join("fake-gh");
write_exec_script(
&path,
&format!(
"#!/bin/sh\ncase \"$1\" in\n\
repo) echo rust-works/omni-dev ;;\n\
api) case \"$4\" in\n\
*issueOrPullRequest*) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {cited}}}}}}}\nJSON\n;;\n\
*) cat <<'JSON'\n{{\"data\": {{\"r0\": {{\"i0\": {issue}}}}}}}\nJSON\n;;\n\
esac ;;\n\
esac\n",
),
);
(path, guard)
}
#[test]
fn fetch_input_resolves_the_issue_and_its_citation() {
let dir = tempfile::tempdir().unwrap();
let (bin, _shim) = fake_gh(dir.path());
let (issue, comment, citations, sources) =
retry_on_etxtbsy(|| fetch_input(&bin, dir.path(), "#1", &CommentSelector::Latest))
.unwrap();
assert_eq!(issue.number, 1);
assert_eq!(comment.body, "settled by #1614");
assert_eq!(citations.len(), 1);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].label, "#1614");
}
#[test]
fn fetch_input_skips_project_resolution_when_the_issue_names_its_own_repo() {
let dir = tempfile::tempdir().unwrap();
let (bin, _shim) = fake_gh(dir.path());
let (issue, comment, citations, sources) = retry_on_etxtbsy(|| {
fetch_input(
&bin,
dir.path(),
"rust-works/omni-dev#1",
&CommentSelector::Latest,
)
})
.unwrap();
assert_eq!(issue.number, 1);
assert_eq!(comment.body, "settled by #1614");
assert_eq!(citations.len(), 1);
assert_eq!(sources.len(), 1);
}
}