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 +