use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::clip;
use crate::config::{require, Config};
use crate::prompt::{
build_user_prompt, ASK_SYSTEM_PROMPT, CRITIQUE_SYSTEM_PROMPT, DESCRIBE_SYSTEM_PROMPT,
FILE_REVIEW_SYSTEM_PROMPT,
};
use crate::providers::PrMeta;
#[derive(Serialize)]
struct Msg {
role: String,
content: String,
}
#[derive(Serialize)]
struct ChatReq {
model: String,
max_tokens: u32,
temperature: f32,
messages: Vec<Msg>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Usage {
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub total_tokens: Option<u32>,
}
fn default_severity() -> String {
"MEDIUM".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
#[serde(default = "default_severity")]
pub severity: String,
#[serde(default)]
pub file: String,
#[serde(default)]
pub line: Option<u64>,
pub body: String,
#[serde(default)]
pub confidence: Option<u8>,
}
pub(crate) fn findings_from_values(raw: Vec<serde_json::Value>) -> (Vec<Finding>, usize) {
let mut kept = Vec::with_capacity(raw.len());
let mut dropped = 0usize;
for v in raw {
match serde_json::from_value::<Finding>(v) {
Ok(f) => kept.push(f),
Err(e) => {
dropped += 1;
tracing::warn!("dropping malformed finding ({e})");
}
}
}
(kept, dropped)
}
fn lenient_findings<'de, D>(d: D) -> std::result::Result<Vec<Finding>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = Vec::<serde_json::Value>::deserialize(d)?;
Ok(findings_from_values(raw).0)
}
#[derive(Debug, Clone, Deserialize)]
pub struct Review {
pub summary: String,
pub recommendation: String,
#[serde(default, deserialize_with = "lenient_findings")]
pub findings: Vec<Finding>,
}
#[derive(Deserialize)]
struct ChoiceMsg {
content: Option<String>,
}
#[derive(Deserialize)]
struct Choice {
message: Option<ChoiceMsg>,
}
#[derive(Deserialize)]
struct ApiErr {
message: Option<String>,
}
#[derive(Deserialize)]
struct ChatRes {
choices: Option<Vec<Choice>>,
usage: Option<Usage>,
error: Option<ApiErr>,
}
#[derive(Debug)]
pub struct ReviewResult {
pub review: Review,
pub model: String,
pub usage: Option<Usage>,
}
pub fn extract_json(text: &str) -> Option<&str> {
let start = text.find('{')?;
let end = text.rfind('}')?;
if end > start {
Some(&text[start..=end])
} else {
None
}
}
pub(crate) fn extract_json_array(text: &str) -> Option<&str> {
let start = text.find('[')?;
let end = text.rfind(']')?;
if end > start {
Some(&text[start..=end])
} else {
None
}
}
pub async fn review_diff(
client: &Client,
cfg: &Config,
meta: &PrMeta,
diff: &str,
omitted_note: Option<String>,
structural_context: Option<&str>,
system_prompt: &str,
) -> Result<ReviewResult> {
require(&cfg.openrouter_api_key, "OPENROUTER_API_KEY")?;
let truncated = diff.chars().count() > cfg.max_diff_chars;
let clipped: String = if truncated {
diff.chars().take(cfg.max_diff_chars).collect()
} else {
diff.to_string()
};
let req = ChatReq {
model: cfg.openrouter_model.clone(),
max_tokens: cfg.openrouter_max_tokens,
temperature: cfg.openrouter_temperature,
messages: vec![
Msg {
role: "system".into(),
content: system_prompt.to_string(),
},
Msg {
role: "user".into(),
content: build_user_prompt(
meta,
&clipped,
truncated,
omitted_note.as_deref(),
structural_context,
),
},
],
};
let res = client
.post(format!("{}/chat/completions", cfg.openrouter_base_url))
.bearer_auth(&cfg.openrouter_api_key)
.header("HTTP-Referer", &cfg.http_referer)
.header("X-Title", &cfg.x_title)
.json(&req)
.send()
.await?;
let status = res.status();
let text = res.text().await?;
let data: ChatRes = serde_json::from_str(&text).map_err(|e| {
anyhow::anyhow!(
"OpenRouter {status}: non-JSON response ({e}): {}",
clip(&text, 300)
)
})?;
if !status.is_success() || data.error.is_some() {
let msg = data
.error
.and_then(|e| e.message)
.unwrap_or_else(|| clip(&text, 500));
anyhow::bail!("OpenRouter {status}: {msg}");
}
let content = data
.choices
.and_then(|c| c.into_iter().next())
.and_then(|c| c.message)
.and_then(|m| m.content)
.ok_or_else(|| anyhow::anyhow!("OpenRouter returned an empty response."))?;
let json = extract_json(&content).ok_or_else(|| {
anyhow::anyhow!(
"Model did not return a JSON object: {}",
clip(&content, 300)
)
})?;
let review: Review = serde_json::from_str(json)
.map_err(|e| anyhow::anyhow!("Could not parse review JSON ({e}): {}", clip(json, 300)))?;
Ok(ReviewResult {
review,
model: cfg.openrouter_model.clone(),
usage: data.usage,
})
}
pub async fn critique_findings(
cfg: &Config,
backend: &dyn crate::backend::ReviewBackend,
meta: &PrMeta,
diff: &str,
findings: &[Finding],
) -> Result<Vec<Finding>> {
let clipped: String = diff.chars().take(cfg.max_diff_chars).collect();
let findings_json = serde_json::to_string_pretty(findings)
.map_err(|e| anyhow::anyhow!("could not serialize findings for critique: {e}"))?;
let user = format!(
"Repository: {}\nPull request: #{}\n\n--- BEGIN DIFF ---\n{clipped}\n--- END DIFF ---\n\n--- PROPOSED FINDINGS (JSON) ---\n{findings_json}",
meta.repo, meta.pr,
);
let content = backend.complete(cfg, CRITIQUE_SYSTEM_PROMPT, &user).await?;
let json = extract_json_array(&content).ok_or_else(|| {
anyhow::anyhow!(
"Critique did not return a JSON array: {}",
clip(&content, 300)
)
})?;
let raw: Vec<serde_json::Value> = serde_json::from_str(json)
.map_err(|e| anyhow::anyhow!("Could not parse critique JSON ({e}): {}", clip(json, 300)))?;
let (kept, dropped) = findings_from_values(raw);
if kept.is_empty() && dropped > 0 {
anyhow::bail!(
"Critique returned {dropped} unparseable finding(s): {}",
clip(json, 300)
);
}
Ok(kept)
}
pub(crate) async fn chat_text(
client: &Client,
cfg: &Config,
system: &str,
user: &str,
) -> Result<String> {
require(&cfg.openrouter_api_key, "OPENROUTER_API_KEY")?;
let req = ChatReq {
model: cfg.openrouter_model.clone(),
max_tokens: cfg.openrouter_max_tokens,
temperature: cfg.openrouter_temperature,
messages: vec![
Msg {
role: "system".into(),
content: system.to_string(),
},
Msg {
role: "user".into(),
content: user.to_string(),
},
],
};
let res = client
.post(format!("{}/chat/completions", cfg.openrouter_base_url))
.bearer_auth(&cfg.openrouter_api_key)
.header("HTTP-Referer", &cfg.http_referer)
.header("X-Title", &cfg.x_title)
.json(&req)
.send()
.await?;
let status = res.status();
let text = res.text().await?;
let data: ChatRes = serde_json::from_str(&text).map_err(|e| {
anyhow::anyhow!(
"OpenRouter {status}: non-JSON response ({e}): {}",
clip(&text, 300)
)
})?;
if !status.is_success() || data.error.is_some() {
let msg = data
.error
.and_then(|e| e.message)
.unwrap_or_else(|| clip(&text, 500));
anyhow::bail!("OpenRouter {status}: {msg}");
}
data.choices
.and_then(|c| c.into_iter().next())
.and_then(|c| c.message)
.and_then(|m| m.content)
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.ok_or_else(|| anyhow::anyhow!("OpenRouter returned an empty response."))
}
pub async fn answer_question(
cfg: &Config,
backend: &dyn crate::backend::ReviewBackend,
meta: &PrMeta,
diff: &str,
question: &str,
structural_context: Option<&str>,
) -> Result<String> {
let clipped: String = diff.chars().take(cfg.max_diff_chars).collect();
let truncated = diff.chars().count() > cfg.max_diff_chars;
let context = build_user_prompt(meta, &clipped, truncated, None, structural_context);
let user = format!("{context}\n\n--- QUESTION ---\n{}", question.trim());
let system = if cfg.extra_system_prompt.is_empty() {
ASK_SYSTEM_PROMPT.to_string()
} else {
format!("{ASK_SYSTEM_PROMPT}\n{}", cfg.extra_system_prompt)
};
backend.complete(cfg, &system, &user).await
}
pub async fn describe_pr(
cfg: &Config,
backend: &dyn crate::backend::ReviewBackend,
meta: &PrMeta,
diff: &str,
structural_context: Option<&str>,
) -> Result<String> {
let clipped: String = diff.chars().take(cfg.max_diff_chars).collect();
let truncated = diff.chars().count() > cfg.max_diff_chars;
let user = build_user_prompt(meta, &clipped, truncated, None, structural_context);
backend.complete(cfg, DESCRIBE_SYSTEM_PROMPT, &user).await
}
pub async fn review_file(
cfg: &Config,
backend: &dyn crate::backend::ReviewBackend,
path: &str,
content: &str,
) -> Result<Review> {
let numbered: String = content
.lines()
.enumerate()
.map(|(i, l)| format!("{}: {l}", i + 1))
.collect::<Vec<_>>()
.join("\n");
let clipped: String = numbered.chars().take(cfg.max_diff_chars).collect();
let truncated = numbered.chars().count() > cfg.max_diff_chars;
let user = format!(
"File: {path}{}\n\n{clipped}",
if truncated {
"\n[NOTE: file truncated to the size limit — review what is shown]"
} else {
""
}
);
let system = if cfg.extra_system_prompt.is_empty() {
FILE_REVIEW_SYSTEM_PROMPT.to_string()
} else {
format!("{FILE_REVIEW_SYSTEM_PROMPT}\n{}", cfg.extra_system_prompt)
};
let raw = backend.complete(cfg, &system, &user).await?;
let json = extract_json(&raw)
.ok_or_else(|| anyhow::anyhow!("file review returned no JSON: {}", clip(&raw, 300)))?;
let review: Review = serde_json::from_str(json)
.map_err(|e| anyhow::anyhow!("could not parse file review ({e}): {}", clip(json, 300)))?;
Ok(review)
}
#[cfg(test)]
mod tests {
use super::{findings_from_values, Review};
#[test]
fn finding_missing_severity_defaults_to_medium() {
let review: Review = serde_json::from_str(
r#"{"summary":"s","recommendation":"APPROVE WITH CHANGES",
"findings":[{"file":"a.rs","line":11,"body":"b"}]}"#,
)
.expect("a missing severity must not fail the whole review");
assert_eq!(review.findings.len(), 1);
assert_eq!(review.findings[0].severity, "MEDIUM");
}
#[test]
fn finding_missing_file_still_parses_and_folds_to_summary() {
let review: Review = serde_json::from_str(
r#"{"summary":"s","recommendation":"APPROVE",
"findings":[{"severity":"HIGH","body":"b"}]}"#,
)
.expect("a missing file must not fail the whole review");
assert_eq!(review.findings[0].file, "");
assert_eq!(review.findings[0].line, None);
}
#[test]
fn one_malformed_finding_is_dropped_not_fatal() {
let review: Review = serde_json::from_str(
r#"{"summary":"s","recommendation":"BLOCK","findings":[
{"severity":"HIGH","file":"a.rs","line":3,"body":"real"},
{"severity":"LOW","file":"b.rs"},
{"file":"c.rs","body":"also real"}]}"#,
)
.expect("one bad element must not fail the whole review");
assert_eq!(review.findings.len(), 2);
assert_eq!(review.findings[1].severity, "MEDIUM");
}
#[test]
fn required_fields_of_the_review_itself_still_hard_fail() {
assert!(serde_json::from_str::<Review>(r#"{"summary":"s","findings":[]}"#).is_err());
}
#[test]
fn findings_from_values_reports_what_it_dropped() {
let raw = vec![
serde_json::json!({"file": "a.rs", "body": "real"}),
serde_json::json!({"file": "b.rs"}),
serde_json::json!("not even an object"),
];
let (kept, dropped) = findings_from_values(raw);
assert_eq!(kept.len(), 1);
assert_eq!(dropped, 2);
}
}