diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index f60ed0a..118fcb8 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -14,11 +14,14 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::time::Duration; +mod creation; +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); const AZURE_PROJECT_WORKERS: usize = 8; -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct IntegrationRepository { pub id: String, diff --git a/src-tauri/src/integrations/creation.rs b/src-tauri/src/integrations/creation.rs new file mode 100644 index 0000000..6647fe7 --- /dev/null +++ b/src-tauri/src/integrations/creation.rs @@ -0,0 +1,168 @@ +use super::*; + +fn creation_payload(provider: &str, source: &str, target: &str, title: &str, description: &str) -> Result { + if title.trim().is_empty() || source.trim().is_empty() || target.trim().is_empty() { + return Err("Title, source branch and target branch are required.".into()); + } + if source == target { return Err("Source and target branch must be different.".into()); } + Ok(match provider { + "github" | "gitea" => serde_json::json!({"head":source,"base":target,"title":title,"body":description}), + "gitlab" | "gitlab-self-hosted" => serde_json::json!({"source_branch":source,"target_branch":target,"title":title,"description":description}), + "azure-devops" => serde_json::json!({"sourceRefName":format!("refs/heads/{source}"),"targetRefName":format!("refs/heads/{target}"),"title":title,"description":description}), + _ => return Err("Unsupported integration provider.".into()), + }) +} + +fn creation_endpoint(provider: &str, base: &str, repository: &IntegrationRepository) -> Result { + let base = if provider == "github" { github_api_base_url(base)? } else { normalized_base_url(base)? }; + let mut url = reqwest::Url::parse(&format!("{base}/")).map_err(|e| e.to_string())?; + { + let mut path = url.path_segments_mut().map_err(|_| "Invalid integration URL.")?; + path.pop_if_empty(); + match provider { + "github" | "gitea" => { + let parts: Vec<_> = repository.full_name.split('/').collect(); + if parts.len() != 2 || parts.iter().any(|part| part.is_empty() || *part == "." || *part == "..") { + return Err("Invalid repository name.".into()); + } + if provider == "gitea" { path.extend(["api", "v1"]); } + path.push("repos").extend(parts).push("pulls"); + } + "gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects", &repository.id,"merge_requests"]); } + "azure-devops" => { path.extend(["_apis","git","repositories", &repository.id,"pullrequests"]); } + _ => return Err("Unsupported integration provider.".into()), + } + } + if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version", "7.1"); } + Ok(url) +} + +#[tauri::command] +pub async fn create_integration_review_request(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository, source_branch: String, target_branch: String, title: String, description: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); } + let source = source_branch.trim().strip_prefix("refs/heads/").unwrap_or(source_branch.trim()); + let target = target_branch.trim().strip_prefix("refs/heads/").unwrap_or(target_branch.trim()); + let payload = creation_payload(&provider, source, target, title.trim(), &description)?; + let endpoint = creation_endpoint(&provider, &base_url, &repository)?; + let client = client()?; + let request = client.post(endpoint).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(if username.is_empty() { "gitty" } else { &username }, Some(&token)), + _ => unreachable!(), + }; + // Never automatically retry creation: a lost response can still mean the PR was created. + let response = request.json(&payload).send().map_err(|_| "The creation response could not be received. Check the original repository before trying again.".to_string())?; + if !response.status().is_success() { return Err(response_error(response, &provider)); } + let mut value: serde_json::Value = response.json().map_err(|_| "The request was created, but its response could not be read. Refresh the Review Center before trying again.".to_string())?; + let mut review = match provider.as_str() { + "github" => { + value["pull_request"] = serde_json::json!({}); + value["repository_url"] = serde_json::json!(format!("{}/repos/{}", github_api_base_url(&base_url)?, repository.full_name)); + parse_github_review(&value).ok_or("Could not read the created PR.")? + } + "gitea" => { + value["pull_request"] = value.clone(); + value["repository"] = serde_json::json!({"id":repository.id.parse::().unwrap_or_default(),"full_name":repository.full_name}); + parse_gitea_review(&value).ok_or("Could not read the created PR.")? + } + "gitlab" | "gitlab-self-hosted" => parse_gitlab_review(&value, &provider), + "azure-devops" => parse_azure_review(&value, &repository), + _ => unreachable!(), + }; + review.repository_name = repository.full_name; + review.source_branch = source.to_string(); + review.target_branch = target.to_string(); + Ok(review) + }).await.map_err(|e| format!("Could not create review request: {e}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn creation_payloads_and_validation() { + for provider in ["github", "gitea", "gitlab", "gitlab-self-hosted", "azure-devops"] { + assert!(creation_payload(provider, "main", "main", "Test", "").is_err()); + assert!(creation_payload(provider, "topic", "main", " ", "").is_err()); + } + assert_eq!(creation_payload("github", "feature/test", "main", "Test", "Body").unwrap()["head"], "feature/test"); + assert_eq!(creation_payload("gitea", "topic", "main", "Test", "Body").unwrap()["body"], "Body"); + assert_eq!(creation_payload("gitlab-self-hosted", "topic", "main", "Test", "").unwrap()["source_branch"], "topic"); + assert_eq!(creation_payload("azure-devops", "topic", "main", "Test", "").unwrap()["targetRefName"], "refs/heads/main"); + } + #[test] + fn creation_urls_preserve_prefixes_and_encode_paths() { + let repository: IntegrationRepository = serde_json::from_value(serde_json::json!({"id":"17","name":"repo","fullName":"owner/repo","description":"","cloneUrl":"","sshUrl":"","webUrl":"","updatedAt":"","private":false})).unwrap(); + assert_eq!(creation_endpoint("github", "https://github.com", &repository).unwrap().as_str(), "https://api.github.com/repos/owner/repo/pulls"); + assert_eq!(creation_endpoint("gitea", "https://git.example/sub", &repository).unwrap().path(), "/sub/api/v1/repos/owner/repo/pulls"); + assert_eq!(creation_endpoint("gitlab", "https://git.example", &repository).unwrap().path(), "/api/v4/projects/17/merge_requests"); + 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 c84ea6d..f19b06b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -437,6 +437,8 @@ async fn main() { cred_load, cred_save, cred_delete, + list_integration_repository_branches, + create_integration_review_request, list_integration_repositories, list_integration_review_requests, list_integration_issues, diff --git a/src/App.svelte b/src/App.svelte index 5eb39e3..96034cc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5572,6 +5572,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, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git"; + import { integrationCredentialKey } from "../integrations"; + import type { GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types"; + + let { source, de, localRepositoryPath = "", loadCredential, onClose, onCreated }: { + source: GitIntegrationSource; de: boolean; localRepositoryPath?: string; + loadCredential: (key: string) => Promise; + onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void; + } = $props(); + let dialog: HTMLDialogElement; + let repositories = $state([]); + 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); + let busy = $state(false); + let error = $state(""); + const mr = $derived(source.provider.startsWith("gitlab")); + 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(!branchesLoading && !branchError && branches.includes(sourceBranch) && branches.includes(targetBranch) && repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch); + + onMount(() => { dialog.showModal(); void loadRepositories(); }); + async function loadRepositories() { + loading = true; error = ""; + try { + repositories = (await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId)).sort((a,b) => a.fullName.localeCompare(b.fullName)); + if (repositories.length === 1) repositoryId = repositories[0].id; + } catch (cause) { error = String(cause); } + finally { loading = false; } + } + async function submit(event: SubmitEvent) { + event.preventDefault(); + if (busy || !valid) return; + const repository = repositories.find(item => item.id === repositoryId); + if (!repository) return; + busy = true; error = ""; + try { + const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId)); + if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration."); + const request = await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description); + onCreated(request); + } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); } + finally { busy = false; } + } + + + { event.preventDefault(); if (!busy) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}> +
+

{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."}

+ + +
+
+
+
+ + diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte index 99aa2cd..f576a06 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,8 @@ 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); let errors = $state>([]); @@ -384,9 +386,24 @@ } +{#if createOpen && activeSource} + createOpen = false} onCreated={(request) => { + ++loadGeneration; + loading = false; + requests = [request, ...requests.filter(item => item.id !== request.id)]; + stateFilter = request.state; + query = ""; + collapsedRepositories = new Set(); + errors = []; + createOpen = false; + selectRequest(request, true); + }}/> +{/if} +

Review Center

+ {#if activeSource}{/if}
{#if sources.length === 0} @@ -514,6 +531,7 @@