Files
GitLite/src-tauri/src/integrations.rs
T
Christoph 8e63f2a939 feat(integrations): add assignee/assignment support
Add a new integrations/assignees backend (src-tauri/src/integrations/assignees.rs)
and expose commands to list, read and set assignees:
- list_integration_assignees
- get_integration_assignees
- set_integration_assignees

Introduce IntegrationAssignee and AssignmentTarget types and provider-specific
URL/payload logic (GitHub, Gitea, GitLab / self-hosted, Azure DevOps). The code
handles pagination, provider quirks (Gitea legacy fields, GitLab assignee_ids,
Azure reviewer vs work-item differences) and validates/verifies assignments.
Unit tests cover routing and payload behavior.

Add UI components AssigneePicker.svelte and AssignmentEditor.svelte and update
CreateIssueDialog, CreateReviewDialog, IssueCenter, ReviewCenter, git types and
git.ts to use the new assignment functionality.
2026-09-22 10:21:57 +02:00

1427 lines
64 KiB
Rust

mod assignees;
pub use assignees::*;
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"
);
}
}