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}"))? }