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 @@