feat(ai): Add comprehensive pre-commit AI code review
Introduces a robust system for running automated, staged diff reviews against various large language models. This feature allows users to submit their changes to external AI services and receive structured feedback on potential bugs, security issues, and maintainability risks before committing. The implementation covers the entire stack: * Backend logic was added to handle API communication with OpenAI, Anthropic, and custom endpoints. * A dedicated parser ensures that complex JSON outputs from LLMs are reliably converted into structured findings (severity, title, description). * New components and UI elements provide a clear visualization of the AI's assessment and actionable suggestions. - Supports multiple major LLM providers (OpenAI, Anthropic) - Parses structured JSON output for consistent review results - Adds dedicated UI dialog to display AI findings and risk level
This commit is contained in:
@@ -1145,6 +1145,143 @@ pub async fn commit_ai_generate(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AiReviewRisk {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AiReviewSeverity {
|
||||
Critical,
|
||||
Warning,
|
||||
Info,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiReviewFinding {
|
||||
pub severity: AiReviewSeverity,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub file: Option<String>,
|
||||
pub line: Option<u32>,
|
||||
pub suggestion: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiReviewResult {
|
||||
pub summary: String,
|
||||
pub risk: AiReviewRisk,
|
||||
pub findings: Vec<AiReviewFinding>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AiReviewWireFinding {
|
||||
severity: String,
|
||||
title: String,
|
||||
description: String,
|
||||
file: Option<String>,
|
||||
line: Option<u32>,
|
||||
suggestion: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AiReviewWireResult {
|
||||
summary: String,
|
||||
risk: String,
|
||||
#[serde(default)]
|
||||
findings: Vec<AiReviewWireFinding>,
|
||||
}
|
||||
|
||||
fn parse_ai_review(raw: &str) -> Result<AiReviewResult, String> {
|
||||
let trimmed = raw.trim().trim_matches('`').trim();
|
||||
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
|
||||
(Some(start), Some(end)) if start <= end => &trimmed[start..=end],
|
||||
_ => return Err("The AI review did not contain valid JSON.".to_string()),
|
||||
};
|
||||
let wire: AiReviewWireResult = serde_json::from_str(json)
|
||||
.map_err(|error| format!("Could not process the AI review: {error}"))?;
|
||||
let risk = match wire.risk.trim().to_ascii_lowercase().as_str() {
|
||||
"high" => AiReviewRisk::High,
|
||||
"medium" => AiReviewRisk::Medium,
|
||||
_ => AiReviewRisk::Low,
|
||||
};
|
||||
let mut findings = wire
|
||||
.findings
|
||||
.into_iter()
|
||||
.filter(|finding| {
|
||||
!finding.title.trim().is_empty() && !finding.description.trim().is_empty()
|
||||
})
|
||||
.map(|finding| AiReviewFinding {
|
||||
severity: match finding.severity.trim().to_ascii_lowercase().as_str() {
|
||||
"critical" | "error" | "high" => AiReviewSeverity::Critical,
|
||||
"warning" | "warn" | "medium" => AiReviewSeverity::Warning,
|
||||
_ => AiReviewSeverity::Info,
|
||||
},
|
||||
title: finding.title.trim().to_string(),
|
||||
description: finding.description.trim().to_string(),
|
||||
file: finding.file.filter(|file| !file.trim().is_empty()),
|
||||
line: finding.line,
|
||||
suggestion: finding.suggestion.trim().to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
findings.sort_by_key(|finding| match finding.severity {
|
||||
AiReviewSeverity::Critical => 0,
|
||||
AiReviewSeverity::Warning => 1,
|
||||
AiReviewSeverity::Info => 2,
|
||||
});
|
||||
findings.truncate(12);
|
||||
Ok(AiReviewResult {
|
||||
summary: if wire.summary.trim().is_empty() {
|
||||
"Review completed.".to_string()
|
||||
} else {
|
||||
wire.summary.trim().to_string()
|
||||
},
|
||||
risk,
|
||||
findings,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn commit_ai_review(
|
||||
path: String,
|
||||
provider: String,
|
||||
model: Option<String>,
|
||||
api_key: Option<String>,
|
||||
base_url: Option<String>,
|
||||
) -> Result<AiReviewResult, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let diff = staged_diff(&repo)?;
|
||||
let model = model.filter(|value| !value.trim().is_empty());
|
||||
let api_key = api_key.filter(|value| !value.trim().is_empty());
|
||||
let base_url = base_url.filter(|value| !value.trim().is_empty());
|
||||
let raw = match provider.as_str() {
|
||||
"openai" => {
|
||||
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
|
||||
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
|
||||
commit_ai::review_openai(&api_key, &model, &diff).await?
|
||||
}
|
||||
"anthropic" => {
|
||||
let api_key = api_key.ok_or_else(|| "Anthropic API key is missing.".to_string())?;
|
||||
let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string());
|
||||
commit_ai::review_anthropic(&api_key, &model, &diff).await?
|
||||
}
|
||||
"custom" => {
|
||||
let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".to_string())?;
|
||||
let model = model.ok_or_else(|| "Model name is missing.".to_string())?;
|
||||
commit_ai::review_custom(&base_url, api_key.as_deref(), &model, &diff).await?
|
||||
}
|
||||
"local" => return Err("Pre-commit review currently requires an API provider.".to_string()),
|
||||
other => return Err(format!("Unknown AI provider: {other}")),
|
||||
};
|
||||
parse_ai_review(&raw)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn apply_file_patch(
|
||||
path: String,
|
||||
@@ -6213,4 +6350,19 @@ mod tests {
|
||||
assert_eq!(result.lines.len(), 1);
|
||||
assert!(result.lines[0].is_uncommitted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() {
|
||||
let raw = r#"```json
|
||||
{"summary":"One issue found","risk":"HIGH","findings":[{"severity":"warn","title":"Unchecked result","description":"The new call ignores an error.","file":"src/main.rs","line":42,"suggestion":"Propagate the error."}]}
|
||||
```"#;
|
||||
|
||||
let review = parse_ai_review(raw).expect("review JSON should parse");
|
||||
|
||||
assert_eq!(review.risk, AiReviewRisk::High);
|
||||
assert_eq!(review.findings.len(), 1);
|
||||
assert_eq!(review.findings[0].severity, AiReviewSeverity::Warning);
|
||||
assert_eq!(review.findings[0].file.as_deref(), Some("src/main.rs"));
|
||||
assert_eq!(review.findings[0].line, Some(42));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user