feat(ai): generate PR drafts and consolidate AI settings UI

Add pull request draft generation to the commit_ai crate and expose it
via a new Tauri command. The backend builds a branch-range context from
published remote-tracking refs only, calls the chosen AI provider, and
parses a JSON {"title","description"} draft (with validation). Also
register the command in the app and add unit tests for parsing and the
branch-context behavior.

Consolidate AI settings in the frontend by renaming the dialog to an
AiSettingsPage and integrating AI options into the main AppSettings
dialog. Persisted AI preferences are merged with existing localStorage
rather than replacing it, and the settings UI now supports opening the
app settings to a specific initial page ("integrations" or "ai").

Other changes:
- Replace the commit-message system prompt used by build_messages with
  the updated, more detailed guidance text.
This commit is contained in:
2026-09-11 22:34:21 +02:00
parent 007dc99447
commit 4b5de5a88b
10 changed files with 273 additions and 102 deletions
+47
View File
@@ -2248,6 +2248,30 @@ pub async fn commit_ai_generate(
}
}
fn pull_request_ai_context(repo: &Path, remote: &str, source_branch: &str, target_branch: &str) -> Result<String, String> {
// Use published remote-tracking refs, never staged or unpushed changes.
let source = verify_commit(&repo, &format!("refs/remotes/{remote}/{source_branch}"))?;
let target = verify_commit(&repo, &format!("refs/remotes/{remote}/{target_branch}"))?;
let range = format!("{target}...{source}");
let diff = run_git(&repo, ["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=3", &range, "--"])?;
if diff.is_empty() { return Err("No changes between the selected branches. Fetch the repository and try again.".into()); }
let commits = run_git(&repo, ["log", "-n", "100", "--format=%s", &format!("{target}..{source}"), "--"])?;
Ok(format!("Source: {source_branch}\nTarget: {target_branch}\nCommit summaries:\n{}\nChanges:\n{}", String::from_utf8_lossy(&commits), String::from_utf8_lossy(&diff)))
}
#[tauri::command]
pub async fn pull_request_ai_generate(
path: String, remote: String, source_branch: String, target_branch: String,
provider: String, model: String, api_key: Option<String>, base_url: Option<String>, language: String,
) -> Result<commit_ai::PullRequestDraft, String> {
if model.trim().is_empty() { return Err("Model name is missing.".into()); }
let context = run_git_task("Could not read pull request changes", move || {
let repo = resolve_repo(&path)?;
pull_request_ai_context(&repo, &remote, &source_branch, &target_branch)
}).await?;
commit_ai::generate_pull_request(&provider, &model, api_key.as_deref(), base_url.as_deref(), &context, &language).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AiReviewRisk {
@@ -10139,6 +10163,29 @@ mod tests {
assert_eq!(index.get("missing"), None);
}
#[test]
fn pull_request_context_uses_published_branch_range_only() {
let repo = init_temp_repo("pr_ai_context");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["update-ref", "refs/remotes/origin/main", "HEAD"]);
fs::write(repo.path.join("published.txt"), "published change\n").unwrap();
run_git_test(&repo.path, ["add", "published.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "Published feature"]);
run_git_test(&repo.path, ["update-ref", "refs/remotes/origin/feature", "HEAD"]);
fs::write(repo.path.join("unpublished.txt"), "unpublished change\n").unwrap();
run_git_test(&repo.path, ["add", "unpublished.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "Unpublished feature"]);
fs::write(repo.path.join("dirty.txt"), "working tree change\n").unwrap();
run_git_test(&repo.path, ["add", "dirty.txt"]);
let context = pull_request_ai_context(&repo.path, "origin", "feature", "main").unwrap();
assert!(context.contains("published.txt"));
assert!(context.contains("Published feature"));
assert!(!context.contains("unpublished.txt"));
assert!(!context.contains("dirty.txt"));
assert!(pull_request_ai_context(&repo.path, "origin", "main", "main").is_err());
assert!(pull_request_ai_context(&repo.path, "origin", "missing", "main").is_err());
}
#[test]
fn repository_files_include_tracked_deleted_and_untracked_entries() {
let repo = init_temp_repo("repository_files");