Introduce dedicated merge handling for integration review merges: - Add src-tauri/src/integrations/merge.rs: implements merge_options (read provider repo settings), merge_payload (build provider-specific merge body) and a Tauri command get_integration_review_merge_options. Includes unit tests for behavior. - Wire merge module into integrations.rs and pass an optional merge_method into provider-specific review action functions (GitHub, GitLab, Gitea, Azure DevOps). run_integration_review_action now accepts an optional merge_method, validates it early, and includes provider-specific merge payloads when performing a merge. - Export the new command in src-tauri/src/main.rs so the frontend can request merge options. Frontend changes to support selecting a merge method before merging: - ConfirmDialog.svelte: add SelectMenu support and a select field to confirm requests. - ReviewCenter.svelte: fetch integration merge options, show a merge-method selector in the merge confirmation, and pass the chosen method to the review action. - Update types and git bindings to surface IntegrationMergeOptions / IntegrationMergeMethod and the getIntegrationReviewMergeOptions call (git.ts / types.ts changes staged). Effect: users can pick a merge method appropriate to the provider/project; the integration layer generates the correct API payload per provider. Tests added for merge logic.
1419 lines
64 KiB
Rust
1419 lines
64 KiB
Rust
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>,
|
|
) -> Result<(), 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()); }
|
|
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()?;
|
|
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()),
|
|
}
|
|
}),
|
|
)
|
|
.await
|
|
.map_err(|_| "The integration API did not respond within 35 seconds.".to_string())?
|
|
.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"
|
|
);
|
|
}
|
|
}
|