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:
@@ -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,
|
||||
|
||||
@@ -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}"))?
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user