feat(integrations): add review fetching and Linux keyring support

Add a cross-provider Review Center and improve credential handling.
Backend integrations fetch and normalize PRs from GitHub, GitLab,
Gitea, and Azure DevOps with improved timeouts and parsing. Credentials
now use a global lock and support secret-tool on Linux to avoid races.

- Normalize review data across providers (GitHub/GitLab/Gitea/Azure)
- Serialize credential access with OnceLock and use secret-tool on Linux
- Add frontend ReviewCenter component and related UI updates
This commit is contained in:
2026-09-06 22:53:44 +02:00
parent 2ec493e91f
commit a55a3ca300
11 changed files with 1666 additions and 15 deletions
+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}")),