Merge pull request 'Review center' (#35) from review-center into main

This commit was merged in pull request #35.
This commit is contained in:
2026-09-07 14:40:38 +00:00
11 changed files with 1840 additions and 18 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ tauri-plugin-dialog = "=2.7.0"
tauri-plugin-aptabase = "1.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "time"] }
log = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
+147 -5
View File
@@ -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}")),
+934 -1
View File
@@ -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"
);
}
}
+9 -1
View File
@@ -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,
+60 -4
View File
@@ -6,11 +6,12 @@
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
import { beginFrontendShutdown } from "./lib/telemetry";
import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry";
import TitleBar from "./lib/TitleBar.svelte";
import RepoToolbar from "./lib/RepoToolbar.svelte";
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
import RepoTabs from "./lib/RepoTabs.svelte";
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
@@ -200,6 +201,7 @@
resolveDetectedExternalToolPrograms,
} from "./lib/externalTools";
import {
configuredIntegrationSources,
defaultGitIntegrationSettings,
integrationCredentialKey,
normaliseGitIntegrationSettings,
@@ -215,7 +217,7 @@
import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type AppView = "management" | "review-center" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
type CredentialMode = "credentials" | "token";
type PendingDiscard =
@@ -313,6 +315,8 @@
let unlistenStartupRepository: (() => void) | undefined;
let activeRepoPath = "";
let activeView: AppView = "management";
let reviewCenterInitialQuery = "";
let reviewCenterInitialSourceId = "";
let repoTabs: RepoTab[] = [];
let repoTabContextMenu: RepoTabContextMenu | null = null;
let recentRepoPaths: string[] = [];
@@ -468,6 +472,7 @@
let credDialogUsername = "";
let credDialogMode: CredentialMode = "credentials";
const rejectedCredentialKeys = new Set<string>();
const credentialCache = new Map<string, StoredCredential | null>();
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
@@ -581,6 +586,8 @@
// ── Lifecycle ──────────────────────────────────────────────────────────────
resumeFrontend();
onMount(() => {
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
@@ -610,7 +617,6 @@
});
onDestroy(() => {
handleAppShutdown();
themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
window.removeEventListener("beforeunload", handleAppShutdown);
window.removeEventListener("pagehide", handleAppShutdown);
@@ -1249,12 +1255,14 @@
const providerConfig = integrationsToSave.providers[update.provider];
if (update.removeToken) {
await credDelete(key);
credentialCache.set(key, null);
if (azureOrganization) azureOrganization.tokenStored = false;
else providerConfig.tokenStored = false;
} else if (update.token) {
const fallbackUsername = update.provider === "github" ? "x-access-token" : "oauth2";
const username = (azureOrganization?.username ?? providerConfig.username).trim() || fallbackUsername;
await credSave(key, username, update.token, "token");
credentialCache.set(key, { username, password: update.token, mode: "token" });
if (azureOrganization) azureOrganization.tokenStored = true;
else providerConfig.tokenStored = true;
}
@@ -2581,6 +2589,15 @@
void backgroundRepoStatusTick(false);
}
function openReviewCenter(repository = "", sourceId = "") {
if (isBusy) return;
closeRepoTabContextMenu();
reviewCenterInitialQuery = repository;
reviewCenterInitialSourceId = sourceId;
activeView = "review-center";
trackEvent("review_center_opened", { integrations: Object.values(gitIntegrationSettings.providers).filter((provider) => provider.enabled && provider.tokenStored).length });
}
async function selectRepoTab(path: string) {
if (isBusy) return;
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
@@ -3653,8 +3670,34 @@
async function loadStoredCredential(key: string | null): Promise<StoredCredential | null> {
if (!key) return null;
if (credentialCache.has(key)) return credentialCache.get(key) ?? null;
const repoHost = key.startsWith("integration:") ? "" : key.split("/", 1)[0].toLowerCase();
const integrationAliases = repoHost
? configuredIntegrationSources(gitIntegrationSettings)
.filter((source) => {
try { return new URL(source.baseUrl).host.toLowerCase() === repoHost; }
catch { return false; }
})
.map((source) => integrationCredentialKey(source.provider, source.accountId))
: [];
const candidates = [...new Set([...integrationAliases, key])];
try {
return await credLoad(key);
for (const candidate of candidates) {
const cached = credentialCache.get(candidate);
if (cached) {
credentialCache.set(key, cached);
return cached;
}
if (credentialCache.has(candidate)) continue;
const credential = await credLoad(candidate);
credentialCache.set(candidate, credential);
if (credential) {
credentialCache.set(key, credential);
return credential;
}
}
credentialCache.set(key, null);
return null;
} catch {
return null;
}
@@ -3931,6 +3974,7 @@
if (save && key) {
try {
await credSave(key, username, password, mode);
credentialCache.set(key, { username, password, mode });
} catch (error) {
credDialogError = errorToMessage(error);
return;
@@ -5138,6 +5182,7 @@
{isBusy}
language={appLanguage}
onOpenManagement={openRepoManagement}
onOpenReviewCenter={() => openReviewCenter()}
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
onSelect={selectRepoTab}
onClose={closeRepoTab}
@@ -5302,10 +5347,21 @@
{#if activeView === "management"}
<RepositoryDashboard
repos={dashboardRepos} language={appLanguage} {isBusy}
integrations={gitIntegrationSettings} loadCredential={loadStoredCredential}
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
onInit={initializeRepository}
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
onRemoveRecent={removeRepoFromRecent}
onOpenPullRequests={(repository, sourceId) => openReviewCenter(repository, sourceId)}
/>
{:else if activeView === "review-center"}
<ReviewCenter
language={appLanguage}
integrations={gitIntegrationSettings}
initialQuery={reviewCenterInitialQuery}
initialSourceId={reviewCenterInitialSourceId}
loadCredential={loadStoredCredential}
onOpenSettings={() => { appSettingsOpen = true; }}
/>
{:else}
<!-- Workspace -->
+9 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Folder, GitBranch, Plus, X } from "@lucide/svelte";
import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
interface RepositoryTabItem {
path: string;
@@ -7,11 +7,12 @@
branch: string | null;
}
export let activeView: "management" | "repository" = "management";
export let activeView: "management" | "review-center" | "repository" = "management";
export let repoTabs: RepositoryTabItem[] = [];
export let isBusy: boolean = false;
export let language: "en" | "de" = "en";
export let onOpenManagement: () => void = () => {};
export let onOpenReviewCenter: () => void = () => {};
export let isActive: (path: string) => boolean = () => false;
export let onSelect: (path: string) => void | Promise<void> = () => {};
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
@@ -33,6 +34,12 @@
</button>
<div class="repo-tabs-scroll">
<div class="repo-tab-wrap review-center-tab" class:active={activeView === "review-center"} role="presentation">
<button class="repo-tab" type="button" onclick={onOpenReviewCenter} disabled={isBusy} title="Review Center" aria-label="Review Center">
<GitPullRequest size={13} aria-hidden="true" />
<span>PR</span>
</button>
</div>
{#each repoTabs as repo (repo.path)}
<div
class="repo-tab-wrap"
+164 -4
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, Plus, Search, Star, Trash2, X } from "@lucide/svelte";
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
import { listIntegrationReviewRequests, listRemotes } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface DashboardRepository {
@@ -17,6 +20,15 @@
}
interface Workspace { id: string; name: string; }
interface PullRequestBadgeState {
status: "loading" | "ready" | "error" | "unavailable";
count: number;
sourceId: string;
repository: string;
message: string;
}
interface RemoteIdentity { host: string; repository: string; organization: string; }
export let repos: DashboardRepository[] = [];
export let language: "de" | "en" = "en";
@@ -28,6 +40,9 @@
export let onInit: () => unknown;
export let onFavorite: (path: string) => unknown;
export let onRemoveRecent: (path: string) => unknown;
export let integrations: GitIntegrationSettings;
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
const STORAGE_KEY = "gitty.dashboard.v1";
let query = "";
@@ -40,6 +55,9 @@
let workspaceName = "";
let workspaceError = "";
let workspaceSelection = new Set<string>();
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
let pullRequestLoadGeneration = 0;
let lastPullRequestLoadKey = "";
$: de = language === "de";
$: normalizedQuery = query.trim().toLocaleLowerCase();
@@ -60,6 +78,15 @@
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
];
$: integrationSources = configuredIntegrationSources(integrations);
$: pullRequestLoadKey = JSON.stringify({
repositories: repos.map((repo) => repo.path),
sources: integrationSources.map((source) => [source.id, source.baseUrl]),
});
$: if (pullRequestLoadKey !== lastPullRequestLoadKey) {
lastPullRequestLoadKey = pullRequestLoadKey;
void loadPullRequestBadges();
}
loadPreferences();
@@ -135,6 +162,135 @@
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(path); }
function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
function removeRecentCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onRemoveRecent(path); }
function openPullRequests(event: MouseEvent, repo: DashboardRepository) {
event.stopPropagation();
const badge = pullRequestBadges[repo.path];
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
}
function remoteIdentity(value: string): RemoteIdentity | null {
const trimmed = value.trim();
if (!trimmed) return null;
let host = "";
let path = "";
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
if (scp) {
host = scp[1];
path = scp[2];
} else {
try {
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
host = url.hostname;
path = url.pathname;
} catch {
return null;
}
}
const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
const normalizedHost = host.toLocaleLowerCase();
if (gitIndex > 0 && parts[gitIndex + 1]) return { host: normalizedHost, repository: `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`.toLocaleLowerCase(), organization: normalizedHost === "dev.azure.com" ? (parts[0] ?? "").toLocaleLowerCase() : "" };
if (parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4) return { host: normalizedHost, repository: `${parts[2]}/${parts[3]}`.toLocaleLowerCase(), organization: parts[1].toLocaleLowerCase() };
return { host: normalizedHost, repository: parts.join("/").toLocaleLowerCase(), organization: "" };
}
function sourceMatchesRemote(source: GitIntegrationSource, identity: RemoteIdentity): boolean {
try {
const sourceUrl = new URL(source.baseUrl);
const sourceHost = sourceUrl.hostname.toLocaleLowerCase();
const remoteHost = identity.host;
if (source.provider === "azure-devops" ? sourceHost.replace(/^ssh\./, "") !== remoteHost.replace(/^ssh\./, "") : sourceHost !== remoteHost) return false;
if (source.provider !== "azure-devops") return true;
const organization = sourceUrl.hostname.toLocaleLowerCase() === "dev.azure.com"
? sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLocaleLowerCase() ?? ""
: sourceUrl.hostname.split(".")[0]?.toLocaleLowerCase() ?? "";
return !organization || !identity.organization || organization === identity.organization;
} catch {
return false;
}
}
function requestMatchesRepository(request: IntegrationReviewRequest, repository: string): boolean {
const requestRepository = request.repositoryName.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
if (!requestRepository) return false;
if (requestRepository === repository || repository.endsWith(`/${requestRepository}`) || requestRepository.endsWith(`/${repository}`)) return true;
const requestParts = requestRepository.split("/");
const remoteParts = repository.split("/");
return requestParts[requestParts.length - 1] === remoteParts[remoteParts.length - 1];
}
function updatePullRequestBadge(path: string, state: PullRequestBadgeState, generation: number) {
if (generation !== pullRequestLoadGeneration) return;
pullRequestBadges = { ...pullRequestBadges, [path]: state };
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
});
}
async function loadPullRequestBadges() {
const generation = ++pullRequestLoadGeneration;
const sources = integrationSources;
pullRequestBadges = Object.fromEntries(repos.map((repo) => [repo.path, {
status: sources.length ? "loading" : "unavailable",
count: 0,
sourceId: "",
repository: "",
message: sources.length ? "" : (de ? "Keine passende Integration eingerichtet." : "No matching integration configured."),
} satisfies PullRequestBadgeState]));
if (!repos.length || !sources.length) return;
const grouped = new Map<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
await Promise.all(repos.map(async (repo) => {
try {
const remotes = await listRemotes(repo.path);
const identities = remotes
.sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"))
.map((remote) => remoteIdentity(remote.fetch_url))
.filter((identity): identity is RemoteIdentity => Boolean(identity));
const match = identities.map((identity) => ({ identity, source: sources.find((source) => sourceMatchesRemote(source, identity)) })).find((item) => item.source);
if (!match?.source) {
updatePullRequestBadge(repo.path, { status: "unavailable", count: 0, sourceId: "", repository: "", message: de ? "Keine passende Integration für das Remote gefunden." : "No matching integration for this remote." }, generation);
return;
}
grouped.set(match.source.id, [...(grouped.get(match.source.id) ?? []), { repo, identity: match.identity }]);
} catch (error) {
updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId: "", repository: "", message: error instanceof Error ? error.message : String(error) }, generation);
}
}));
await Promise.all([...grouped.entries()].map(async ([sourceId, entries]) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000, de ? "Der System-Schlüsselbund hat nicht geantwortet." : "The system keychain did not respond.");
if (!credential?.password) throw new Error(de ? "Kein Integrationstoken gespeichert." : "No integration token stored.");
const requests = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, "open"), 46_000, de ? `${source.label} hat nicht geantwortet.` : `${source.label} did not respond.`);
for (const { repo, identity } of entries) {
updatePullRequestBadge(repo.path, {
status: "ready",
count: requests.filter((request) => requestMatchesRepository(request, identity.repository)).length,
sourceId,
repository: identity.repository,
message: "",
}, generation);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
for (const { repo, identity } of entries) updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId, repository: identity.repository, message }, generation);
}
}));
}
function pullRequestTitle(repo: DashboardRepository): string {
const badge = pullRequestBadges[repo.path];
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
}
function toggleCategory(id: string) {
const next = new Set(collapsedCategories);
if (next.has(id)) next.delete(id);
@@ -188,6 +344,7 @@
{#if category.repos.length > 0}
<div class="repo-grid">
{#each category.repos as repo (repo.path)}
{@const pullRequests = pullRequestBadges[repo.path]}
<article class="repo-card" class:open={repo.isOpen}>
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy} title={repo.path}>
<span class="card-title"><FolderGit2 size={15} /><strong>{repo.name}</strong></span>
@@ -202,6 +359,9 @@
</span>
</button>
<div class="card-actions">
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(repo)}`} title={pullRequestTitle(repo)}>
{#if !pullRequests || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else if pullRequests.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests.status === "ready" ? pullRequests.count : ""}</span>{/if}
</button>
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
{#if repo.isOpen}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
{#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
@@ -237,10 +397,10 @@
<style>
.repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5}
.dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff}
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
.dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:12px}.section-header{min-height:31px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)}
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 70px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)}
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 104px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)}
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,transparent)}
.dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
@media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
+457
View File
@@ -0,0 +1,457 @@
<script lang="ts">
import { onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import {
AlertTriangle, Check, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown,
CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
} from "@lucide/svelte";
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
import type { AppLanguage, GitIntegrationSettings, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
interface Props {
language: AppLanguage;
integrations: GitIntegrationSettings;
initialQuery?: string;
initialSourceId?: string;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onOpenSettings: () => void;
}
let { language = "en", integrations, initialQuery = "", initialSourceId = "", loadCredential, onOpenSettings = () => {} }: Props = $props();
let requests = $state<IntegrationReviewRequest[]>([]);
let loading = $state(false);
let errors = $state<Array<{ source: string; message: string }>>([]);
let query = $state("");
let stateFilter = $state<IntegrationReviewState>("open");
let selectedSourceId = $state("");
let selectedId = $state("");
let detailOpen = $state(false);
let actionMenuId = $state("");
let actionBusyId = $state("");
let actionNotice = $state("");
let detailLoadingId = $state("");
let commentDraft = $state("");
let commentPosting = $state(false);
let collapsedRepositories = $state(new Set<string>());
let loadGeneration = 0;
let loadedStates = $state(new Set<"open" | "merged" | "closed">());
const SOURCE_TIMEOUT_MS = 46_000;
const de = $derived(language === "de");
const sources = $derived(configuredIntegrationSources(integrations));
const activeSource = $derived(sources.find((source) => source.id === selectedSourceId) ?? null);
const normalizedQuery = $derived(query.trim().toLocaleLowerCase());
const filtered = $derived(requests.filter((request) => {
const haystack = `${request.title} ${request.repositoryName} ${request.author} ${request.number} ${request.sourceBranch} ${request.targetBranch}`.toLocaleLowerCase();
return request.state === stateFilter && (!normalizedQuery || haystack.includes(normalizedQuery));
}));
const groupedRequests = $derived.by(() => {
const groups = new Map<string, IntegrationReviewRequest[]>();
for (const request of filtered) {
const key = request.repositoryName || providerLabel(request.provider);
groups.set(key, [...(groups.get(key) ?? []), request]);
}
return [...groups.entries()].map(([name, items]) => ({ name, items }));
});
const selected = $derived(requests.find((request) => request.id === selectedId) ?? null);
const backendRestartRequired = $derived(errors.some((error) => /command.*not found|unknown command|not registered/i.test(error.message)));
onMount(() => {
const closeActionMenu = () => { actionMenuId = ""; };
window.addEventListener("click", closeActionMenu);
query = initialQuery;
const source = sources.find((candidate) => candidate.id === initialSourceId) ?? sources[0];
if (source) {
selectedSourceId = source.id;
void loadRequests("open", source.id);
}
return () => window.removeEventListener("click", closeActionMenu);
});
function withTimeout<T>(promise: Promise<T>, source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise<T> {
return new Promise<T>((resolve, reject) => {
const seconds = Math.round(timeoutMs / 1_000);
const timer = window.setTimeout(() => reject(new Error(de ? `${source} hat nach ${seconds} Sekunden nicht geantwortet.` : `${source} did not respond within ${seconds} seconds.`)), timeoutMs);
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
});
}
async function loadRequests(state: "open" | "merged" | "closed" = stateFilter === "draft" ? "open" : stateFilter, sourceId = selectedSourceId) {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return;
const generation = ++loadGeneration;
loading = true;
errors = [];
requests = requests.filter((request) => state === "open" ? request.state !== "open" && request.state !== "draft" : request.state !== state);
selectedId = "";
detailOpen = false;
actionMenuId = "";
actionNotice = "";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
const loaded = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, state), source.label);
if (generation !== loadGeneration) return;
requests = [...requests, ...loaded].sort((left, right) => Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt));
loadedStates = new Set([...loadedStates, state]);
const missingBranches = loaded.filter((request) => !request.sourceBranch || !request.targetBranch);
if (missingBranches.length) void enrichMissingBranches(missingBranches, source, credential, generation);
} catch (error) {
if (generation !== loadGeneration) return;
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally {
if (generation === loadGeneration) loading = false;
}
}
async function enrichMissingBranches(items: IntegrationReviewRequest[], source: (typeof sources)[number], credential: StoredCredential, generation: number) {
const queue = [...items];
const worker = async () => {
while (queue.length && generation === loadGeneration) {
const request = queue.shift();
if (!request) return;
try {
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
if (generation !== loadGeneration) return;
requests = requests.map((item) => item.id === details.id ? details : item);
} catch {
// Keep the request visible; selecting it retries the detail request and surfaces the error.
}
}
};
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, worker));
}
function selectSource(sourceId: string) {
if (sourceId === selectedSourceId) return;
selectedSourceId = sourceId;
stateFilter = "open";
requests = [];
errors = [];
selectedId = "";
detailOpen = false;
actionMenuId = "";
collapsedRepositories = new Set();
loadedStates = new Set();
void loadRequests("open", sourceId);
}
function selectState(state: IntegrationReviewState) {
stateFilter = state;
selectedId = "";
detailOpen = false;
const apiState = state === "draft" ? "open" : state;
if (!loadedStates.has(apiState)) void loadRequests(apiState);
}
function count(state: IntegrationReviewState): number {
return requests.filter((request) => request.state === state).length;
}
function countLabel(state: IntegrationReviewState): string {
return loadedStates.has(state === "draft" ? "open" : state) ? String(count(state)) : "";
}
function stateLabel(state: IntegrationReviewState): string {
if (state === "draft") return "Draft";
if (state === "merged") return de ? "Zusammengeführt" : "Merged";
if (state === "closed") return de ? "Geschlossen" : "Closed";
return "Open";
}
function formatDate(value: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function formatRelativeDate(value: string): string {
if (!value) return "";
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return "";
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) return de ? "gerade eben" : "just now";
if (minutes < 60) return de ? `vor ${minutes} Min.` : `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return de ? `vor ${hours} Std.` : `${hours}h ago`;
const days = Math.round(hours / 24);
return de ? `vor ${days} T.` : `${days}d ago`;
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : parts[0].slice(0, 2)).toLocaleUpperCase();
}
function actionLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "azure-devops" ? "Open in Azure" : `Open in ${providerLabel(provider)}`;
}
function requestTypeLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "gitlab" || provider === "gitlab-self-hosted" ? "Merge Request" : "Pull Request";
}
function toggleRepository(name: string) {
const next = new Set(collapsedRepositories);
if (next.has(name)) next.delete(name);
else next.add(name);
collapsedRepositories = next;
}
function collapseAll() {
collapsedRepositories = new Set(groupedRequests.map((group) => group.name));
}
function expandAll() {
collapsedRepositories = new Set();
}
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
if (selectedId !== request.id) commentDraft = "";
selectedId = request.id;
if (showDetail) detailOpen = true;
if (request.changedFiles === null && detailLoadingId !== request.id) void loadDetails(request);
}
function openDetail(request: IntegrationReviewRequest) {
selectRequest(request, true);
}
async function loadDetails(request: IntegrationReviewRequest) {
const source = activeSource;
if (!source) return;
detailLoadingId = request.id;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) return;
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label);
requests = requests.map((item) => item.id === details.id ? details : item);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally { detailLoadingId = ""; }
}
function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; }
async function runPrimaryAction(request: IntegrationReviewRequest) {
if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge");
else if (request.state === "closed") await performReviewAction(request, "reopen");
else await openRequest(request);
}
function primaryActionLabel(request: IntegrationReviewRequest): string {
if (hasConflicts(request)) return de ? "Konflikt" : "Conflict";
if (request.state === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge";
if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen";
return actionLabel(request.provider);
}
async function openRequest(request: IntegrationReviewRequest | null = selected) {
if (!request?.webUrl) return;
try { await openInBrowser(request.webUrl); }
catch (error) { errors = [{ source: providerLabel(request.provider), message: error instanceof Error ? error.message : String(error) }]; }
}
function reviewActionLabel(action: IntegrationReviewAction): string {
if (action === "merge") return de ? "Zusammenführen" : "Merge";
if (action === "approve") return de ? "Freigeben" : "Approve";
if (action === "close") return de ? "Request schließen" : "Close request";
return de ? "Request wieder öffnen" : "Reopen request";
}
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
const source = activeSource;
if (!source || actionBusyId) return;
if (action !== "approve") {
const prompt = action === "merge"
? (de ? `Request #${request.number} wirklich zusammenführen?` : `Merge request #${request.number}?`)
: action === "close"
? (de ? `Request #${request.number} wirklich schließen?` : `Close request #${request.number}?`)
: (de ? `Request #${request.number} wieder öffnen?` : `Reopen request #${request.number}?`);
if (!window.confirm(prompt)) return;
}
actionMenuId = "";
actionNotice = "";
actionBusyId = request.id;
errors = [];
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action), source.label);
actionNotice = de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`;
requests = [];
loadedStates = new Set();
await loadRequests(stateFilter === "draft" ? "open" : stateFilter);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally {
actionBusyId = "";
}
}
async function submitComment() {
const request = selected;
const source = activeSource;
const body = commentDraft.trim();
if (!request || !source || !body || commentPosting) return;
commentPosting = true;
errors = [];
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
await withTimeout(addIntegrationReviewComment(source.provider, source.baseUrl, credential.username, credential.password, request, body), source.label);
commentDraft = "";
await loadDetails(request);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally { commentPosting = false; }
}
</script>
<section class="review-center" aria-label="Review Center">
<header class="review-header">
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1><span>{activeSource?.label ?? ""}</span></div>
</header>
{#if sources.length === 0}
<div class="empty-state"><div class="empty-icon"><GitPullRequest size={23} /></div><h2>{de ? "Keine Integration eingerichtet" : "No integration configured"}</h2><p>{de ? "Verbinde GitHub, GitLab, Gitea oder Azure DevOps, um Pull- und Merge-Requests hier zu sehen." : "Connect GitHub, GitLab, Gitea, or Azure DevOps to see pull and merge requests here."}</p><button class="primary" type="button" onclick={onOpenSettings}><Settings2 size={14} />{de ? "Integrationen öffnen" : "Open integrations"}</button></div>
{:else}
<nav class="state-tabs" aria-label={de ? "Request-Status" : "Request status"}>
{#each ["open", "draft", "merged", "closed"] as state}
<button class:active={stateFilter === state} type="button" onclick={() => selectState(state as IntegrationReviewState)}>{stateLabel(state as IntegrationReviewState)}<span>{countLabel(state as IntegrationReviewState)}</span></button>
{/each}
</nav>
<div class="review-toolbar">
<div class="group-actions">
<button type="button" onclick={collapseAll}><ChevronsDownUp size={14} />{de ? "Alle einklappen" : "Collapse all"}</button>
<button type="button" onclick={expandAll}><ChevronsUpDown size={14} />{de ? "Alle ausklappen" : "Expand all"}</button>
<button class="icon-button" title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"} type="button" onclick={() => loadRequests()} disabled={loading}><RefreshCw class={loading ? "spin" : ""} size={14} /></button>
</div>
<label class="review-search"><Search size={14} /><input bind:value={query} placeholder={de ? "Requests durchsuchen …" : "Search requests …"} /></label>
<select value={selectedSourceId} onchange={(event) => selectSource(event.currentTarget.value)} aria-label={de ? "Integration auswählen" : "Select integration"}>
{#each sources as source}<option value={source.id}>{source.label}</option>{/each}
</select>
</div>
{#if actionNotice}<div class="action-notice"><Check size={13} />{actionNotice}</div>{/if}
{#if errors.length > 0}
<details class="source-warning" open>
<summary><AlertTriangle size={14} /><span>{backendRestartRequired ? (de ? "Gitty muss neu gestartet werden, damit das Review-Backend geladen wird." : "Restart Gitty to load the Review Center backend.") : (de ? "Die ausgewählte Integration konnte nicht geladen werden." : "The selected integration could not be loaded.")}</span></summary>
<ul>{#each errors as error}<li><strong>{error.source}:</strong> {error.message}</li>{/each}</ul>
</details>
{/if}
<div class:inspector-open={detailOpen && selected} class="review-layout">
<main class="table-pane">
<div class="table-head">
<span>Status</span><span>{de ? "Request" : "Request"}</span><span>{de ? "Autor" : "Author"}</span><span>Collaborators</span><span>Repo/Branch</span><span>{de ? "Aktion" : "Action"}</span>
</div>
{#if loading && requests.length === 0}
<div class="loading-state"><LoaderCircle class="spin" size={17} />{de ? "Requests werden geladen …" : "Loading requests …"}</div>
{:else if groupedRequests.length === 0}
<div class="list-empty"><Inbox size={20} /><strong>{de ? "Keine passenden Requests" : "No matching requests"}</strong><span>{de ? "Passe Status oder Suche an." : "Adjust the status or search."}</span></div>
{:else}
<div class="request-groups">
{#each groupedRequests as group (group.name)}
<section class="request-group">
<button class="group-header" type="button" onclick={() => toggleRepository(group.name)}>
{#if collapsedRepositories.has(group.name)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
<GitBranch size={13} /><strong>{group.name}</strong><span>{group.items.length}</span>
</button>
{#if !collapsedRepositories.has(group.name)}
{#each group.items as request (request.id)}
<div class:selected={selectedId === request.id} class="request-row" role="button" tabindex="0" onclick={() => selectRequest(request)} onkeydown={(event) => { if (event.key === "Enter") selectRequest(request); }}>
<span class="request-status" class:open={request.state === "open"} class:draft={request.state === "draft"} class:merged={request.state === "merged"} class:closed={request.state === "closed"}>
{#if request.state === "merged"}<GitMerge size={13} />{:else if request.state === "closed"}<XCircle size={13} />{:else if request.state === "draft"}<CircleDotDashed size={13} />{:else}<GitPullRequest size={13} />{/if}
<span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span>
</span>
<span class="request-title"><span><code>#{request.number}</code><strong title={request.title}>{request.title}</strong></span><small class="change-stats"><b>+{request.additions ?? ""}</b><i>/</i><em>{request.deletions ?? ""}</em>{#if request.changedFiles !== null}<span title={de ? "Geänderte Dateien" : "Changed files"}>{request.changedFiles} {de ? "Dateien" : "files"}</span>{:else if detailLoadingId === request.id}<span>{de ? "Lädt …" : "Loading …"}</span>{/if}</small></span>
<span class="request-author"><i>{initials(request.author)}</i><span>{request.author || (de ? "Unbekannt" : "Unknown")}</span></span>
<span class="collaborators">{#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}<i title={collaborator}>{initials(collaborator)}</i>{/each}{:else}<span></span>{/if}</span>
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b></b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
<span class="row-actions">
<span class:merge-action={request.state === "open" || request.state === "draft"} class:conflict-action={hasConflicts(request)} class="provider-action">
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={hasConflicts(request) || !!actionBusyId} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
<button class="action-toggle" class:active={actionMenuId === request.id} type="button" aria-label={de ? "Weitere Aktionen" : "More actions"} onclick={(event) => { event.stopPropagation(); actionMenuId = actionMenuId === request.id ? "" : request.id; }}><ChevronDown size={13} /></button>
{#if actionMenuId === request.id}
<span class="action-menu" role="menu" tabindex="-1">
<button type="button" role="menuitem" onclick={() => openDetail(request)}><PanelRightOpen size={13} />{de ? "Request prüfen" : "Review request"}</button>
{#if request.state === "open" || request.state === "draft"}
<button type="button" role="menuitem" disabled={actionBusyId === request.id || hasConflicts(request)} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{hasConflicts(request) ? (de ? "Merge-Konflikt" : "Merge conflict") : reviewActionLabel("merge")}</button>
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "approve")}><Check size={13} />{reviewActionLabel("approve")}</button>
<button class="danger" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "close")}><XCircle size={13} />{reviewActionLabel("close")}</button>
{:else if request.state === "closed"}
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "reopen")}><RotateCcw size={13} />{reviewActionLabel("reopen")}</button>
{/if}
</span>
{/if}
</span>
<button class="panel-button" class:active={detailOpen && selectedId === request.id} title={de ? "Detailpanel öffnen" : "Open detail panel"} aria-label={de ? "Detailpanel öffnen" : "Open detail panel"} type="button" onclick={(event) => { event.stopPropagation(); openDetail(request); }}><PanelRightOpen size={14} /></button>
</span>
</div>
{/each}
{/if}
</section>
{/each}
</div>
{/if}
</main>
{#if detailOpen && selected}
<aside class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}>
<header class="detail-header">
<div class="detail-provider"><GitPullRequest size={17} /><strong>{providerLabel(selected.provider)} {requestTypeLabel(selected.provider)}</strong></div>
<div class="detail-header-actions"><button class="icon-button" type="button" aria-label={de ? "Im Anbieter öffnen" : "Open in provider"} onclick={() => void openRequest()}><ExternalLink size={16} /></button><button class="icon-button" type="button" aria-label={de ? "Detailansicht schließen" : "Close details"} onclick={() => { detailOpen = false; }}><X size={17} /></button></div>
</header>
<div class="detail-content">
<main class="detail-main">
<section class="detail-title"><span>#{selected.number}</span><h2>{selected.title}</h2><div class="detail-summary"><span class="state-badge" class:open={selected.state === "open"} class:draft={selected.state === "draft"} class:merged={selected.state === "merged"} class:closed={selected.state === "closed"}>{stateLabel(selected.state)}</span><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong><span>{de ? "möchte" : "wants to merge"}</span></div><div class="branch-detail"><GitBranch size={15} />{#if selected.sourceBranch && selected.targetBranch}<code>{selected.sourceBranch}</code><span></span><code>{selected.targetBranch}</code>{:else}<span>{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</div></section>
<section class="description"><h3>{de ? "Beschreibung" : "Description"}</h3><p class:muted={!selected.description}>{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</p></section>
<section class="comments-section"><header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header><div class:conflict={hasConflicts(selected)} class="merge-summary">{#if detailLoadingId === selected.id}<LoaderCircle class="spin" size={14} /><span>{de ? "Merge-Status wird geladen …" : "Loading merge status …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<Check size={14} /><span>{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}</span>{/if}</div>{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><span class="avatar">{initials(comment.author)}</span><div><header><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time></header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}<form class="comment-composer" onsubmit={(event) => { event.preventDefault(); void submitComment(); }}><span class="avatar">{initials(selected.author)}</span><div><textarea bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><button type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></div></form></section>
</main>
<aside class="detail-sidebar">
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId || hasConflicts(selected)} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{hasConflicts(selected) ? (de ? "Merge-Konflikt" : "Merge conflict") : reviewActionLabel("merge")}</button><button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span></div></section>
</aside>
</div>
</aside>
{/if}
</div>
{/if}
</section>
<style>
.review-center{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}.review-center button,.review-center input,.review-center select{font:inherit}.review-header{display:flex;min-height:48px;align-items:center;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.review-heading{display:flex;align-items:center;gap:9px}.review-heading>:global(svg){color:var(--color-accent)}.review-heading h1{margin:0;font-size:16px;font-weight:650}.review-heading span{margin-left:3px;padding-left:12px;border-left:1px solid var(--color-border);color:var(--color-ink-faint);font-size:10.5px}
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}.review-toolbar select{width:190px;height:30px;padding:0 28px 0 9px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}
.source-warning{padding:7px 18px;border-bottom:1px solid color-mix(in srgb,#d3a64d 28%,var(--color-border));color:#d3a64d;background:color-mix(in srgb,#d3a64d 6%,var(--app-bg))}.source-warning summary{display:flex;align-items:center;gap:7px;cursor:pointer}.source-warning ul{display:grid;gap:4px;margin:7px 0 2px;padding-left:22px;color:var(--color-ink-muted);font-size:10.5px}.source-warning li strong{color:var(--color-ink)}.action-notice{display:flex;min-height:30px;align-items:center;gap:7px;padding:0 18px;border-bottom:1px solid color-mix(in srgb,#63c783 28%,var(--color-border));color:#63c783;background:color-mix(in srgb,#63c783 6%,var(--app-bg));font-size:10.5px}
.review-layout{display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}.review-layout.inspector-open{grid-template-columns:minmax(560px,1fr) minmax(360px,38%)}.table-pane{min-width:0;min-height:0;overflow:auto}.table-head,.request-row{display:grid;grid-template-columns:95px minmax(280px,1.5fr) 135px 145px minmax(210px,1fr) 190px;align-items:center}.table-head{position:sticky;z-index:2;top:0;min-height:30px;padding:0 12px;border-bottom:1px solid var(--color-border);color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px;font-weight:700;letter-spacing:.035em;text-transform:uppercase}.request-groups{min-width:1075px}.request-group{border-bottom:1px solid var(--color-border-subtle)}.group-header{display:flex;width:100%;height:34px;align-items:center;justify-content:flex-start;gap:7px;padding:0 12px;border:0;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink);background:var(--color-surface);text-align:left}.group-header:hover{background:var(--color-surface-hover)}.group-header>:global(svg){color:var(--color-ink-faint)}.group-header strong{font-size:11px}.group-header>span{margin-left:2px}.request-row{position:relative;min-height:52px;padding:0 12px;border-bottom:1px solid var(--color-border-subtle);outline:1px solid transparent;outline-offset:-1px;color:var(--color-ink-muted);background:transparent;cursor:default}.request-row:hover{background:var(--color-surface-hover)}.request-row.selected{z-index:1;outline-color:var(--color-accent);background:var(--color-surface-raised)}.request-status{display:flex;align-items:center;gap:6px;font-size:10px}.request-status>span{display:grid;gap:2px}.request-status small{color:var(--color-ink-faint);font-size:9px}.request-title{display:grid;min-width:0;gap:5px;padding-right:14px}.request-title>span{display:flex;min-width:0;gap:8px}.request-title code{color:var(--color-accent);font-size:10px}.request-title strong{overflow:hidden;color:var(--color-ink);font-size:11px;font-weight:560;text-overflow:ellipsis;white-space:nowrap}.change-stats{display:flex;align-items:center;gap:6px;font-size:9.5px}.change-stats b{color:#63c783}.change-stats em{color:#e0737b;font-style:normal}.change-stats i{color:var(--color-ink-faint);font-style:normal}.change-stats span{color:var(--color-ink-faint)}.request-author{display:flex;min-width:0;align-items:center;gap:7px;padding-right:10px}.request-author i,.avatar,.collaborators i{display:grid;width:23px;height:23px;flex:0 0 auto;place-items:center;border:1px solid color-mix(in srgb,var(--color-accent) 35%,var(--color-border));border-radius:50%;color:#fff;background:color-mix(in srgb,var(--color-accent) 45%,var(--color-surface-raised));font-size:9px;font-style:normal;font-weight:750}.request-author>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.collaborators{display:flex;padding-left:3px}.collaborators i+ i{margin-left:-5px}.repo-branch{display:grid;min-width:0;gap:4px}.repo-branch>strong{overflow:hidden;color:var(--color-ink-muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-route{display:flex;min-width:0;align-items:center;gap:5px;padding-right:12px}.branch-route code{max-width:42%;overflow:hidden;color:var(--color-ink-faint);font-size:9px;text-overflow:ellipsis;white-space:nowrap}.branch-route b{color:var(--color-ink-faint);font-weight:400}.row-actions{display:flex;align-items:center;justify-content:flex-end;gap:5px}.provider-action{position:relative;display:flex}.provider-button,.action-toggle,.detail-action{display:inline-flex;height:28px;align-items:center;justify-content:center;gap:6px;border:1px solid var(--color-accent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,transparent)}.provider-button{min-width:120px;padding:0 8px;border-right:0}.provider-button.merge-primary{color:#63c783;border-color:#4fa565;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-button.conflict{color:#e0737b;border-color:#a74b55}.action-toggle{width:27px;padding:0}.provider-button:hover,.action-toggle:hover,.action-toggle.active,.detail-action:hover{color:#fff;background:color-mix(in srgb,var(--color-accent) 24%,var(--color-surface))}.action-menu{position:absolute;z-index:8;top:31px;right:0;display:grid;width:178px;padding:4px;border:1px solid var(--color-border);box-shadow:0 8px 22px #0008;background:var(--color-surface-raised)}.action-menu button{display:flex;height:29px;align-items:center;gap:8px;padding:0 8px;border:0;color:var(--color-ink-muted);background:transparent;text-align:left}.action-menu button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.action-menu button.danger{color:#e0737b}.panel-button{display:grid;width:28px;height:28px;place-items:center;border:1px solid var(--color-border);color:var(--color-ink-muted);background:var(--app-button-bg)}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:var(--color-accent)}
.detail-panel{min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--color-border);background:var(--color-surface)}.detail-header{display:flex;min-height:78px;align-items:flex-start;justify-content:space-between;gap:14px;padding:15px 16px;border-bottom:1px solid var(--color-border)}.detail-header>div{min-width:0}.detail-content{display:grid;gap:0;padding:0 16px}.detail-summary{display:flex;min-height:48px;align-items:center;gap:8px;border-bottom:1px solid var(--color-border-subtle)}.state-badge{padding:3px 7px;border:1px solid currentColor;font-size:9px;font-weight:700}.detail-summary .avatar{margin-left:4px}.detail-summary strong{font-size:11px}.branch-detail{display:flex;align-items:center;gap:7px;padding:13px 0;border-bottom:1px solid var(--color-border-subtle)}.branch-detail>:global(svg){color:var(--color-accent)}.branch-detail code{min-width:0;overflow:hidden;padding:2px 5px;color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,transparent);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-detail span{color:var(--color-ink-faint)}.description{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.description h3{margin:0 0 9px;font-size:11px}.description p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:11px;line-height:1.55}.description p.muted{color:var(--color-ink-faint)}.detail-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:16px}.detail-action{width:100%;height:32px;padding:0 8px}.detail-action.primary-action{color:#63c783;border-color:#63c783}.detail-action.danger-action{color:#e0737b;border-color:color-mix(in srgb,#e0737b 75%,var(--color-border))}.open{color:#63c783}.draft{color:var(--color-ink-muted)}.merged{color:#b785e8}.closed{color:#e0737b}
.loading-state,.list-empty,.empty-state{display:flex;min-height:180px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}.list-empty,.empty-state{flex-direction:column}.list-empty strong{color:var(--color-ink);font-size:12px}.list-empty span{font-size:10px}.empty-state{flex:1;text-align:center}.empty-state h2{margin:4px 0 0;font-size:14px}.empty-state p{max-width:430px;margin:0 0 8px;line-height:1.5}.empty-icon{display:grid;width:42px;height:42px;place-items:center;border:1px solid var(--color-border);color:var(--color-accent);background:var(--color-surface)}.primary{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-primary);color:#fff;background:var(--color-primary)}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(500px,1fr) 360px}.table-head,.request-row{grid-template-columns:78px minmax(230px,1.5fr) minmax(180px,1fr) 90px 170px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
@media(max-width:760px){.review-toolbar{flex-wrap:wrap}.group-actions{order:2}.review-search{order:1;flex-basis:calc(100% - 200px)}.review-toolbar select{order:1}.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 15%;z-index:5;box-shadow:-12px 0 30px #0008}.state-tabs{gap:8px}.group-actions button{font-size:0}.group-actions button>:global(svg){margin:0}.table-head,.request-row{grid-template-columns:72px minmax(220px,1fr) 90px 160px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.branch-route{display:none}.request-groups{min-width:580px}}
.merge-summary{display:flex;min-height:45px;align-items:center;gap:7px;border-bottom:1px solid var(--color-border-subtle);color:#63c783}.merge-summary.conflict{color:#e0737b}.merge-summary>span,.merge-summary>strong{font-size:10.5px}
@media(max-width:1100px){.table-head,.request-row{grid-template-columns:78px minmax(270px,1fr) minmax(190px,1fr) 190px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.collaborators{display:none}.request-groups{min-width:780px}}
@media(max-width:760px){.table-head,.request-row{grid-template-columns:72px minmax(260px,1fr) 190px}.table-head>span:nth-child(5),.repo-branch{display:none}.request-groups{min-width:600px}.merge-summary{align-items:flex-start;flex-wrap:wrap;padding:10px 0}}
.branch-loading{display:flex;align-items:center;gap:5px;color:var(--color-ink-faint);font-size:9px}
.provider-action{overflow:visible;border-radius:2px;box-shadow:0 1px 0 #0005}.provider-button{border-radius:2px 0 0 2px;font-weight:600}.action-toggle{border-radius:0 2px 2px 0;background:color-mix(in srgb,var(--color-accent) 14%,var(--color-surface))}.panel-button{margin-left:2px;border-radius:2px}.review-layout.inspector-open{grid-template-columns:minmax(580px,1fr) minmax(420px,44%)}.detail-header{background:linear-gradient(180deg,color-mix(in srgb,var(--color-surface-raised) 72%,var(--color-surface)),var(--color-surface))}
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}.comment-composer{display:flex;align-items:flex-start;gap:9px;margin-top:10px}.comment-composer>div{display:grid;flex:1;justify-items:end;gap:7px}.comment-composer textarea{box-sizing:border-box;width:100%;min-height:82px;resize:vertical;padding:9px 10px;border:1px solid var(--color-border-input);outline:0;color:var(--color-ink);background:var(--app-input-bg);font:inherit;line-height:1.45}.comment-composer textarea:focus{border-color:var(--color-accent)}.comment-composer button{display:inline-flex;height:29px;align-items:center;gap:6px;padding:0 10px;border:1px solid #4fa565;border-radius:2px;color:#63c783;background:color-mix(in srgb,#4fa565 12%,var(--color-surface))}.comment-composer button:hover:not(:disabled){color:#fff;background:#397849}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(480px,1fr) 380px}}
@media(max-width:760px){.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 12%;z-index:5;box-shadow:-12px 0 30px #0008}}
.comment-list{max-height:230px;overflow-y:auto;overscroll-behavior:contain;padding-right:5px;scrollbar-gutter:stable}.comment-list::-webkit-scrollbar{width:7px}.comment-list::-webkit-scrollbar-thumb{border:2px solid transparent;border-radius:4px;background:var(--color-border-input);background-clip:padding-box}.comment-list::-webkit-scrollbar-track{background:transparent}
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.branch-detail,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header,.comment-composer{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.comment-composer textarea{min-height:68px;max-height:92px}.detail-actions{margin-bottom:12px}
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title>span{color:var(--color-ink-faint);font-size:10px}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-title .branch-detail{padding:8px 0 13px;border:0}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-main .comment-composer{flex:0 0 auto}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
@media(max-width:900px){.detail-content{grid-template-columns:minmax(0,1fr) 270px;gap:18px;padding:0 18px}.detail-sidebar{padding-left:16px}.detail-title h2{font-size:17px}}
@media(max-width:680px){.detail-content{display:block;overflow:hidden;padding:0 15px}.detail-main{height:100%}.detail-sidebar{display:none}.detail-panel{inset:0}}
.comment-list{align-content:start;grid-auto-rows:max-content}
.detail-panel{inset:0 0 0 auto;width:50%;min-width:760px;max-width:100%;height:100%;border-left:1px solid var(--color-border);box-shadow:-14px 0 30px #0007}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}
.provider-action.merge-action .action-toggle{border-color:#4fa565;color:#70d58d;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-action.merge-action .action-toggle:hover,.provider-action.merge-action .action-toggle.active{color:#fff;background:#397849}.provider-action.conflict-action .action-toggle{border-color:#a74b55;color:#e0737b;background:color-mix(in srgb,#a74b55 12%,var(--color-surface))}.panel-button{border-color:color-mix(in srgb,var(--color-accent) 45%,var(--color-border));color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:#fff;background:color-mix(in srgb,var(--color-accent) 25%,var(--color-surface))}
@media(max-width:760px){.detail-panel{inset:0;width:100%;min-width:0;max-width:none}}
</style>
+22
View File
@@ -17,6 +17,8 @@ import type {
GitIgnoreKind,
GitIntegrationProvider,
GitIntegrationRepository,
IntegrationReviewRequest,
IntegrationReviewAction,
GitLfsStatus,
GitRepositoryFile,
GitRemote,
@@ -59,6 +61,26 @@ export function listIntegrationRepositories(provider: GitIntegrationProvider, ba
return invoke<GitIntegrationRepository[]>("list_integration_repositories", { provider, baseUrl, accountId: accountId ?? null });
}
export function listIntegrationReviewRequests(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, state: "open" | "merged" | "closed"): Promise<IntegrationReviewRequest[]> {
return invoke<IntegrationReviewRequest[]>("list_integration_review_requests", { provider, baseUrl, username, token, state });
}
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<void> {
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action });
}
export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {
return invoke<IntegrationReviewRequest>("get_integration_review_details", { provider, baseUrl, username, token, request });
}
export function addIntegrationReviewComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, body: string): Promise<void> {
return invoke<void>("add_integration_review_comment", { provider, baseUrl, username, token, request, body });
}
export function openInBrowser(url: string): Promise<void> {
return invoke<void>("open_in_browser", { url });
}
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
}
+4
View File
@@ -11,6 +11,10 @@ export function beginFrontendShutdown() {
frontendShuttingDown = true;
}
export function resumeFrontend() {
frontendShuttingDown = false;
}
// Telemetry must never contain repository contents or identifying local data.
function sanitize(message: string): string {
return message
+33
View File
@@ -65,6 +65,39 @@ export interface GitIntegrationRepository {
private: boolean;
}
export type IntegrationReviewState = "open" | "draft" | "merged" | "closed";
export type IntegrationReviewAction = "merge" | "approve" | "close" | "reopen";
export interface IntegrationReviewComment {
id: string;
author: string;
body: string;
createdAt: string;
}
export interface IntegrationReviewRequest {
id: string;
number: number;
provider: GitIntegrationProvider;
repositoryId: string;
repositoryName: string;
title: string;
description: string;
author: string;
state: IntegrationReviewState;
sourceBranch: string;
targetBranch: string;
webUrl: string;
createdAt: string;
updatedAt: string;
collaborators: string[];
additions: number | null;
deletions: number | null;
changedFiles: number | null;
mergeStatus: "" | "mergeable" | "conflicts" | "checking" | "blocked";
comments: IntegrationReviewComment[];
}
export interface CustomThemeColors {
background: string;
surface: string;