Merge remote-tracking branch 'origin/main' into issue-center

# Conflicts:
#	src-tauri/src/main.rs
#	src/lib/components/ReviewCenter.svelte
#	src/lib/git.ts
This commit is contained in:
Christoph Brandau
2026-09-08 21:55:51 +02:00
7 changed files with 362 additions and 15 deletions
+168
View File
@@ -0,0 +1,168 @@
use super::*;
fn creation_payload(provider: &str, source: &str, target: &str, title: &str, description: &str) -> Result<serde_json::Value, String> {
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<reqwest::Url, String> {
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<IntegrationReviewRequest, String> {
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::<u64>().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<String>,
default_branch: String,
}
#[tauri::command]
pub async fn list_integration_repository_branches(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository) -> Result<RepositoryBranches, String> {
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<Response, String> {
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}"))?
}