From 82c47c8d03644403ed091ba3dcc11d13b544b4d7 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 13 Jul 2026 00:03:11 +0200 Subject: [PATCH] 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 --- src-tauri/crates/commit_ai/src/cloud.rs | 120 +++++++++++++++++- src-tauri/crates/commit_ai/src/lib.rs | 29 ++++- src-tauri/src/git.rs | 152 +++++++++++++++++++++++ src-tauri/src/main.rs | 19 +-- src/App.svelte | 63 ++++++++++ src/app.css | 102 +++++++++++++++ src/lib/components/AiReviewDialog.svelte | 94 ++++++++++++++ src/lib/components/CommitPanel.svelte | 18 ++- src/lib/git.ts | 11 ++ src/lib/types.ts | 18 +++ 10 files changed, 614 insertions(+), 12 deletions(-) create mode 100644 src/lib/components/AiReviewDialog.svelte diff --git a/src-tauri/crates/commit_ai/src/cloud.rs b/src-tauri/crates/commit_ai/src/cloud.rs index 6656f0d..fa5d53e 100644 --- a/src-tauri/crates/commit_ai/src/cloud.rs +++ b/src-tauri/crates/commit_ai/src/cloud.rs @@ -2,7 +2,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::{build_messages, looks_like_diff_echo, sanitize_message}; +use crate::{build_messages, build_review_messages, looks_like_diff_echo, sanitize_message}; // Generous sizing so a detailed body with bullet points isn't cut off. const DEFAULT_MAX_TOKENS: u32 = 1500; @@ -139,6 +139,82 @@ pub async fn generate_custom( openai_compatible_request(url, api_key, model, diff, notes).await } +async fn openai_compatible_review_request( + url: String, + bearer: Option<&str>, + model: &str, + diff: &str, +) -> Result { + let (system, user) = build_review_messages(diff)?; + let body = OpenAiRequest { + model: model.to_string(), + messages: vec![ + OpenAiMessage { + role: "system", + content: system, + }, + OpenAiMessage { + role: "user", + content: user, + }, + ], + temperature: 0.1, + }; + let client = http_client()?; + let mut request = client.post(url).json(&body); + if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) { + request = request.bearer_auth(key); + } + let response = request + .send() + .await + .map_err(|err| format!("Request to the AI model failed: {err}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|err| format!("Could not read response: {err}"))?; + if !status.is_success() { + return Err(format!("API error ({status}): {text}")); + } + let parsed: OpenAiResponse = + serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?; + parsed + .choices + .into_iter() + .next() + .and_then(|choice| choice.message.content) + .map(|content| sanitize_message(&content)) + .filter(|content| !content.is_empty()) + .ok_or_else(|| "The model did not return a review.".to_string()) +} + +pub async fn review_openai(api_key: &str, model: &str, diff: &str) -> Result { + if api_key.trim().is_empty() { + return Err("OpenAI API key is missing.".to_string()); + } + openai_compatible_review_request( + "https://api.openai.com/v1/chat/completions".to_string(), + Some(api_key), + model, + diff, + ) + .await +} + +pub async fn review_custom( + base_url: &str, + api_key: Option<&str>, + model: &str, + diff: &str, +) -> Result { + if base_url.trim().is_empty() { + return Err("Endpoint URL is missing.".to_string()); + } + let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); + openai_compatible_review_request(url, api_key, model, diff).await +} + #[derive(Serialize)] struct AnthropicMessage { role: &'static str, @@ -220,3 +296,45 @@ pub async fn generate_anthropic( } Ok(message) } + +pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result { + if api_key.trim().is_empty() { + return Err("Anthropic API key is missing.".to_string()); + } + let (system, user) = build_review_messages(diff)?; + let body = AnthropicRequest { + model: model.to_string(), + max_tokens: 2400, + system, + messages: vec![AnthropicMessage { + role: "user", + content: user, + }], + }; + let client = http_client()?; + let response = client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .json(&body) + .send() + .await + .map_err(|err| format!("Request to Anthropic failed: {err}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|err| format!("Could not read response: {err}"))?; + if !status.is_success() { + return Err(format!("API error ({status}): {text}")); + } + let parsed: AnthropicResponse = + serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?; + parsed + .content + .into_iter() + .find_map(|block| block.text) + .map(|text| sanitize_message(&text)) + .filter(|text| !text.is_empty()) + .ok_or_else(|| "The model did not return a review.".to_string()) +} diff --git a/src-tauri/crates/commit_ai/src/lib.rs b/src-tauri/crates/commit_ai/src/lib.rs index 19a20ae..915b694 100644 --- a/src-tauri/crates/commit_ai/src/lib.rs +++ b/src-tauri/crates/commit_ai/src/lib.rs @@ -1,6 +1,9 @@ mod cloud; -pub use cloud::{generate_anthropic, generate_custom, generate_openai}; +pub use cloud::{ + generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom, + review_openai, +}; use std::{ collections::hash_map::DefaultHasher, @@ -443,3 +446,27 @@ instead of a direct string comparison.\n\n\ user.push_str(&format!("Staged diff:\n{diff}")); Ok((system, user)) } + +pub(crate) fn build_review_messages(diff: &str) -> Result<(String, String), String> { + if diff.trim().is_empty() { + return Err("No staged changes available for review.".to_string()); + } + + const MAX_CHARS: usize = 36_000; + let diff = truncate_at_char_boundary(diff, MAX_CHARS); + let system = r#"You are a senior software engineer performing a focused pre-commit review. +Review only the supplied staged Git diff. Look for concrete correctness bugs, security issues, +data loss, regressions, broken edge cases, unsafe error handling, and meaningful performance or +maintainability risks. Do not report formatting preferences or speculative nitpicks. + +Return ONLY valid JSON with this exact shape: +{"summary":"one concise overall assessment","risk":"low|medium|high","findings":[{"severity":"critical|warning|info","title":"short title","description":"clear evidence and impact","file":"path or null","line":123,"suggestion":"specific safe next step"}]} + +Use the new-file line number from the diff when it is known; otherwise use null. Use null for file +when the issue is repository-wide. Maximum 12 findings, ordered critical then warning then info. +If no actionable issue exists, return an empty findings array and risk low. Never use markdown, +code fences, commentary outside the JSON, or claim that tests were executed."# + .to_string(); + let user = format!("Staged diff to review:\n{diff}"); + Ok((system, user)) +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 4179308..c0d13fd 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -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, + pub line: Option, + 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, +} + +#[derive(Deserialize)] +struct AiReviewWireFinding { + severity: String, + title: String, + description: String, + file: Option, + line: Option, + suggestion: String, +} + +#[derive(Deserialize)] +struct AiReviewWireResult { + summary: String, + risk: String, + #[serde(default)] + findings: Vec, +} + +fn parse_ai_review(raw: &str) -> Result { + 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::>(); + 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, + api_key: Option, + base_url: Option, +) -> Result { + 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)); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a49598a..0ff83c3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,14 +8,14 @@ use git::{ SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, - commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head, - compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, - delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame, - get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits, - list_file_history, list_interactive_rebase_commits, list_reflog, list_repository_files, - list_stashes, list_tags, merge_branch, open_repo_in_explorer, open_repository, - open_repository_bundle, open_repository_file, pull, push, push_tag, read_conflict, - rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict, + commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits, + compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete, + cred_load, cred_save, delete_branch, delete_tag, diff_file_against_working_tree, fetch, + get_file_blame, get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, + list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, + list_repository_files, list_stashes, list_tags, merge_branch, open_repo_in_explorer, + open_repository, open_repository_bundle, open_repository_file, pull, push, push_tag, + read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, @@ -53,7 +53,7 @@ async fn main() { return; } - let builder =tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _, _| { #[cfg(desktop)] let _ = app @@ -116,6 +116,7 @@ async fn main() { commit_ai_load, commit_ai_local_models, commit_ai_generate, + commit_ai_review, pull, push, fetch, diff --git a/src/App.svelte b/src/App.svelte index 30f38b7..177b531 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -10,6 +10,7 @@ import RepoToolbar from "./lib/RepoToolbar.svelte"; import RepoTabs from "./lib/RepoTabs.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; + import AiReviewDialog from "./lib/components/AiReviewDialog.svelte"; import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte"; import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; @@ -46,6 +47,7 @@ cloneRepository, commit, commitAiGenerate, + commitAiReview, commitAiLoad, commitAiLocalModels, commitAiStatus, @@ -107,6 +109,7 @@ } from "./lib/git"; import type { + AiReviewResult, AiSettings, AppLanguage, AppTheme, @@ -250,6 +253,9 @@ let lastLocalAiGeneratedMessage = ""; let commitAiPhase: CommitAiPhase = "idle"; let commitAiGenerating = false; + let commitAiReviewing = false; + let aiReviewResult: AiReviewResult | null = null; + let aiReviewOpen = false; let commitAiPollTimer: ReturnType | undefined; let aiSettings: AiSettings = defaultAiSettings(); let aiSettingsOpen = false; @@ -931,6 +937,51 @@ } } + async function reviewStagedWithAi() { + if (!activeRepoPath || commitAiReviewing || stagedCount === 0) return; + if (aiSettings.provider === "local") { + errorMessage = "Pre-commit review currently requires OpenAI, Anthropic, or a custom endpoint."; + return; + } + commitAiReviewing = true; + errorMessage = ""; + try { + if (aiSettings.provider === "openai") { + const cred = await credLoad("ai:openai"); + aiReviewResult = await commitAiReview(activeRepoPath, { + provider: "openai", + model: aiSettings.openaiModel, + apiKey: cred?.password, + }); + } else if (aiSettings.provider === "anthropic") { + const cred = await credLoad("ai:anthropic"); + aiReviewResult = await commitAiReview(activeRepoPath, { + provider: "anthropic", + model: aiSettings.anthropicModel, + apiKey: cred?.password, + }); + } else { + const cred = await credLoad("ai:custom"); + aiReviewResult = await commitAiReview(activeRepoPath, { + provider: "custom", + model: aiSettings.customModel, + baseUrl: aiSettings.customBaseUrl, + apiKey: cred?.password, + }); + } + aiReviewOpen = true; + trackEvent("ai_precommit_review", { + provider: aiSettings.provider, + findings: aiReviewResult.findings.length, + risk: aiReviewResult.risk, + }); + } catch (error) { + errorMessage = errorToMessage(error); + } finally { + commitAiReviewing = false; + } + } + function toggleAutoRefresh() { autoRefreshEnabled = !autoRefreshEnabled; if (autoRefreshEnabled) void autoRefreshTick(); @@ -4012,11 +4063,13 @@ commitAiProvider={aiSettings.provider} {commitAiPhase} {commitAiGenerating} + {commitAiReviewing} {canAmend} {amendMode} onCommit={commitChanges} onCommitMessageChange={updateCommitMessage} onGenerateCommitMessage={generateCommitMessageWithAi} + onReviewStaged={reviewStagedWithAi} onOpenAiSettings={() => { aiSettingsOpen = true; }} onToggleAmend={toggleAmendMode} onUndoLastCommit={undoLastCommitChange} @@ -4147,6 +4200,16 @@ { helpOpen = false; }} /> {/if} +{#if aiReviewOpen && aiReviewResult} + { aiReviewOpen = false; }} + /> +{/if} + {#if linePatchOpen && linePatchFile} strong { color: var(--color-ink); font-size: 14px; } +.ai-review-summary p { margin: 5px 0 0; color: var(--color-ink-muted); font-size: 12.5px; line-height: 1.45; } +.ai-review-risk { + padding: 2px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 850; + letter-spacing: .06em; + text-transform: uppercase; +} +.ai-review-risk.low { color: #2da44e; background: color-mix(in srgb, #2da44e 12%, transparent); } +.ai-review-risk.medium { color: #b96500; background: color-mix(in srgb, #d9891b 13%, transparent); } +.ai-review-risk.high { color: #d1242f; background: color-mix(in srgb, #d1242f 12%, transparent); } +.ai-review-findings { min-height: 0; padding: 10px; overflow: auto; background: var(--app-dialog-bg); } +.ai-review-finding { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 8px; + padding: 12px; + border: 1px solid var(--color-border-subtle); + border-left: 3px solid var(--color-border); + border-radius: 7px; + background: var(--color-surface-solid); +} +.ai-review-finding + .ai-review-finding { margin-top: 8px; } +.ai-review-finding.critical { border-left-color: #d1242f; } +.ai-review-finding.warning { border-left-color: #d9891b; } +.ai-review-finding.info { border-left-color: var(--color-primary); } +.ai-review-finding-icon { display: grid; place-items: start center; padding-top: 1px; color: var(--color-primary); } +.ai-review-finding.critical .ai-review-finding-icon { color: #d1242f; } +.ai-review-finding.warning .ai-review-finding-icon { color: #d9891b; } +.ai-review-finding-title { display: flex; align-items: center; gap: 7px; } +.ai-review-finding-title span { color: var(--color-ink-faint); font-size: 9px; font-weight: 850; letter-spacing: .07em; text-transform: uppercase; } +.ai-review-finding-title strong { color: var(--color-ink); font-size: 12.5px; } +.ai-review-finding-body > p { margin: 6px 0 8px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; } +.ai-review-location { display: flex; align-items: center; gap: 5px; color: var(--color-primary); } +.ai-review-location code { overflow-wrap: anywhere; font: 10.5px/1.4 var(--font-mono); } +.ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); } +.ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; } +.ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; } +.ai-review-clean-state { display: grid; place-items: center; align-content: center; min-height: 280px; gap: 8px; color: #2da44e; text-align: center; } +.ai-review-clean-state strong { color: var(--color-ink); font-size: 14px; } +.ai-review-clean-state span { color: var(--color-ink-faint); font-size: 12px; } +.ai-review-footer p { margin: 0; color: var(--color-ink-faint); font-size: 10.5px; } +.ai-review-footer > div { display: flex; gap: 7px; } + +@media (max-width: 720px) { + .commit-head-actions .pill-count { display: none; } + .commit-review-button, + .commit-generate-button { padding-inline: 8px; } + .ai-review-dialog { width: calc(100vw - 16px); height: calc(100vh - 16px); } + .ai-review-footer { align-items: stretch; flex-direction: column; } + .ai-review-footer > div { justify-content: flex-end; } +} diff --git a/src/lib/components/AiReviewDialog.svelte b/src/lib/components/AiReviewDialog.svelte new file mode 100644 index 0000000..ef7c7b8 --- /dev/null +++ b/src/lib/components/AiReviewDialog.svelte @@ -0,0 +1,94 @@ + + + { if (event.key === "Escape" && !isReviewing) onClose(); }} /> + + diff --git a/src/lib/components/CommitPanel.svelte b/src/lib/components/CommitPanel.svelte index 91c9e1b..c73efaf 100644 --- a/src/lib/components/CommitPanel.svelte +++ b/src/lib/components/CommitPanel.svelte @@ -1,5 +1,5 @@
@@ -74,6 +80,16 @@
{stagedCount} staged +