From ee35169feb2568b864da6641e374565697dc8cd6 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 8 Sep 2026 21:41:29 +0200 Subject: [PATCH] feat(create-review): load repository branches and suggest local branch Add a backend command to enumerate a repository's branches and default branch for configured integrations, and expose it to the UI. The create review dialog now fetches branches, shows loading and error states, and uses searchable SelectMenu controls for repositories and branches. If a local repository path is available the dialog will try to match remotes and preselect a local branch that exists on the remote to streamline review creation. - Add integration branch listing command and wire it into the dialog - Replace plain selects with searchable SelectMenu and improved UX - Attempt to detect and suggest a matching local source branch when possible --- src-tauri/src/integrations.rs | 2 +- src-tauri/src/integrations/creation.rs | 63 +++++++++++++++++++ src-tauri/src/main.rs | 3 +- src/App.svelte | 1 + src/lib/components/CreateReviewDialog.svelte | 66 +++++++++++++++++--- src/lib/components/ReviewCenter.svelte | 5 +- src/lib/components/SelectMenu.svelte | 52 +++++++++++---- src/lib/git.ts | 4 ++ 8 files changed, 174 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index 6b9e946..5443c00 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use std::time::Duration; mod creation; -pub use creation::create_integration_review_request; +pub use creation::{create_integration_review_request, list_integration_repository_branches}; const PAGE_SIZE: usize = 100; const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35); diff --git a/src-tauri/src/integrations/creation.rs b/src-tauri/src/integrations/creation.rs index 3f180c7..6647fe7 100644 --- a/src-tauri/src/integrations/creation.rs +++ b/src-tauri/src/integrations/creation.rs @@ -103,3 +103,66 @@ mod tests { assert_eq!(creation_endpoint("azure-devops", "https://dev.azure.com/org", &repository).unwrap().as_str(), "https://dev.azure.com/org/_apis/git/repositories/17/pullrequests?api-version=7.1"); } } + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RepositoryBranches { + branches: Vec, + default_branch: String, +} + +#[tauri::command] +pub async fn list_integration_repository_branches(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); } + let client = client()?; + let mut metadata_url = creation_endpoint(&provider, &base_url, &repository)?; + metadata_url.path_segments_mut().map_err(|_| "Invalid repository URL")?.pop(); + let get = |url: reqwest::Url| -> Result { + let request = client.get(url).header(USER_AGENT, "Gitty").header(ACCEPT, "application/json"); + let request = match provider.as_str() { + "github" => request.bearer_auth(&token), + "gitea" => request.header("Authorization", format!("token {token}")), + "gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", &token), + "azure-devops" => request.basic_auth(&username, Some(&token)), + _ => return Err("Unsupported integration provider.".into()), + }; + let response = request.send().map_err(|e| format!("Could not load branches: {e}"))?; + if !response.status().is_success() { return Err(response_error(response, &provider)); } + Ok(response) + }; + let metadata: serde_json::Value = get(metadata_url.clone())?.json().map_err(|e| format!("Invalid repository response: {e}"))?; + let default_branch = value_string(&metadata, &[if provider == "azure-devops" { "defaultBranch" } else { "default_branch" }]).trim_start_matches("refs/heads/").to_string(); + let mut branches = BTreeSet::new(); + let mut continuation = String::new(); + for page in 1..=1000 { + let mut url = metadata_url.clone(); + { + let mut path = url.path_segments_mut().map_err(|_| "Invalid repository URL")?; + if provider.starts_with("gitlab") { path.push("repository"); } + path.push(if provider == "azure-devops" { "refs" } else { "branches" }); + } + if provider == "azure-devops" { + url.query_pairs_mut().append_pair("filter", "heads/").append_pair("$top", "100"); + if !continuation.is_empty() { url.query_pairs_mut().append_pair("continuationToken", &continuation); } + } else { + url.query_pairs_mut().append_pair("page", &page.to_string()).append_pair(if provider == "gitea" { "limit" } else { "per_page" }, "100"); + } + let response = get(url)?; + let next = response.headers().get("x-ms-continuationtoken").and_then(|h| h.to_str().ok()).unwrap_or_default().to_string(); + let data: serde_json::Value = response.json().map_err(|e| format!("Invalid branch response: {e}"))?; + let items = if provider == "azure-devops" { data.get("value") } else { Some(&data) }.and_then(serde_json::Value::as_array).ok_or("Invalid branch list.")?; + for item in items { + let name = value_string(item, &["name"]); + let name = if provider == "azure-devops" { name.strip_prefix("refs/heads/").unwrap_or(&name) } else { &name }; + if !name.is_empty() { branches.insert(name.to_string()); } + } + if (provider == "azure-devops" && next.is_empty()) || (provider != "azure-devops" && items.len() < 100) { + return Ok(RepositoryBranches { branches: branches.into_iter().collect(), default_branch }); + } + if provider == "azure-devops" && next == continuation { return Err("The server repeated its branch pagination token.".into()); } + continuation = next; + } + Err("The repository has too many branches to load completely.".into()) + }).await.map_err(|e| format!("Could not load branches: {e}"))? +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 67afd1e..4856721 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -34,7 +34,7 @@ use git::{ unstage_files, untrack_paths, update_remote, }; use integrations::{ - create_integration_review_request, add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, + list_integration_repository_branches, create_integration_review_request, add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, run_integration_review_action, }; use std::path::{Path, PathBuf}; @@ -437,6 +437,7 @@ async fn main() { cred_load, cred_save, cred_delete, + list_integration_repository_branches, create_integration_review_request, list_integration_repositories, list_integration_review_requests, diff --git a/src/App.svelte b/src/App.svelte index d91a7b8..2fe680d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5568,6 +5568,7 @@ /> {:else if activeView === "review-center"} + import SelectMenu from "./SelectMenu.svelte"; import { onMount } from "svelte"; import { GitPullRequest, X, LoaderCircle, ArrowRight } from "@lucide/svelte"; - import { createIntegrationReviewRequest, listIntegrationRepositories } from "../git"; + import { createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git"; import { integrationCredentialKey } from "../integrations"; import type { GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types"; - let { source, de, loadCredential, onClose, onCreated }: { - source: GitIntegrationSource; de: boolean; + let { source, de, localRepositoryPath = "", loadCredential, onClose, onCreated }: { + source: GitIntegrationSource; de: boolean; localRepositoryPath?: string; loadCredential: (key: string) => Promise; onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void; } = $props(); @@ -15,6 +16,49 @@ let repositoryId = $state(""); let sourceBranch = $state(""); let targetBranch = $state(""); + let branches = $state([]); + let defaultBranch = $state(""); + let branchesLoading = $state(false); + let branchError = $state(""); + let branchGeneration = 0; + const branchOptions = $derived(branches.map(name => ({value:name,label:name,group:name === defaultBranch ? (de ? "Standardbranch" : "Default branch") : undefined}))); + $effect(() => { + const repository = repositories.find(item => item.id === repositoryId); + void loadRepositoryBranches(repository); + }); + async function loadRepositoryBranches(repository?: GitIntegrationRepository) { + const generation = ++branchGeneration; + branches = []; sourceBranch = ""; targetBranch = ""; defaultBranch = ""; branchError = ""; + branchesLoading = !!repository; + if (!repository) return; + try { + const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId)); + if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored."); + const result = await listIntegrationRepositoryBranches(source.provider, source.baseUrl, credential.username, credential.password, repository); + if (generation !== branchGeneration) return; + branches = result.branches; + defaultBranch = result.defaultBranch; + targetBranch = branches.includes(defaultBranch) ? defaultBranch : ""; + // Only suggest a local branch when its remote matches this exact repository. + if (localRepositoryPath) { + try { + const remotes = await listRemotes(localRepositoryPath); + const clean = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, ""); + const matches = remotes.some(remote => [repository.cloneUrl, repository.sshUrl].filter(Boolean).some(url => clean(url) === clean(remote.fetch_url) || clean(url) === clean(remote.push_url))); + if (matches) { + const localBranches = await listBranches(localRepositoryPath); + if (generation !== branchGeneration) return; + const current = localBranches.find(branch => branch.current)?.name; + if (current && branches.includes(current) && current !== targetBranch) sourceBranch = current; + } + } catch { /* Remote branch selection remains available without local metadata. */ } + } + if (generation !== branchGeneration) return; + if (!sourceBranch && branches.length === 2 && targetBranch) sourceBranch = branches.find(name => name !== targetBranch) ?? ""; + } catch (cause) { + if (generation === branchGeneration) branchError = String(cause); + } finally { if (generation === branchGeneration) branchesLoading = false; } + } let title = $state(""); let description = $state(""); let loading = $state(true); @@ -24,7 +68,7 @@ const heading = $derived(de ? (mr ? "Merge Request erstellen" : "Pull Request erstellen") : (mr ? "Create merge request" : "Create pull request")); const normalizeBranch = (branch: string) => branch.trim().replace(/^refs\/heads\//, ""); const sameBranch = $derived(!!sourceBranch.trim() && normalizeBranch(sourceBranch) === normalizeBranch(targetBranch)); - const valid = $derived(repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch); + const valid = $derived(!branchesLoading && !branchError && branches.includes(sourceBranch) && branches.includes(targetBranch) && repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch); onMount(() => { dialog.showModal(); void loadRepositories(); }); async function loadRepositories() { @@ -56,9 +100,16 @@

{heading}

{source.label}

{#if error}{/if} - +
Repository{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}
+ ({value:repository.id,label:repository.fullName}))} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} onChange={value => repositoryId = value}/> +
{#if !loading && !error && !repositories.length}

{de ? "Keine Repositories für diese Integration gefunden." : "No repositories found for this integration."}

{/if} -
+
+
{de ? "Quellbranch" : "Source branch"} ({...option,disabled:option.value === targetBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Quellbranch" : "Source branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Quellbranch auswählen" : "Select source branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => sourceBranch = value}/>
+ +
{de ? "Zielbranch" : "Target branch"} ({...option,disabled:option.value === sourceBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Zielbranch" : "Target branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Zielbranch auswählen" : "Select target branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => targetBranch = value}/>
+
+ {#if branchError}{:else if repositoryId && !branchesLoading && !branches.length}

{de ? "Dieses Repository hat noch keine Branches." : "This repository has no branches yet."}

{/if} {#if sameBranch}

{de ? "Quell- und Zielbranch müssen unterschiedlich sein." : "Source and target branches must be different."}

{/if}

{de ? "Beide Branches müssen bereits in diesem Repository gepusht sein." : "Both branches must already be pushed to this repository."}

@@ -69,5 +120,6 @@ diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte index 8189a50..1d44c83 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -17,6 +17,7 @@ interface Props { language: AppLanguage; + localRepositoryPath?: string; integrations: GitIntegrationSettings; initialQuery?: string; initialSourceId?: string; @@ -32,7 +33,7 @@ onPushLocalResolution?: () => void | Promise; } - let { language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props(); + let { localRepositoryPath = "", language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props(); let createOpen = $state(false); let requests = $state([]); let loading = $state(false); @@ -401,7 +402,7 @@ {#if createOpen && activeSource} - createOpen = false} onCreated={(request) => { + createOpen = false} onCreated={(request) => { ++loadGeneration; loading = false; requests = [request, ...requests.filter(item => item.id !== request.id)]; diff --git a/src/lib/components/SelectMenu.svelte b/src/lib/components/SelectMenu.svelte index 25dd35f..5323dc8 100644 --- a/src/lib/components/SelectMenu.svelte +++ b/src/lib/components/SelectMenu.svelte @@ -1,6 +1,6 @@