From f7f85d387d88928e2c093f3d905330e0f6d72574 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 8 Sep 2026 21:32:12 +0200 Subject: [PATCH] feat(integrations): add create integration review request API and UI Add a new Tauri command and front-end flow to create PRs/MRs. The backend builds and validates provider-specific payloads and endpoints, sends creation requests, and parses responses into the app's review model. A Svelte dialog and a JS wrapper wire the UI to the command so users can create requests from the Review Center. - Implement provider payload and endpoint logic with unit tests - Expose create_integration_review_request as a Tauri command - Add CreateReviewDialog UI and integrate a creation button in Review Center --- src-tauri/src/integrations.rs | 5 +- src-tauri/src/integrations/creation.rs | 105 +++++++++++++++++++ src-tauri/src/main.rs | 3 +- src/lib/components/CreateReviewDialog.svelte | 73 +++++++++++++ src/lib/components/ReviewCenter.svelte | 18 ++++ src/lib/git.ts | 4 + 6 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/integrations/creation.rs create mode 100644 src/lib/components/CreateReviewDialog.svelte diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index d189e77..6b9e946 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -5,11 +5,14 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::time::Duration; +mod creation; +pub use creation::create_integration_review_request; + 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..3f180c7 --- /dev/null +++ b/src-tauri/src/integrations/creation.rs @@ -0,0 +1,105 @@ +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"); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 40b4e87..67afd1e 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::{ - add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, + 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, + create_integration_review_request, list_integration_repositories, list_integration_review_requests, get_integration_review_details, diff --git a/src/lib/components/CreateReviewDialog.svelte b/src/lib/components/CreateReviewDialog.svelte new file mode 100644 index 0000000..3088d2a --- /dev/null +++ b/src/lib/components/CreateReviewDialog.svelte @@ -0,0 +1,73 @@ + + + { 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} + + {#if !loading && !error && !repositories.length}

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

{/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 e3d4b77..8189a50 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -1,5 +1,6 @@ +{#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} @@ -528,6 +545,7 @@