use std::sync::Arc;
use tracing::{debug, info, warn};
use super::runner_coverage::load_coverage_contrib;
use super::runner_helpers::{
abort_dry, apply_grade_and_floor, attach_inline_comments, fetch_github_pr_meta, finalize_run,
};
use crate::{
config::ReviewConfig,
coverage::{CoverageVerdictContrib, apply_coverage_floor},
integrations::{analyze_client::AnalyzeClient, github::RunMode, search_client::SearchClient},
llm::LlmProvider,
models::{ReviewResult, ReviewStatus, Verdict},
pipeline::{
context_gate::{GateOutcome, degraded_banner, preflight_context},
diff::{DiffSource, extract_changed_files, extract_identifiers, load_diff, truncate_diff},
diff_analyzer::DiffAnalyzer, parser::parse_review_response,
prompt::{ReviewPrMeta, build_review_prompt_with_coverage},
runner_context::{gather_context, gather_external_context_md},
trigger::TriggerDecision,
verify::maybe_verify,
voice_config::build_voice_config,
},
store::{ClaimOutcome, DedupStore},
};
pub struct ReviewInput {
pub diff_source: DiffSource,
pub reviewer_model: String,
pub write_log: bool,
pub print_result: bool,
pub trigger: TriggerDecision,
pub run_mode: RunMode,
pub allow_posting: bool,
}
pub struct ReviewDeps {
pub llm: Arc<dyn LlmProvider>,
pub verifier: Option<Arc<dyn LlmProvider>>,
pub search: Arc<dyn SearchClient>,
pub analyze: Option<Arc<dyn AnalyzeClient>>,
pub dedup: Option<Arc<DedupStore>>,
}
pub async fn run_review(
config: &ReviewConfig,
input: ReviewInput,
deps: ReviewDeps,
) -> ReviewResult {
let (owner, repo, pr_number, is_local) = match &input.diff_source {
DiffSource::Github {
owner, repo, pr, ..
} => (owner.clone(), repo.clone(), *pr, false),
DiffSource::LocalFile { path } => {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("local");
("local".to_string(), stem.to_string(), 0_u64, true)
}
};
let pr_url = if !is_local {
format!("https://github.com/{owner}/{repo}/pull/{pr_number}")
} else {
String::new()
};
let (pr_meta, head_sha): (ReviewPrMeta, String) = if is_local {
(ReviewPrMeta::default(), String::new())
} else {
match fetch_github_pr_meta(config, &owner, &repo, pr_number, input.run_mode).await {
Ok((m, sha)) => (m, sha),
Err(e) => {
warn!("failed to fetch PR metadata: {e} — using empty metadata");
(
ReviewPrMeta {
title: format!("PR #{pr_number}"),
body: String::new(),
author: String::new(),
url: pr_url.clone(),
},
String::new(),
)
}
}
};
let mut result = ReviewResult::new(
owner.clone(),
repo.clone(),
pr_number,
pr_meta.title.clone(),
pr_url,
);
result.head_sha = head_sha.clone();
if !is_local
&& !head_sha.is_empty()
&& let Some(store) = deps.dedup.as_ref()
{
match store.claim(&owner, &repo, pr_number, &head_sha) {
Ok(ClaimOutcome::Skipped) => {
info!(
owner = %owner,
repo = %repo,
pr = pr_number,
head_sha = %head_sha,
"dedup: a completed review already exists for this head SHA — skipping"
);
result.verdict = Verdict::Approve;
result.error = Some("skipped: duplicate of a completed review".to_string());
result.dry_run = true;
return result;
}
Ok(ClaimOutcome::Claimed) => {
debug!(head_sha = %head_sha, "dedup: claimed review slot");
}
Err(e) => {
warn!("dedup claim failed (proceeding without dedup): {e}");
}
}
}
let raw_diff = match load_diff(&input.diff_source).await {
Ok(d) => d,
Err(e) => {
warn!("failed to load diff: {e}");
result.error = Some(format!("diff load failed: {e}"));
return abort_dry(result, config, &input, &deps);
}
};
let filtered = DiffAnalyzer::default().analyze(&raw_diff).await;
let max = crate::config::constants::MAX_DIFF_CHARS;
let diff = truncate_diff(&filtered.render_for_prompt(max));
debug!(orig = raw_diff.len(), filt = diff.len(), "diff filtered");
let identifiers = extract_identifiers(&diff, 8);
let changed_files = extract_changed_files(&diff);
debug!(ids = ?identifiers, files = changed_files.len(), "extracted identifiers from diff");
let degraded_reason: Option<String> = match preflight_context(config, &deps).await {
GateOutcome::Proceed => None,
GateOutcome::Skip(reason) => {
warn!("required-context gate: skipping review — {reason}");
result.status = ReviewStatus::Skipped;
result.verdict = Verdict::Unknown;
result.error = Some(reason);
result.dry_run = true;
return abort_dry(result, config, &input, &deps);
}
GateOutcome::Degraded(reason) => {
warn!("required-context gate: proceeding DEGRADED (non-authoritative) — {reason}");
result.status = ReviewStatus::Degraded;
Some(reason)
}
};
let title = &pr_meta.title;
let body = &pr_meta.body;
let (mut context, external_context) = tokio::join!(
gather_context(config, &deps, &identifiers, &changed_files, title, body),
gather_external_context_md(
config,
&owner,
&repo,
&identifiers,
&changed_files,
title,
body,
pr_number,
input.run_mode,
),
);
let coverage_contrib: Option<CoverageVerdictContrib> =
load_coverage_contrib(config, &diff).await;
context.coverage_contrib = coverage_contrib.clone();
let voice_config = build_voice_config(config);
let llm_req = build_review_prompt_with_coverage(
&owner,
&repo,
&pr_meta,
&diff,
&context,
&external_context,
&input.reviewer_model,
&voice_config,
config.coverage.enabled,
);
debug!(model = %input.reviewer_model, "calling LLM reviewer");
let requested_max_tokens = llm_req.max_tokens;
let llm_resp = match deps.llm.complete(llm_req).await {
Ok(resp) => resp,
Err(e) => {
warn!("LLM call failed: {e} — applying fail-safe UNKNOWN (fail-closed, #1241)");
result.verdict = Verdict::Unknown;
result.error = Some(format!("LLM error: {e}"));
return abort_dry(result, config, &input, &deps);
}
};
info!(
model = %llm_resp.model,
input_tokens = llm_resp.input_tokens,
output_tokens = llm_resp.output_tokens,
cost_usd = llm_resp.cost_usd,
latency_ms = llm_resp.latency_ms,
"LLM reviewer call complete"
);
result.apply_llm_response(&llm_resp);
if let Some(reason) = degraded_reason.as_ref() {
result.review_body = format!("{}{}", degraded_banner(reason), result.review_body);
if result.error.is_none() {
result.error = Some(format!("degraded (non-authoritative): {reason}"));
}
}
if is_truncated(
llm_resp.finish_reason.as_deref(),
llm_resp.output_tokens,
requested_max_tokens,
) {
warn!(
output_tokens = llm_resp.output_tokens,
max_tokens = requested_max_tokens,
"LLM output hit the token ceiling — treating as truncated → UNKNOWN (fail-closed, #1241)"
);
result.verdict = Verdict::Unknown;
result.error = Some(format!(
"review output truncated at token ceiling ({}/{} tokens) — could not review",
llm_resp.output_tokens, requested_max_tokens
));
return abort_dry(result, config, &input, &deps);
}
let parsed = parse_review_response(&llm_resp.text);
if parsed.is_fail_safe {
warn!(
reason = ?parsed.fail_safe_reason,
"verdict parsing fell back to fail-safe UNKNOWN (fail-closed, #1241)"
);
}
let (final_verdict, final_grade, original_llm_grade) = apply_grade_and_floor(&parsed);
info!(
verdict = %final_verdict,
grade = %final_grade,
findings_count = parsed.findings.len(),
"final verdict + grade after severity-anchored floor"
);
let (final_verdict, _cov_grade) = if let Some(ref cov) = coverage_contrib {
let before = final_verdict.clone();
let (cv, cg) = apply_coverage_floor(final_verdict, final_grade, cov);
if cv != before {
info!(
before = %before,
after = %cv,
reason = %cov.summary,
"coverage floor tightened verdict"
);
}
(cv, cg)
} else {
(final_verdict, final_grade)
};
let mut findings = parsed.findings;
result.verdict = maybe_verify(
config,
deps.verifier.as_ref(),
&diff,
final_verdict,
&mut findings,
)
.await;
result.findings = findings;
result.grade = Some(
crate::pipeline::letter_grade::clamp_grade_to_verdict(original_llm_grade, &result.verdict)
.to_string(),
);
attach_inline_comments(&mut result, &raw_diff);
finalize_run(result, config, &input, deps.dedup.as_ref()).await
}
const DEFAULT_TRUNCATION_TOKEN_RATIO: f64 = 0.95;
const TRUNCATION_TOKEN_RATIO_ENV: &str = "TRUSTY_REVIEW_TRUNCATION_TOKEN_RATIO";
fn truncation_token_ratio() -> f64 {
match std::env::var(TRUNCATION_TOKEN_RATIO_ENV) {
Ok(raw) => match raw.trim().parse::<f64>() {
Ok(v) if v.is_finite() && v > 0.0 && v <= 1.0 => v,
_ => DEFAULT_TRUNCATION_TOKEN_RATIO,
},
Err(_) => DEFAULT_TRUNCATION_TOKEN_RATIO,
}
}
fn is_truncated(finish_reason: Option<&str>, output_tokens: u32, max_tokens: u32) -> bool {
if let Some(reason) = finish_reason {
let r = reason.trim().to_ascii_lowercase();
if !r.is_empty() {
return matches!(r.as_str(), "length" | "max_tokens" | "max_token");
}
}
if max_tokens == 0 {
return false;
}
let threshold = (f64::from(max_tokens) * truncation_token_ratio()).ceil() as u32;
output_tokens >= threshold
}
#[cfg(test)]
#[path = "runner_tests.rs"]
mod tests;