use std::sync::Arc;
use tracing::{debug, info, warn};
use crate::{
config::ReviewConfig,
integrations::{
analyze_client::AnalyzeClient,
github::{AuthStrategy, GithubClient, GithubError, RunMode, fetch_pr_metadata},
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, grade::derive_verdict,
output::{print_review_result, write_review_log},
parser::parse_review_response,
post::{PostContext, finalize_review},
prompt::{ReviewPrMeta, build_review_prompt},
runner_context::{gather_context, gather_external_context_md},
trigger::TriggerDecision,
verify::maybe_verify,
},
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 (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,
input.run_mode,
),
);
let llm_req = build_review_prompt(
&owner,
&repo,
&pr_meta,
&diff,
&context,
&external_context,
&input.reviewer_model,
);
debug!(model = %input.reviewer_model, "calling LLM reviewer");
let llm_resp = match deps.llm.complete(llm_req).await {
Ok(resp) => resp,
Err(e) => {
warn!("LLM call failed: {e} — applying fail-safe APPROVE (spec REV-130)");
result.verdict = Verdict::Approve;
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}"));
}
}
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 APPROVE"
);
}
let final_verdict = if parsed.is_fail_safe {
parsed.verdict
} else {
derive_verdict(parsed.verdict, &parsed.findings)
};
info!(
verdict = %final_verdict,
findings_count = parsed.findings.len(),
"final verdict after severity-anchored floor"
);
let mut findings = parsed.findings;
result.verdict = maybe_verify(
config,
deps.verifier.as_ref(),
&diff,
final_verdict,
&mut findings,
)
.await;
result.findings = findings;
finalize_run(result, config, &input, deps.dedup.as_ref()).await
}
async fn fetch_github_pr_meta(
config: &ReviewConfig,
owner: &str,
repo: &str,
pr: u64,
run_mode: RunMode,
) -> Result<(ReviewPrMeta, String), GithubError> {
let client = GithubClient::new();
let token = AuthStrategy::select(run_mode, None)
.resolve_token(&client, config, owner)
.await?;
let meta = fetch_pr_metadata(&client, owner, repo, pr, &token).await?;
let head_sha = meta.head.sha.clone();
Ok((
ReviewPrMeta {
title: meta.title,
body: meta.body.unwrap_or_default(),
author: meta.user.login,
url: meta.html_url,
},
head_sha,
))
}
fn abort_dry(
mut result: ReviewResult,
config: &ReviewConfig,
input: &ReviewInput,
deps: &ReviewDeps,
) -> ReviewResult {
result.dry_run = true;
if !result.head_sha.is_empty()
&& let Some(store) = deps.dedup.as_ref()
&& let Err(e) = store.release(
&result.owner,
&result.repo,
result.pr_number,
&result.head_sha,
)
{
warn!("dedup release() after abort failed (non-fatal): {e}");
}
if input.write_log {
write_review_log(&result, &config.log_dir);
}
if input.print_result {
print_review_result(&result);
}
result
}
async fn finalize_run(
result: ReviewResult,
config: &ReviewConfig,
input: &ReviewInput,
dedup: Option<&Arc<DedupStore>>,
) -> ReviewResult {
let owner = result.owner.clone();
let repo = result.repo.clone();
let pr = result.pr_number;
let head_sha = result.head_sha.clone();
let post_ctx = PostContext {
owner: &owner,
repo: &repo,
pr,
head_sha: &head_sha,
run_mode: input.run_mode,
dedup,
};
finalize_review(
result,
config,
input.trigger,
input.allow_posting,
input.write_log,
input.print_result,
post_ctx,
)
.await
}
#[cfg(test)]
#[path = "runner_tests.rs"]
mod tests;