feat(integrations): add review fetching and Linux keyring support
Add a cross-provider Review Center and improve credential handling. Backend integrations fetch and normalize PRs from GitHub, GitLab, Gitea, and Azure DevOps with improved timeouts and parsing. Credentials now use a global lock and support secret-tool on Linux to avoid races. - Normalize review data across providers (GitHub/GitLab/Gitea/Azure) - Serialize credential access with OnceLock and use secret-tool on Linux - Add frontend ReviewCenter component and related UI updates
This commit is contained in:
+147
-5
@@ -8,7 +8,7 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output, Stdio},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -2861,6 +2861,14 @@ fn push_args_with_http_1_1(push_args: Vec<OsString>) -> Vec<OsString> {
|
||||
// ── Credential storage (OS keychain) ────────────────────────────────────────
|
||||
|
||||
const CRED_SERVICE: &str = "tauri_git_lite";
|
||||
static CREDENTIAL_STORE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn credential_store_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
CREDENTIAL_STORE_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredCredential {
|
||||
@@ -2878,17 +2886,141 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
|
||||
}
|
||||
|
||||
pub(crate) fn load_stored_credential(key: &str) -> Result<Option<StoredCredential>, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn secret_tool_load(key: &str) -> Result<Option<String>, String> {
|
||||
let output = match Command::new("secret-tool")
|
||||
.args(["lookup", "service", CRED_SERVICE, "username", key])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return keyring_load(key),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Could not start the system keychain helper: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if output.status.success() {
|
||||
return String::from_utf8(output.stdout)
|
||||
.map(|value| Some(value.trim_end_matches(['\r', '\n']).to_string()))
|
||||
.map_err(|error| format!("System keychain returned unreadable data: {error}"));
|
||||
}
|
||||
if output.status.code() == Some(1) && output.stderr.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if detail.is_empty() {
|
||||
"System keychain lookup failed.".to_string()
|
||||
} else {
|
||||
format!("System keychain lookup failed: {detail}")
|
||||
})
|
||||
}
|
||||
|
||||
fn keyring_load(key: &str) -> Result<Option<String>, String> {
|
||||
let entry = cred_entry(key)?;
|
||||
match entry.get_password() {
|
||||
Ok(json) => serde_json::from_str::<StoredCredential>(&json)
|
||||
.map(Some)
|
||||
.map_err(|err| format!("Stored credentials unreadable: {err}")),
|
||||
Ok(json) => Ok(Some(json)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(format!("Keychain access failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn secret_tool_save(key: &str, value: &str) -> Result<(), String> {
|
||||
let mut child = match Command::new("secret-tool")
|
||||
.args([
|
||||
"store",
|
||||
"--label=Gitty credentials",
|
||||
"service",
|
||||
CRED_SERVICE,
|
||||
"username",
|
||||
key,
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return cred_entry(key)?
|
||||
.set_password(value)
|
||||
.map_err(|err| format!("Saving to keychain failed: {err}"));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Could not start the system keychain helper: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Could not open the system keychain helper input.".to_string())?
|
||||
.write_all(value.as_bytes())
|
||||
.map_err(|error| format!("Could not pass credentials to the system keychain: {error}"))?;
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|error| format!("System keychain helper failed: {error}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if detail.is_empty() {
|
||||
"Saving to the system keychain failed.".to_string()
|
||||
} else {
|
||||
format!("Saving to the system keychain failed: {detail}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn secret_tool_delete(key: &str) -> Result<(), String> {
|
||||
let output = match Command::new("secret-tool")
|
||||
.args(["clear", "service", CRED_SERVICE, "username", key])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return match cred_entry(key)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!("Deleting from keychain failed: {err}")),
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Could not start the system keychain helper: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if output.status.success() || output.status.code() == Some(1) {
|
||||
Ok(())
|
||||
} else {
|
||||
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if detail.is_empty() {
|
||||
"Deleting from the system keychain failed.".to_string()
|
||||
} else {
|
||||
format!("Deleting from the system keychain failed: {detail}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_stored_credential(key: &str) -> Result<Option<StoredCredential>, String> {
|
||||
let _guard = credential_store_lock();
|
||||
#[cfg(target_os = "linux")]
|
||||
let stored = secret_tool_load(key)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let stored = keyring_load(key)?;
|
||||
stored
|
||||
.map(|json| {
|
||||
serde_json::from_str::<StoredCredential>(&json)
|
||||
.map_err(|err| format!("Stored credentials unreadable: {err}"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Returns the remote URL used for auth key derivation (upstream remote of the
|
||||
/// current branch, falling back to `origin`, then the first configured remote).
|
||||
#[tauri::command(async)]
|
||||
@@ -3084,6 +3216,8 @@ pub fn cred_save(
|
||||
password: String,
|
||||
mode: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let _guard = credential_store_lock();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let entry = cred_entry(&key)?;
|
||||
let mode = match mode.as_deref() {
|
||||
Some("token") => Some("token".to_string()),
|
||||
@@ -3096,6 +3230,9 @@ pub fn cred_save(
|
||||
};
|
||||
let json = serde_json::to_string(&cred)
|
||||
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
|
||||
#[cfg(target_os = "linux")]
|
||||
return secret_tool_save(&key, &json);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
entry
|
||||
.set_password(&json)
|
||||
.map_err(|err| format!("Saving to keychain failed: {err}"))
|
||||
@@ -3103,7 +3240,12 @@ pub fn cred_save(
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn cred_delete(key: String) -> Result<(), String> {
|
||||
let _guard = credential_store_lock();
|
||||
#[cfg(target_os = "linux")]
|
||||
return secret_tool_delete(&key);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let entry = cred_entry(&key)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!("Deleting from keychain failed: {err}")),
|
||||
|
||||
@@ -2,9 +2,12 @@ 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;
|
||||
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -20,6 +23,46 @@ pub struct IntegrationRepository {
|
||||
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,
|
||||
@@ -118,7 +161,8 @@ fn integration_key(provider: &str, account_id: Option<&str>) -> Result<String, S
|
||||
|
||||
fn client() -> Result<Client, String> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(25))
|
||||
.connect_timeout(Duration::from_secs(7))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|err| format!("Could not initialize the integration client: {err}"))
|
||||
}
|
||||
@@ -350,6 +394,852 @@ fn azure_repositories(
|
||||
.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,
|
||||
) -> 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(&serde_json::json!({})),
|
||||
"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,
|
||||
) -> 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")),
|
||||
"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,
|
||||
) -> 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(&serde_json::json!({ "Do": "merge" })),
|
||||
"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,
|
||||
) -> 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()); }
|
||||
serde_json::json!({ "status": "completed", "lastMergeSourceCommit": { "commitId": commit_id } })
|
||||
}
|
||||
_ => 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,
|
||||
) -> 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()); }
|
||||
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),
|
||||
"gitlab" | "gitlab-self-hosted" => gitlab_review_action(&client, &base_url, &token, &repository_id, number, &action),
|
||||
"gitea" => gitea_review_action(&client, &base_url, &token, &repository_name, number, &action),
|
||||
"azure-devops" => azure_review_action(&client, &base_url, &username, &token, &repository_name, &repository_id, number, &action),
|
||||
_ => 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,
|
||||
@@ -454,4 +1344,47 @@ mod tests {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ use git::{
|
||||
stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree,
|
||||
unstage_files, untrack_paths, update_remote,
|
||||
};
|
||||
use integrations::list_integration_repositories;
|
||||
use integrations::{
|
||||
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
|
||||
run_integration_review_action,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, Manager};
|
||||
@@ -435,6 +438,11 @@ async fn main() {
|
||||
cred_save,
|
||||
cred_delete,
|
||||
list_integration_repositories,
|
||||
list_integration_review_requests,
|
||||
get_integration_review_details,
|
||||
add_integration_review_comment,
|
||||
run_integration_review_action,
|
||||
open_in_browser,
|
||||
set_sync_badge,
|
||||
close_splashscreen,
|
||||
set_telemetry_enabled,
|
||||
|
||||
Reference in New Issue
Block a user