Files
GitLite/src-tauri/src/integrations.rs
T
Christoph 5db4f36abf feat(integrations): support automatic branch cleanup after merge
Add a new git::review_cleanup module that implements a CleanupPlan with
prepare() and finish() routines to safely remove/clean tracking and local
branches after a PR/MR is merged. The cleanup logic validates branch names,
ensures a clean worktree, checks remotes/URLs, verifies commits/ancestry,
protects against concurrent worktrees or divergent local/remote commits, and
performs authenticated fetch/push and ref updates. Unit tests for the cleanup
behavior are included.

Wire provider-side cleanup into integrations:
- add an integrations/cleanup module to read provider PR payloads and derive
  cleanup inputs
- run cleanup::prepare(...) before performing a merge when an optional
  cleanup_path is provided
- after a successful provider merge, run cleanup::finish(...); any failure is
  reported as MERGE_ACCEPTED_CLEANUP_FAILED

Also:
- export the new git review_cleanup module (src-tauri/src/git.rs)
- accept an optional cleanup_path parameter in run_integration_review_action
- remove the previous REVIEW_REQUEST_TIMEOUT wrapper around the spawned
  blocking task (the integration action is no longer wrapped with the 35s timeout)
2026-09-18 15:22:57 +02:00

1425 lines
64 KiB
Rust

mod cleanup;
mod merge;
pub use merge::get_integration_review_merge_options;
use merge::merge_payload;
mod issue_creation;
pub use issue_creation::*;
mod issue_actions;
pub use issue_actions::*;
mod issue_comments;
pub use issue_comments::*;
mod boards;
pub use boards::*;
mod issues;
pub use issues::*;
use crate::git::load_stored_credential;
use reqwest::blocking::{Client, Response};
use reqwest::header::{ACCEPT, USER_AGENT};
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, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IntegrationRepository {
pub id: String,
pub name: String,
pub full_name: String,
pub description: String,
pub clone_url: String,
pub ssh_url: String,
pub web_url: String,
pub updated_at: String,
pub private: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IntegrationReviewRequest {
pub id: String,
pub number: u64,
pub provider: String,
pub repository_id: String,
pub repository_name: String,
pub title: String,
pub description: String,
pub author: String,
pub state: String,
pub source_branch: String,
pub target_branch: String,
pub web_url: String,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub collaborators: Vec<String>,
#[serde(default)]
pub additions: Option<u64>,
#[serde(default)]
pub deletions: Option<u64>,
#[serde(default)]
pub changed_files: Option<u64>,
#[serde(default)]
pub merge_status: String,
#[serde(default)]
pub comments: Vec<IntegrationReviewComment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IntegrationReviewComment {
pub id: String,
pub author: String,
pub body: String,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
struct GitLabProject {
id: u64,
name: String,
path_with_namespace: String,
#[serde(default)]
description: Option<String>,
http_url_to_repo: String,
#[serde(default)]
ssh_url_to_repo: String,
#[serde(default)]
web_url: String,
#[serde(default)]
last_activity_at: String,
#[serde(default)]
visibility: String,
}
#[derive(Debug, Deserialize)]
struct GitHubRepository {
id: u64,
name: String,
full_name: String,
#[serde(default)]
description: Option<String>,
clone_url: String,
#[serde(default)]
ssh_url: String,
#[serde(default)]
html_url: String,
#[serde(default)]
updated_at: String,
#[serde(default)]
private: bool,
}
#[derive(Debug, Deserialize)]
struct GiteaRepository {
id: u64,
name: String,
full_name: String,
#[serde(default)]
description: String,
clone_url: String,
#[serde(default)]
ssh_url: String,
#[serde(default)]
html_url: String,
#[serde(default)]
updated_at: String,
#[serde(default)]
private: bool,
}
#[derive(Debug, Deserialize)]
struct AzureRepositoryList {
#[serde(default)]
value: Vec<AzureRepository>,
}
#[derive(Debug, Deserialize)]
struct AzureRepository {
id: String,
name: String,
project: AzureProject,
#[serde(default, rename = "remoteUrl")]
remote_url: String,
#[serde(default, rename = "sshUrl")]
ssh_url: String,
#[serde(default, rename = "webUrl")]
web_url: String,
}
#[derive(Debug, Deserialize)]
struct AzureProject {
name: String,
}
fn integration_key(provider: &str, account_id: Option<&str>) -> Result<String, String> {
if provider == "azure-devops" {
if let Some(account_id) = account_id.filter(|value| !value.is_empty()) {
if account_id.len() > 80
|| !account_id.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
})
{
return Err("Invalid integration account identifier.".to_string());
}
if account_id != "default" {
return Ok(format!("integration:{provider}:{account_id}"));
}
}
}
Ok(format!("integration:{provider}"))
}
fn client() -> Result<Client, String> {
Client::builder()
.connect_timeout(Duration::from_secs(7))
.timeout(Duration::from_secs(15))
.build()
.map_err(|err| format!("Could not initialize the integration client: {err}"))
}
fn normalized_base_url(base_url: &str) -> Result<String, String> {
let base_url = base_url.trim().trim_end_matches('/');
if !(base_url.starts_with("https://") || base_url.starts_with("http://")) {
return Err("The integration URL must start with http:// or https://.".to_string());
}
Ok(base_url.to_string())
}
fn github_api_base_url(base_url: &str) -> Result<String, String> {
match normalized_base_url(base_url)?.to_ascii_lowercase().as_str() {
"https://github.com" | "https://www.github.com" => Ok("https://api.github.com".to_string()),
"https://api.github.com" => Ok("https://api.github.com".to_string()),
_ => Err("The GitHub integration URL must be https://github.com.".to_string()),
}
}
fn response_error(response: Response, provider: &str) -> String {
let status = response.status();
let detail = response.text().ok().and_then(|body| {
serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|value| {
value
.get("message")
.or_else(|| value.get("error"))
.and_then(|value| value.as_str())
.map(str::to_string)
})
});
match detail {
Some(detail) if !detail.trim().is_empty() => {
format!("{provider} returned {status}: {detail}")
}
_ => format!("{provider} returned {status}."),
}
}
fn gitlab_repositories(
client: &Client,
base_url: &str,
token: &str,
) -> Result<Vec<IntegrationRepository>, String> {
let mut repositories = Vec::new();
let mut page = 1usize;
loop {
let response = client
.get(format!("{base_url}/api/v4/projects"))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.header("PRIVATE-TOKEN", token)
.query(&[
("membership", "true"),
("simple", "true"),
("order_by", "last_activity_at"),
("sort", "desc"),
("per_page", &PAGE_SIZE.to_string()),
("page", &page.to_string()),
])
.send()
.map_err(|err| format!("Could not reach GitLab: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "GitLab"));
}
let next_page = response
.headers()
.get("x-next-page")
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string();
let projects = response
.json::<Vec<GitLabProject>>()
.map_err(|err| format!("GitLab returned an unreadable repository list: {err}"))?;
repositories.extend(projects.into_iter().map(|project| IntegrationRepository {
id: project.id.to_string(),
name: project.name,
full_name: project.path_with_namespace,
description: project.description.unwrap_or_default(),
clone_url: project.http_url_to_repo,
ssh_url: project.ssh_url_to_repo,
web_url: project.web_url,
updated_at: project.last_activity_at,
private: project.visibility == "private",
}));
if next_page.is_empty() {
break;
}
page = next_page.parse().unwrap_or(page + 1);
}
Ok(repositories)
}
fn github_repositories(
client: &Client,
base_url: &str,
token: &str,
) -> Result<Vec<IntegrationRepository>, String> {
let api_base_url = github_api_base_url(base_url)?;
let mut repositories = Vec::new();
let mut page = 1usize;
loop {
let response = client
.get(format!("{api_base_url}/user/repos"))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/vnd.github+json")
.header("Authorization", format!("Bearer {token}"))
.header("X-GitHub-Api-Version", "2026-03-10")
.query(&[
("per_page", PAGE_SIZE.to_string()),
("page", page.to_string()),
("sort", "updated".to_string()),
("direction", "desc".to_string()),
])
.send()
.map_err(|err| format!("Could not reach GitHub: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "GitHub"));
}
let page_repositories = response
.json::<Vec<GitHubRepository>>()
.map_err(|err| format!("GitHub returned an unreadable repository list: {err}"))?;
let count = page_repositories.len();
repositories.extend(page_repositories.into_iter().map(|repository| {
IntegrationRepository {
id: repository.id.to_string(),
name: repository.name,
full_name: repository.full_name,
description: repository.description.unwrap_or_default(),
clone_url: repository.clone_url,
ssh_url: repository.ssh_url,
web_url: repository.html_url,
updated_at: repository.updated_at,
private: repository.private,
}
}));
if count < PAGE_SIZE {
break;
}
page += 1;
}
Ok(repositories)
}
fn gitea_repositories(
client: &Client,
base_url: &str,
token: &str,
) -> Result<Vec<IntegrationRepository>, String> {
let mut repositories = Vec::new();
let mut page = 1usize;
loop {
let response = client
.get(format!("{base_url}/api/v1/user/repos"))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.header("Authorization", format!("token {token}"))
.query(&[
("limit", PAGE_SIZE.to_string()),
("page", page.to_string()),
("sort", "updated".to_string()),
])
.send()
.map_err(|err| format!("Could not reach Gitea: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "Gitea"));
}
let page_repositories = response
.json::<Vec<GiteaRepository>>()
.map_err(|err| format!("Gitea returned an unreadable repository list: {err}"))?;
let count = page_repositories.len();
repositories.extend(page_repositories.into_iter().map(|repository| {
IntegrationRepository {
id: repository.id.to_string(),
name: repository.name,
full_name: repository.full_name,
description: repository.description,
clone_url: repository.clone_url,
ssh_url: repository.ssh_url,
web_url: repository.html_url,
updated_at: repository.updated_at,
private: repository.private,
}
}));
if count < PAGE_SIZE {
break;
}
page += 1;
}
Ok(repositories)
}
fn azure_repositories(
client: &Client,
base_url: &str,
username: &str,
token: &str,
) -> Result<Vec<IntegrationRepository>, String> {
let response = client
.get(format!("{base_url}/_apis/git/repositories"))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.basic_auth(username, Some(token))
.query(&[("api-version", "7.1")])
.send()
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "Azure DevOps"));
}
let repositories = response
.json::<AzureRepositoryList>()
.map_err(|err| format!("Azure DevOps returned an unreadable repository list: {err}"))?;
Ok(repositories
.value
.into_iter()
.map(|repository| IntegrationRepository {
id: repository.id,
full_name: format!("{}/{}", repository.project.name, repository.name),
name: repository.name,
description: String::new(),
clone_url: repository.remote_url,
ssh_url: repository.ssh_url,
web_url: repository.web_url,
updated_at: String::new(),
private: true,
})
.collect())
}
fn value_string(value: &serde_json::Value, path: &[&str]) -> String {
path.iter()
.try_fold(value, |current, key| current.get(*key))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
}
fn value_u64(value: &serde_json::Value, key: &str) -> u64 {
value
.get(key)
.and_then(serde_json::Value::as_u64)
.unwrap_or_default()
}
fn github_repository_name(repository_url: &str) -> String {
let parts: Vec<_> = repository_url.trim_end_matches('/').split('/').collect();
if parts.len() < 2 {
return String::new();
}
format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
}
fn parse_github_review(value: &serde_json::Value) -> Option<IntegrationReviewRequest> {
let pull = value.get("pull_request")?;
let number = value_u64(value, "number");
let repository_url = value_string(value, &["repository_url"]);
let state = if pull.get("merged_at").is_some_and(|item| !item.is_null()) {
"merged"
} else if value_string(value, &["state"]) == "open" {
if value.get("draft").and_then(serde_json::Value::as_bool) == Some(true) {
"draft"
} else {
"open"
}
} else {
"closed"
};
Some(IntegrationReviewRequest {
id: format!("github:{repository_url}:{number}"),
number,
provider: "github".to_string(),
repository_id: repository_url.clone(),
repository_name: github_repository_name(&repository_url),
title: value_string(value, &["title"]),
description: value_string(value, &["body"]),
author: value_string(value, &["user", "login"]),
state: state.to_string(),
source_branch: String::new(),
target_branch: String::new(),
web_url: value_string(value, &["html_url"]),
created_at: value_string(value, &["created_at"]),
updated_at: value_string(value, &["updated_at"]),
collaborators: Vec::new(), additions: None, deletions: None, changed_files: None, merge_status: String::new(), comments: Vec::new(),
})
}
fn parse_gitlab_review(value: &serde_json::Value, provider: &str) -> IntegrationReviewRequest {
let number = value_u64(value, "iid");
let project_id = value.get("project_id").map_or_else(String::new, |id| {
id.as_u64().map_or_else(
|| id.as_str().unwrap_or_default().to_string(),
|id| id.to_string(),
)
});
let reference = value_string(value, &["references", "full"]);
let repository_name = reference
.rsplit_once('!')
.map_or(reference.clone(), |(name, _)| name.to_string());
let raw_state = value_string(value, &["state"]);
let draft = value.get("draft").and_then(serde_json::Value::as_bool) == Some(true)
|| value
.get("work_in_progress")
.and_then(serde_json::Value::as_bool)
== Some(true);
let state = match raw_state.as_str() {
"opened" if draft => "draft",
"opened" => "open",
"merged" => "merged",
_ => "closed",
};
IntegrationReviewRequest {
id: format!("{provider}:{project_id}:{number}"),
number,
provider: provider.to_string(),
repository_id: project_id,
repository_name,
title: value_string(value, &["title"]),
description: value_string(value, &["description"]),
author: value_string(value, &["author", "name"]),
state: state.to_string(),
source_branch: value_string(value, &["source_branch"]),
target_branch: value_string(value, &["target_branch"]),
web_url: value_string(value, &["web_url"]),
created_at: value_string(value, &["created_at"]),
updated_at: value_string(value, &["updated_at"]),
collaborators: value.get("reviewers").and_then(serde_json::Value::as_array).into_iter().flatten().map(|item| value_string(item, &["name"])).filter(|name| !name.is_empty()).collect(),
additions: None, deletions: None,
changed_files: value_string(value, &["changes_count"]).parse().ok(),
merge_status: match value_string(value, &["detailed_merge_status"]).as_str() { "mergeable" | "can_be_merged" => "mergeable", "conflict" | "conflicts" => "conflicts", "checking" | "unchecked" | "preparing" => "checking", "" => "", _ => "blocked" }.to_string(), comments: Vec::new(),
}
}
fn parse_gitea_review(value: &serde_json::Value) -> Option<IntegrationReviewRequest> {
value.get("pull_request")?;
let number = value_u64(value, "number");
let repository_id = value
.get("repository")
.and_then(|repository| repository.get("id"))
.and_then(serde_json::Value::as_u64)
.map_or_else(String::new, |id| id.to_string());
let is_open = value_string(value, &["state"]) == "open";
let merged = value
.get("pull_request")
.and_then(|pull| pull.get("merged_at"))
.is_some_and(|item| !item.is_null());
Some(IntegrationReviewRequest {
id: format!("gitea:{repository_id}:{number}"),
number,
provider: "gitea".to_string(),
repository_id,
repository_name: value_string(value, &["repository", "full_name"]),
title: value_string(value, &["title"]),
description: value_string(value, &["body"]),
author: value_string(value, &["user", "login"]),
state: if merged {
"merged"
} else if is_open {
"open"
} else {
"closed"
}
.to_string(),
source_branch: {
let branch = value_string(value, &["pull_request", "head", "ref"]);
if branch.is_empty() { value_string(value, &["pull_request", "head", "label"]) } else { branch }
},
target_branch: {
let branch = value_string(value, &["pull_request", "base", "ref"]);
if branch.is_empty() { value_string(value, &["pull_request", "base", "label"]) } else { branch }
},
web_url: value_string(value, &["html_url"]),
created_at: value_string(value, &["created_at"]),
updated_at: value_string(value, &["updated_at"]),
collaborators: Vec::new(), additions: None, deletions: None, changed_files: None,
merge_status: value.get("pull_request").and_then(|pull| pull.get("mergeable")).and_then(serde_json::Value::as_bool).map(|mergeable| if mergeable { "mergeable" } else { "conflicts" }).unwrap_or_default().to_string(), comments: Vec::new(),
})
}
fn parse_azure_review(
value: &serde_json::Value,
repository: &IntegrationRepository,
) -> IntegrationReviewRequest {
let number = value_u64(value, "pullRequestId");
let raw_state = value_string(value, &["status"]);
let draft = value.get("isDraft").and_then(serde_json::Value::as_bool) == Some(true);
let state = match raw_state.as_str() {
"active" if draft => "draft",
"active" => "open",
"completed" => "merged",
_ => "closed",
};
let trim_branch = |branch: String| {
branch
.strip_prefix("refs/heads/")
.unwrap_or(&branch)
.to_string()
};
IntegrationReviewRequest {
id: format!("azure-devops:{}:{number}", repository.id),
number,
provider: "azure-devops".to_string(),
repository_id: repository.id.clone(),
repository_name: repository.full_name.clone(),
title: value_string(value, &["title"]),
description: value_string(value, &["description"]),
author: value_string(value, &["createdBy", "displayName"]),
state: state.to_string(),
source_branch: trim_branch(value_string(value, &["sourceRefName"])),
target_branch: trim_branch(value_string(value, &["targetRefName"])),
web_url: format!(
"{}/pullrequest/{number}",
repository.web_url.trim_end_matches('/')
),
created_at: value_string(value, &["creationDate"]),
updated_at: value_string(value, &["closedDate"]),
collaborators: value.get("reviewers").and_then(serde_json::Value::as_array).into_iter().flatten().map(|item| value_string(item, &["displayName"])).filter(|name| !name.is_empty()).collect(),
additions: None, deletions: None, changed_files: None,
merge_status: match value_string(value, &["mergeStatus"]).as_str() { "succeeded" => "mergeable", "conflicts" => "conflicts", "queued" | "notSet" => "checking", "" => "", _ => "blocked" }.to_string(), comments: Vec::new(),
}
}
fn github_reviews(
client: &Client,
base_url: &str,
token: &str,
state: &str,
) -> Result<Vec<IntegrationReviewRequest>, String> {
let query = match state {
"open" => "is:pr is:open",
"merged" => "is:pr is:merged",
"closed" => "is:pr is:closed is:unmerged",
_ => return Err("Unsupported review state.".to_string()),
};
let response = client
.get(format!("{}/search/issues", github_api_base_url(base_url)?))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/vnd.github+json")
.header("Authorization", format!("Bearer {token}"))
.header("X-GitHub-Api-Version", "2026-03-10")
.query(&[
("q", query),
("sort", "updated"),
("order", "desc"),
("per_page", "100"),
])
.send()
.map_err(|err| format!("Could not reach GitHub: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "GitHub"));
}
let payload = response
.json::<serde_json::Value>()
.map_err(|err| format!("GitHub returned an unreadable pull request list: {err}"))?;
Ok(payload
.get("items")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(parse_github_review)
.map(|mut review| {
if state != "open" {
review.state = state.to_string();
}
review
})
.collect())
}
fn gitlab_reviews(
client: &Client,
base_url: &str,
token: &str,
provider: &str,
state: &str,
) -> Result<Vec<IntegrationReviewRequest>, String> {
let api_state = match state {
"open" => "opened",
"merged" => "merged",
"closed" => "closed",
_ => return Err("Unsupported review state.".to_string()),
};
let response = client
.get(format!("{base_url}/api/v4/merge_requests"))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.header("PRIVATE-TOKEN", token)
.query(&[
("scope", "all"),
("state", api_state),
("order_by", "updated_at"),
("sort", "desc"),
("per_page", "100"),
])
.send()
.map_err(|err| format!("Could not reach GitLab: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "GitLab"));
}
Ok(response
.json::<Vec<serde_json::Value>>()
.map_err(|err| format!("GitLab returned an unreadable merge request list: {err}"))?
.iter()
.map(|value| parse_gitlab_review(value, provider))
.collect())
}
fn gitea_reviews(
client: &Client,
base_url: &str,
token: &str,
state: &str,
) -> Result<Vec<IntegrationReviewRequest>, String> {
let api_state = if state == "open" { "open" } else { "closed" };
let response = client
.get(format!("{base_url}/api/v1/repos/issues/search"))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.header("Authorization", format!("token {token}"))
.query(&[("type", "pulls"), ("state", api_state), ("limit", "100")])
.send()
.map_err(|err| format!("Could not reach Gitea: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "Gitea"));
}
Ok(response
.json::<Vec<serde_json::Value>>()
.map_err(|err| format!("Gitea returned an unreadable pull request list: {err}"))?
.iter()
.filter_map(parse_gitea_review)
.filter(|review| state == "open" || review.state == state)
.collect())
}
fn azure_project_reviews(
client: &Client,
base_url: &str,
username: &str,
token: &str,
api_state: &str,
project: &str,
repositories: &[IntegrationRepository],
) -> Result<Vec<IntegrationReviewRequest>, String> {
let mut endpoint =
reqwest::Url::parse(base_url).map_err(|err| format!("Invalid Azure DevOps URL: {err}"))?;
endpoint
.path_segments_mut()
.map_err(|_| "Invalid Azure DevOps URL.".to_string())?
.push(project)
.push("_apis")
.push("git")
.push("pullrequests");
let response = client
.get(endpoint)
.timeout(Duration::from_secs(15))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.basic_auth(username, Some(token))
.query(&[
("searchCriteria.status", api_state),
("$top", "100"),
("api-version", "7.1"),
])
.send()
.map_err(|err| format!("Could not reach Azure DevOps project {project}: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "Azure DevOps"));
}
let payload = response
.json::<serde_json::Value>()
.map_err(|err| format!("Azure DevOps returned an unreadable pull request list: {err}"))?;
Ok(payload
.get("value")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|value| {
let repository_id = value_string(value, &["repository", "id"]);
repositories
.iter()
.find(|repository| repository.id == repository_id)
.map(|repository| parse_azure_review(value, repository))
})
.collect())
}
fn azure_reviews(
client: &Client,
base_url: &str,
username: &str,
token: &str,
state: &str,
) -> Result<Vec<IntegrationReviewRequest>, String> {
let api_state = match state {
"open" => "active",
"merged" => "completed",
"closed" => "abandoned",
_ => return Err("Unsupported review state.".to_string()),
};
let repositories = azure_repositories(client, base_url, username, token)?;
let projects: Vec<_> = repositories
.iter()
.filter_map(|repository| {
repository
.full_name
.split_once('/')
.map(|(project, _)| project)
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if projects.is_empty() {
return Ok(Vec::new());
}
let chunk_size = projects.len().div_ceil(AZURE_PROJECT_WORKERS);
let results = std::thread::scope(|scope| {
let handles = projects
.chunks(chunk_size)
.map(|project_chunk| {
let repositories = &repositories;
scope.spawn(move || {
project_chunk
.iter()
.map(|project| {
azure_project_reviews(
client,
base_url,
username,
token,
api_state,
project,
repositories,
)
})
.collect::<Vec<_>>()
})
})
.collect::<Vec<_>>();
let mut results = Vec::with_capacity(projects.len());
for handle in handles {
match handle.join() {
Ok(worker_results) => results.extend(worker_results),
Err(_) => results.push(Err("Azure DevOps project worker failed.".to_string())),
}
}
results
});
let mut reviews = Vec::new();
let mut errors = Vec::new();
let mut successful_projects = 0usize;
for result in results {
match result {
Ok(project_reviews) => {
successful_projects += 1;
reviews.extend(project_reviews);
}
Err(error) => errors.push(error),
}
}
if successful_projects == 0 && !errors.is_empty() {
let omitted = errors.len().saturating_sub(3);
let mut detail = errors.into_iter().take(3).collect::<Vec<_>>().join("; ");
if omitted > 0 {
detail.push_str(&format!("; and {omitted} more project request(s) failed."));
}
return Err(detail);
}
reviews.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
Ok(reviews)
}
fn ensure_action_response(response: Response, provider: &str, action: &str) -> Result<(), String> {
if response.status().is_success() {
Ok(())
} else {
Err(format!("Could not {action} review request: {}", response_error(response, provider)))
}
}
fn github_review_action(
client: &Client,
base_url: &str,
token: &str,
repository_name: &str,
number: u64,
action: &str,
merge_method: Option<&str>,
) -> Result<(), String> {
if repository_name.split('/').count() != 2 {
return Err("GitHub returned an invalid repository name.".to_string());
}
let endpoint = format!("{}/repos/{repository_name}/pulls/{number}", github_api_base_url(base_url)?);
let request = match action {
"merge" => client.put(format!("{endpoint}/merge")).json(&merge_payload("github", merge_method)?),
"approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVE" })),
"close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })),
"reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })),
_ => return Err("Unsupported review action.".to_string()),
};
let response = request
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/vnd.github+json")
.header("Authorization", format!("Bearer {token}"))
.header("X-GitHub-Api-Version", "2026-03-10")
.send()
.map_err(|err| format!("Could not reach GitHub: {err}"))?;
ensure_action_response(response, "GitHub", action)
}
fn gitlab_review_action(
client: &Client,
base_url: &str,
token: &str,
repository_id: &str,
number: u64,
action: &str,
merge_method: Option<&str>,
) -> Result<(), String> {
if repository_id.trim().is_empty() {
return Err("GitLab returned an invalid project identifier.".to_string());
}
let endpoint = format!("{base_url}/api/v4/projects/{repository_id}/merge_requests/{number}");
let request = match action {
"merge" => client.put(format!("{endpoint}/merge")).json(&merge_payload("gitlab", merge_method)?),
"approve" => client.post(format!("{endpoint}/approve")),
"close" => client.put(&endpoint).query(&[("state_event", "close")]),
"reopen" => client.put(&endpoint).query(&[("state_event", "reopen")]),
_ => return Err("Unsupported review action.".to_string()),
};
let response = request
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.header("PRIVATE-TOKEN", token)
.send()
.map_err(|err| format!("Could not reach GitLab: {err}"))?;
ensure_action_response(response, "GitLab", action)
}
fn gitea_review_action(
client: &Client,
base_url: &str,
token: &str,
repository_name: &str,
number: u64,
action: &str,
merge_method: Option<&str>,
) -> Result<(), String> {
if repository_name.split('/').count() != 2 {
return Err("Gitea returned an invalid repository name.".to_string());
}
let endpoint = format!("{base_url}/api/v1/repos/{repository_name}/pulls/{number}");
let request = match action {
"merge" => client.post(format!("{endpoint}/merge")).json(&merge_payload("gitea", merge_method)?),
"approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVED", "body": "" })),
"close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })),
"reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })),
_ => return Err("Unsupported review action.".to_string()),
};
let response = request
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.header("Authorization", format!("token {token}"))
.send()
.map_err(|err| format!("Could not reach Gitea: {err}"))?;
ensure_action_response(response, "Gitea", action)
}
fn azure_review_endpoint(
base_url: &str,
repository_name: &str,
repository_id: &str,
number: u64,
) -> Result<reqwest::Url, String> {
let project = repository_name
.split_once('/')
.map(|(project, _)| project)
.filter(|project| !project.is_empty())
.ok_or_else(|| "Azure DevOps returned an invalid repository name.".to_string())?;
let mut endpoint = reqwest::Url::parse(base_url)
.map_err(|err| format!("Invalid Azure DevOps URL: {err}"))?;
endpoint
.path_segments_mut()
.map_err(|_| "Invalid Azure DevOps URL.".to_string())?
.push(project)
.push("_apis")
.push("git")
.push("repositories")
.push(repository_id)
.push("pullrequests")
.push(&number.to_string());
endpoint.query_pairs_mut().append_pair("api-version", "7.1");
Ok(endpoint)
}
fn azure_review_action(
client: &Client,
base_url: &str,
username: &str,
token: &str,
repository_name: &str,
repository_id: &str,
number: u64,
action: &str,
merge_method: Option<&str>,
) -> Result<(), String> {
let endpoint = azure_review_endpoint(base_url, repository_name, repository_id, number)?;
let auth_user = if username.trim().is_empty() { "gitty" } else { username };
if action == "approve" {
let mut identity_endpoint = reqwest::Url::parse(base_url)
.map_err(|err| format!("Invalid Azure DevOps URL: {err}"))?;
identity_endpoint.path_segments_mut().map_err(|_| "Invalid Azure DevOps URL.".to_string())?.push("_apis").push("connectionData");
identity_endpoint.query_pairs_mut().append_pair("connectOptions", "1").append_pair("lastChangeId", "-1").append_pair("lastChangeId64", "-1");
let identity = client.get(identity_endpoint).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send()
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
if !identity.status().is_success() { return Err(response_error(identity, "Azure DevOps")); }
let payload = identity.json::<serde_json::Value>().map_err(|err| format!("Azure DevOps returned an unreadable identity: {err}"))?;
let reviewer_id = value_string(&payload, &["authenticatedUser", "id"]);
if reviewer_id.is_empty() { return Err("Azure DevOps did not return the authenticated user.".to_string()); }
let reviewer_endpoint = format!("{}/reviewers/{reviewer_id}?api-version=7.1", endpoint.as_str().split('?').next().unwrap_or_default());
let response = client.put(reviewer_endpoint).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).json(&serde_json::json!({ "vote": 10 })).send()
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
return ensure_action_response(response, "Azure DevOps", action);
}
let body = match action {
"close" => serde_json::json!({ "status": "abandoned" }),
"reopen" => serde_json::json!({ "status": "active" }),
"merge" => {
let current = client.get(endpoint.clone()).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send()
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
if !current.status().is_success() { return Err(response_error(current, "Azure DevOps")); }
let payload = current.json::<serde_json::Value>().map_err(|err| format!("Azure DevOps returned an unreadable pull request: {err}"))?;
let commit_id = value_string(&payload, &["lastMergeSourceCommit", "commitId"]);
if commit_id.is_empty() { return Err("Azure DevOps did not return the current source commit.".to_string()); }
{
let mut body = merge_payload("azure-devops", merge_method)?;
body["status"] = serde_json::json!("completed");
body["lastMergeSourceCommit"] = serde_json::json!({ "commitId": commit_id });
body
}
}
_ => return Err("Unsupported review action.".to_string()),
};
let response = client.patch(endpoint).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).json(&body).send()
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
ensure_action_response(response, "Azure DevOps", action)
}
fn review_names(value: &serde_json::Value, key: &str, name_key: &str) -> Vec<String> {
value.get(key).and_then(serde_json::Value::as_array).into_iter().flatten()
.map(|item| value_string(item, &[name_key]))
.filter(|name| !name.is_empty()).collect()
}
fn json_id(value: &serde_json::Value) -> String {
value.get("id").map_or_else(String::new, |id| id.as_str().map(str::to_string).or_else(|| id.as_u64().map(|id| id.to_string())).unwrap_or_default())
}
fn review_comments(client: &Client, base_url: &str, username: &str, token: &str, review: &IntegrationReviewRequest) -> Result<Vec<IntegrationReviewComment>, String> {
let (payload, provider) = match review.provider.as_str() {
"github" => {
let url = format!("{}/repos/{}/issues/{}/comments", github_api_base_url(base_url)?, review.repository_name, review.number);
let response = client.get(url).header(USER_AGENT,"Gitty").header(ACCEPT,"application/vnd.github+json").header("Authorization",format!("Bearer {token}")).header("X-GitHub-Api-Version","2026-03-10").query(&[("per_page","100")]).send().map_err(|err|format!("Could not load GitHub comments: {err}"))?;
if !response.status().is_success(){return Err(response_error(response,"GitHub"));} (response.json::<serde_json::Value>().map_err(|err|format!("GitHub returned unreadable comments: {err}"))?,"github")
}
"gitlab" | "gitlab-self-hosted" => {
let url=format!("{base_url}/api/v4/projects/{}/merge_requests/{}/notes",review.repository_id,review.number);
let response=client.get(url).header(USER_AGENT,"Gitty").header("PRIVATE-TOKEN",token).query(&[("per_page","100"),("sort","asc")]).send().map_err(|err|format!("Could not load GitLab comments: {err}"))?;
if !response.status().is_success(){return Err(response_error(response,"GitLab"));} (response.json::<serde_json::Value>().map_err(|err|format!("GitLab returned unreadable comments: {err}"))?,"gitlab")
}
"gitea" => {
let url=format!("{base_url}/api/v1/repos/{}/issues/{}/comments",review.repository_name,review.number);
let response=client.get(url).header(USER_AGENT,"Gitty").header("Authorization",format!("token {token}")).query(&[("limit","100")]).send().map_err(|err|format!("Could not load Gitea comments: {err}"))?;
if !response.status().is_success(){return Err(response_error(response,"Gitea"));} (response.json::<serde_json::Value>().map_err(|err|format!("Gitea returned unreadable comments: {err}"))?,"gitea")
}
"azure-devops" => {
let mut url=azure_review_endpoint(base_url,&review.repository_name,&review.repository_id,review.number)?; url.set_query(None); url.path_segments_mut().map_err(|_|"Invalid Azure DevOps URL.".to_string())?.push("threads"); url.query_pairs_mut().append_pair("api-version","7.1");
let auth=if username.trim().is_empty(){"gitty"}else{username}; let response=client.get(url).header(USER_AGENT,"Gitty").basic_auth(auth,Some(token)).send().map_err(|err|format!("Could not load Azure DevOps comments: {err}"))?;
if !response.status().is_success(){return Err(response_error(response,"Azure DevOps"));} (response.json::<serde_json::Value>().map_err(|err|format!("Azure DevOps returned unreadable comments: {err}"))?,"azure-devops")
}
_=>return Err("Unsupported integration provider.".to_string()),
};
let mut comments=Vec::new();
if provider=="azure-devops" {
for thread in payload.get("value").and_then(serde_json::Value::as_array).into_iter().flatten() { for item in thread.get("comments").and_then(serde_json::Value::as_array).into_iter().flatten() { if item.get("isDeleted").and_then(serde_json::Value::as_bool)==Some(true){continue;} comments.push(IntegrationReviewComment{id:format!("{}:{}",json_id(thread),json_id(item)),author:value_string(item,&["author","displayName"]),body:value_string(item,&["content"]),created_at:value_string(item,&["publishedDate"])}); } }
} else if let Some(items)=payload.as_array() { for item in items { if provider=="gitlab" && item.get("system").and_then(serde_json::Value::as_bool)==Some(true){continue;} comments.push(IntegrationReviewComment{id:json_id(item),author:if provider=="gitlab"{value_string(item,&["author","name"])}else{value_string(item,&["user","login"])},body:value_string(item,&["body"]),created_at:value_string(item,&["created_at"])}); } }
Ok(comments)
}
fn post_review_comment(client:&Client,base_url:&str,username:&str,token:&str,review:&IntegrationReviewRequest,body:&str)->Result<(),String>{
let response=match review.provider.as_str(){
"github"=>client.post(format!("{}/repos/{}/issues/{}/comments",github_api_base_url(base_url)?,review.repository_name,review.number)).header(USER_AGENT,"Gitty").header(ACCEPT,"application/vnd.github+json").header("Authorization",format!("Bearer {token}")).header("X-GitHub-Api-Version","2026-03-10").json(&serde_json::json!({"body":body})).send().map_err(|err|format!("Could not reach GitHub: {err}"))?,
"gitlab"|"gitlab-self-hosted"=>client.post(format!("{base_url}/api/v4/projects/{}/merge_requests/{}/notes",review.repository_id,review.number)).header(USER_AGENT,"Gitty").header("PRIVATE-TOKEN",token).json(&serde_json::json!({"body":body})).send().map_err(|err|format!("Could not reach GitLab: {err}"))?,
"gitea"=>client.post(format!("{base_url}/api/v1/repos/{}/issues/{}/comments",review.repository_name,review.number)).header(USER_AGENT,"Gitty").header("Authorization",format!("token {token}")).json(&serde_json::json!({"body":body})).send().map_err(|err|format!("Could not reach Gitea: {err}"))?,
"azure-devops"=>{let mut url=azure_review_endpoint(base_url,&review.repository_name,&review.repository_id,review.number)?;url.set_query(None);url.path_segments_mut().map_err(|_|"Invalid Azure DevOps URL.".to_string())?.push("threads");url.query_pairs_mut().append_pair("api-version","7.1");let auth=if username.trim().is_empty(){"gitty"}else{username};client.post(url).header(USER_AGENT,"Gitty").basic_auth(auth,Some(token)).json(&serde_json::json!({"comments":[{"parentCommentId":0,"content":body,"commentType":1}],"status":1})).send().map_err(|err|format!("Could not reach Azure DevOps: {err}"))?},
_=>return Err("Unsupported integration provider.".to_string())};
ensure_action_response(response, provider_label_for_error(&review.provider), "add comment")
}
fn provider_label_for_error(provider:&str)->&str{match provider{"github"=>"GitHub","gitlab"|"gitlab-self-hosted"=>"GitLab","gitea"=>"Gitea","azure-devops"=>"Azure DevOps",_=>"Integration"}}
fn load_review_details(
client: &Client,
base_url: &str,
username: &str,
token: &str,
mut review: IntegrationReviewRequest,
) -> Result<IntegrationReviewRequest, String> {
let payload = match review.provider.as_str() {
"github" => {
let endpoint = format!("{}/repos/{}/pulls/{}", github_api_base_url(base_url)?, review.repository_name, review.number);
let response = client.get(endpoint).header(USER_AGENT, "Gitty").header(ACCEPT, "application/vnd.github+json").header("Authorization", format!("Bearer {token}")).header("X-GitHub-Api-Version", "2026-03-10").send().map_err(|err| format!("Could not reach GitHub: {err}"))?;
if !response.status().is_success() { return Err(response_error(response, "GitHub")); }
response.json::<serde_json::Value>().map_err(|err| format!("GitHub returned unreadable pull request details: {err}"))?
}
"gitlab" | "gitlab-self-hosted" => {
let endpoint = format!("{base_url}/api/v4/projects/{}/merge_requests/{}", review.repository_id, review.number);
let response = client.get(&endpoint).header(USER_AGENT, "Gitty").header("PRIVATE-TOKEN", token).send().map_err(|err| format!("Could not reach GitLab: {err}"))?;
if !response.status().is_success() { return Err(response_error(response, "GitLab")); }
let mut payload = response.json::<serde_json::Value>().map_err(|err| format!("GitLab returned unreadable merge request details: {err}"))?;
let diffs = client.get(format!("{endpoint}/diffs")).header(USER_AGENT, "Gitty").header("PRIVATE-TOKEN", token).query(&[("per_page", "100")]).send().map_err(|err| format!("Could not load GitLab diffs: {err}"))?;
if diffs.status().is_success() {
if let Ok(items) = diffs.json::<Vec<serde_json::Value>>() { payload["gitty_diffs"] = serde_json::Value::Array(items); }
}
payload
}
"gitea" => {
let endpoint = format!("{base_url}/api/v1/repos/{}/pulls/{}", review.repository_name, review.number);
let response = client.get(endpoint).header(USER_AGENT, "Gitty").header("Authorization", format!("token {token}")).send().map_err(|err| format!("Could not reach Gitea: {err}"))?;
if !response.status().is_success() { return Err(response_error(response, "Gitea")); }
response.json::<serde_json::Value>().map_err(|err| format!("Gitea returned unreadable pull request details: {err}"))?
}
"azure-devops" => {
let endpoint = azure_review_endpoint(base_url, &review.repository_name, &review.repository_id, review.number)?;
let auth_user = if username.trim().is_empty() { "gitty" } else { username };
let response = client.get(endpoint.clone()).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send().map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
if !response.status().is_success() { return Err(response_error(response, "Azure DevOps")); }
let mut payload = response.json::<serde_json::Value>().map_err(|err| format!("Azure DevOps returned unreadable pull request details: {err}"))?;
let mut iterations_url = endpoint; iterations_url.set_query(None); iterations_url.path_segments_mut().map_err(|_| "Invalid Azure DevOps URL.".to_string())?.push("iterations"); iterations_url.query_pairs_mut().append_pair("api-version", "7.1");
if let Ok(iterations) = client.get(iterations_url.clone()).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send() {
if iterations.status().is_success() {
if let Ok(value) = iterations.json::<serde_json::Value>() {
if let Some(id) = value.get("value").and_then(serde_json::Value::as_array).and_then(|items| items.last()).and_then(|item| item.get("id")).and_then(serde_json::Value::as_u64) {
iterations_url.set_query(None); iterations_url.path_segments_mut().map_err(|_| "Invalid Azure DevOps URL.".to_string())?.push(&id.to_string()).push("changes"); iterations_url.query_pairs_mut().append_pair("$top", "2000").append_pair("api-version", "7.1");
if let Ok(changes) = client.get(iterations_url).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send() { if changes.status().is_success() { if let Ok(value) = changes.json::<serde_json::Value>() { payload["gitty_changed_files"] = serde_json::json!(value.get("changeEntries").and_then(serde_json::Value::as_array).map_or(0, Vec::len)); } } }
}
}
}
}
payload
}
_ => return Err("Unsupported integration provider.".to_string()),
};
match review.provider.as_str() {
"github" => { review.additions = payload.get("additions").and_then(serde_json::Value::as_u64); review.deletions = payload.get("deletions").and_then(serde_json::Value::as_u64); review.changed_files = payload.get("changed_files").and_then(serde_json::Value::as_u64); review.collaborators = review_names(&payload, "requested_reviewers", "login"); review.source_branch = value_string(&payload, &["head", "ref"]); review.target_branch = value_string(&payload, &["base", "ref"]); review.merge_status = match payload.get("mergeable").and_then(serde_json::Value::as_bool) { Some(true) => "mergeable", Some(false) => "conflicts", None => "checking" }.to_string(); }
"gitlab" | "gitlab-self-hosted" => { review.collaborators = review_names(&payload, "reviewers", "name"); review.changed_files = value_string(&payload, &["changes_count"]).parse().ok(); review.merge_status = match value_string(&payload, &["detailed_merge_status"]).as_str() { "mergeable" | "can_be_merged" => "mergeable", "conflict" | "conflicts" => "conflicts", "checking" | "unchecked" | "preparing" => "checking", _ => "blocked" }.to_string(); if let Some(diffs) = payload.get("gitty_diffs").and_then(serde_json::Value::as_array) { let mut plus=0; let mut minus=0; for line in diffs.iter().filter_map(|item| item.get("diff").and_then(serde_json::Value::as_str)).flat_map(str::lines) { if line.starts_with('+') && !line.starts_with("+++") { plus+=1; } else if line.starts_with('-') && !line.starts_with("---") { minus+=1; } } review.additions=Some(plus); review.deletions=Some(minus); review.changed_files=Some(diffs.len() as u64); } }
"gitea" => { review.additions=payload.get("additions").and_then(serde_json::Value::as_u64); review.deletions=payload.get("deletions").and_then(serde_json::Value::as_u64); review.changed_files=payload.get("changed_files").and_then(serde_json::Value::as_u64); review.collaborators=review_names(&payload,"requested_reviewers","login"); let source=value_string(&payload,&["head","ref"]); if !source.is_empty(){review.source_branch=source;} let target=value_string(&payload,&["base","ref"]); if !target.is_empty(){review.target_branch=target;} review.merge_status=payload.get("mergeable").and_then(serde_json::Value::as_bool).map(|ok|if ok{"mergeable"}else{"conflicts"}).unwrap_or("checking").to_string(); }
"azure-devops" => { review.collaborators=review_names(&payload,"reviewers","displayName"); review.changed_files=payload.get("gitty_changed_files").and_then(serde_json::Value::as_u64); review.merge_status=match value_string(&payload,&["mergeStatus"]).as_str(){"succeeded"=>"mergeable","conflicts"=>"conflicts","queued"|"notSet"=>"checking",_=>"blocked"}.to_string(); }
_ => {}
}
review.comments = review_comments(client, base_url, username, token, &review).unwrap_or_default();
Ok(review)
}
#[tauri::command]
pub async fn list_integration_review_requests(
provider: String,
base_url: String,
username: String,
token: String,
state: String,
) -> Result<Vec<IntegrationReviewRequest>, String> {
tokio::time::timeout(
REVIEW_REQUEST_TIMEOUT,
tauri::async_runtime::spawn_blocking(move || {
if token.trim().is_empty() {
return Err("No token is stored for this integration.".to_string());
}
let base_url = normalized_base_url(&base_url)?;
let client = client()?;
match provider.as_str() {
"github" => github_reviews(&client, &base_url, &token, &state),
"gitlab" | "gitlab-self-hosted" => {
gitlab_reviews(&client, &base_url, &token, &provider, &state)
}
"gitea" => gitea_reviews(&client, &base_url, &token, &state),
"azure-devops" => azure_reviews(
&client,
&base_url,
if username.trim().is_empty() {
"gitty"
} else {
&username
},
&token,
&state,
),
_ => Err("Unsupported integration provider.".to_string()),
}
}),
)
.await
.map_err(|_| "The integration API did not respond within 35 seconds.".to_string())?
.map_err(|err| format!("Could not load review requests: {err}"))?
}
#[tauri::command]
pub async fn run_integration_review_action(
provider: String,
base_url: String,
username: String,
token: String,
repository_id: String,
repository_name: String,
number: u64,
action: String,
merge_method: Option<String>,
cleanup_path: Option<String>,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
if token.trim().is_empty() { return Err("No token is stored for this integration.".to_string()); }
if !matches!(action.as_str(), "merge" | "approve" | "close" | "reopen") { return Err("Unsupported review action.".to_string()); }
if action == "merge" { merge_payload(&provider, merge_method.as_deref())?; }
let base_url = normalized_base_url(&base_url)?;
let client = client()?;
let cleanup = if action == "merge" {
cleanup_path.as_deref().map(|path| cleanup::prepare(&client, &base_url, &username, &token, &provider, &repository_id, &repository_name, number, path)).transpose()?
} else { None };
match provider.as_str() {
"github" => github_review_action(&client, &base_url, &token, &repository_name, number, &action, merge_method.as_deref()),
"gitlab" | "gitlab-self-hosted" => gitlab_review_action(&client, &base_url, &token, &repository_id, number, &action, merge_method.as_deref()),
"gitea" => gitea_review_action(&client, &base_url, &token, &repository_name, number, &action, merge_method.as_deref()),
"azure-devops" => azure_review_action(&client, &base_url, &username, &token, &repository_name, &repository_id, number, &action, merge_method.as_deref()),
_ => Err("Unsupported integration provider.".to_string()),
}?;
if let Some(cleanup) = cleanup {
cleanup::finish(&cleanup, &client, &base_url, &username, &token, &provider, &repository_id, &repository_name, number)
.map_err(|err| format!("MERGE_ACCEPTED_CLEANUP_FAILED: {err}"))?;
}
Ok(())
})
.await
.map_err(|err| format!("Could not update review request: {err}"))?
}
#[tauri::command]
pub async fn get_integration_review_details(provider: String, base_url: String, username: String, token: String, request: IntegrationReviewRequest) -> Result<IntegrationReviewRequest, String> {
tokio::time::timeout(REVIEW_REQUEST_TIMEOUT, tauri::async_runtime::spawn_blocking(move || {
if token.trim().is_empty() { return Err("No token is stored for this integration.".to_string()); }
let base_url = normalized_base_url(&base_url)?;
let client = client()?;
if provider != request.provider { return Err("Review provider does not match the selected integration.".to_string()); }
load_review_details(&client, &base_url, &username, &token, request)
})).await.map_err(|_| "The integration API did not respond within 35 seconds.".to_string())?
.map_err(|err| format!("Could not load review details: {err}"))?
}
#[tauri::command]
pub async fn add_integration_review_comment(provider:String,base_url:String,username:String,token:String,request:IntegrationReviewRequest,body:String)->Result<(),String>{
tokio::time::timeout(REVIEW_REQUEST_TIMEOUT,tauri::async_runtime::spawn_blocking(move||{
let body=body.trim(); if body.is_empty(){return Err("Comment cannot be empty.".to_string());} if body.chars().count()>100_000{return Err("Comment is too long.".to_string());}
if provider!=request.provider{return Err("Review provider does not match the selected integration.".to_string());}
let base_url=normalized_base_url(&base_url)?; let client=client()?; post_review_comment(&client,&base_url,&username,&token,&request,body)
})).await.map_err(|_|"The integration API did not respond within 35 seconds.".to_string())?.map_err(|err|format!("Could not add comment: {err}"))?
}
#[tauri::command]
pub fn open_in_browser(url: String) -> Result<(), String> {
let normalized = normalized_base_url(&url)?;
#[cfg(target_os = "windows")]
let mut command = {
let mut command = std::process::Command::new("rundll32");
command.args(["url.dll,FileProtocolHandler", &normalized]);
command
};
#[cfg(target_os = "macos")]
let mut command = {
let mut command = std::process::Command::new("open");
command.arg(&normalized);
command
};
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let mut command = {
let mut command = std::process::Command::new("xdg-open");
command.arg(&normalized);
command
};
command
.spawn()
.map(|_| ())
.map_err(|err| format!("Could not open the browser: {err}"))
}
#[tauri::command]
pub async fn list_integration_repositories(
provider: String,
base_url: String,
account_id: Option<String>,
) -> Result<Vec<IntegrationRepository>, String> {
tauri::async_runtime::spawn_blocking(move || {
let credential_key = integration_key(&provider, account_id.as_deref())?;
let credential = load_stored_credential(&credential_key)?
.ok_or_else(|| "No token is stored for this integration.".to_string())?;
let base_url = normalized_base_url(&base_url)?;
let client = client()?;
match provider.as_str() {
"github" => github_repositories(&client, &base_url, &credential.password),
"gitlab" | "gitlab-self-hosted" => {
gitlab_repositories(&client, &base_url, &credential.password)
}
"azure-devops" => azure_repositories(
&client,
&base_url,
if credential.username.trim().is_empty() {
"gitty"
} else {
&credential.username
},
&credential.password,
),
"gitea" => gitea_repositories(&client, &base_url, &credential.password),
_ => Err("Unsupported integration provider.".to_string()),
}
})
.await
.map_err(|err| format!("Could not load integration repositories: {err}"))?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_urls_are_normalized_and_validated() {
assert_eq!(
normalized_base_url(" https://gitlab.example.com/ ").unwrap(),
"https://gitlab.example.com"
);
assert!(normalized_base_url("gitlab.example.com").is_err());
assert_eq!(
github_api_base_url("https://github.com/").unwrap(),
"https://api.github.com"
);
assert!(github_api_base_url("https://github.example.com").is_err());
}
#[test]
fn integration_credential_keys_match_the_frontend() {
assert_eq!(
integration_key("github", None).unwrap(),
"integration:github"
);
assert_eq!(integration_key("gitea", None).unwrap(), "integration:gitea");
assert_eq!(
integration_key("azure-devops", Some("org-123")).unwrap(),
"integration:azure-devops:org-123"
);
assert_eq!(
integration_key("azure-devops", Some("default")).unwrap(),
"integration:azure-devops"
);
assert!(integration_key("azure-devops", Some("../invalid")).is_err());
}
#[test]
fn provider_repository_payloads_deserialize() {
let github: Vec<GitHubRepository> = serde_json::from_str(
r#"[{"id":6,"name":"desktop","full_name":"team/desktop","description":null,"clone_url":"https://github.com/team/desktop.git","ssh_url":"git@github.com:team/desktop.git","html_url":"https://github.com/team/desktop","updated_at":"2026-08-29T09:00:00Z","private":true}]"#,
)
.unwrap();
assert_eq!(github[0].full_name, "team/desktop");
assert!(github[0].description.is_none());
let gitlab: Vec<GitLabProject> = serde_json::from_str(
r#"[{"id":7,"name":"app","path_with_namespace":"team/app","description":"Demo","http_url_to_repo":"https://gitlab.test/team/app.git","ssh_url_to_repo":"git@gitlab.test:team/app.git","web_url":"https://gitlab.test/team/app","last_activity_at":"2026-08-29T10:00:00Z","visibility":"private"}]"#,
)
.unwrap();
assert_eq!(gitlab[0].path_with_namespace, "team/app");
assert_eq!(gitlab[0].visibility, "private");
let gitea: Vec<GiteaRepository> = serde_json::from_str(
r#"[{"id":8,"name":"api","full_name":"team/api","description":"","clone_url":"https://gitea.test/team/api.git","ssh_url":"git@gitea.test:team/api.git","html_url":"https://gitea.test/team/api","updated_at":"2026-08-29T11:00:00Z","private":false}]"#,
)
.unwrap();
assert_eq!(gitea[0].full_name, "team/api");
assert!(!gitea[0].private);
let azure: AzureRepositoryList = serde_json::from_str(
r#"{"value":[{"id":"repo-id","name":"web","project":{"name":"Platform"},"remoteUrl":"https://dev.azure.com/org/Platform/_git/web","sshUrl":"git@ssh.dev.azure.com:v3/org/Platform/web","webUrl":"https://dev.azure.com/org/Platform/_git/web"}]}"#,
)
.unwrap();
assert_eq!(azure.value[0].project.name, "Platform");
assert_eq!(
azure.value[0].remote_url,
"https://dev.azure.com/org/Platform/_git/web"
);
}
#[test]
fn provider_review_payloads_map_to_common_shape() {
let github: serde_json::Value = serde_json::from_str(r#"{
"number":42,"title":"Improve dashboard","body":"Details","state":"open",
"repository_url":"https://api.github.com/repos/team/app","html_url":"https://github.com/team/app/pull/42",
"user":{"login":"alex"},"pull_request":{"merged_at":null},"created_at":"2026-09-01","updated_at":"2026-09-02"
}"#).unwrap();
let review = parse_github_review(&github).unwrap();
assert_eq!(review.repository_name, "team/app");
assert_eq!(review.state, "open");
let gitlab: serde_json::Value = serde_json::from_str(r#"{
"iid":7,"project_id":9,"title":"Add bisect","state":"opened","draft":true,
"references":{"full":"team/app!7"},"author":{"name":"Sam"},
"source_branch":"feature","target_branch":"main","web_url":"https://gitlab.test/team/app/-/merge_requests/7"
}"#).unwrap();
let review = parse_gitlab_review(&gitlab, "gitlab-self-hosted");
assert_eq!(review.repository_name, "team/app");
assert_eq!(review.state, "draft");
let gitea: serde_json::Value = serde_json::from_str(r#"{
"number":3,"title":"Fix menu","state":"closed","repository":{"id":2,"full_name":"team/ui"},
"user":{"login":"dev"},"pull_request":{"merged_at":"2026-09-03"},"html_url":"https://gitea.test/team/ui/pulls/3"
}"#).unwrap();
let review = parse_gitea_review(&gitea).unwrap();
assert_eq!(review.state, "merged");
}
#[test]
fn azure_review_action_url_keeps_organization_and_encodes_project() {
let endpoint = azure_review_endpoint(
"https://dev.azure.com/example-org",
"Machine Kits/api",
"repo-id",
42,
)
.unwrap();
assert_eq!(
endpoint.as_str(),
"https://dev.azure.com/example-org/Machine%20Kits/_apis/git/repositories/repo-id/pullrequests/42?api-version=7.1"
);
}
}