feat(integrations): add Git hosting integrations and repo listing

Add support for integrating with external Git hosts (GitLab, Gitea,
and Azure DevOps). The backend gains a client to fetch paginated
repository lists, normalise base URLs, and surface provider errors.
Credentials are loaded from the OS keychain and a Tauri command is
exposed for the frontend to list integration repositories.

- Implement integration client with pagination, deserialization,
  and provider-specific handling.
- Centralise keychain credential loading and expose listing command.
- Update UI to manage integration metadata, persist settings, and
  save/remove tokens to the OS keychain for cloning and operations.
This commit is contained in:
2026-08-29 23:25:51 +02:00
parent 823a50ce85
commit 91263547db
10 changed files with 1190 additions and 130 deletions
+12 -10
View File
@@ -2878,6 +2878,17 @@ 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> {
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}")),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Keychain access failed: {err}")),
}
}
/// 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)]
@@ -3061,16 +3072,7 @@ fn push_args_for_repo_to(
#[tauri::command(async)]
pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
let entry = cred_entry(&key)?;
match entry.get_password() {
Ok(json) => {
let cred = serde_json::from_str::<StoredCredential>(&json)
.map_err(|err| format!("Stored credentials unreadable: {err}"))?;
Ok(Some(cred))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Keychain access failed: {err}")),
}
load_stored_credential(&key)
}
#[tauri::command(async)]
+363
View File
@@ -0,0 +1,363 @@
use crate::git::load_stored_credential;
use reqwest::blocking::{Client, Response};
use reqwest::header::{ACCEPT, USER_AGENT};
use serde::{Deserialize, Serialize};
use std::time::Duration;
const PAGE_SIZE: usize = 100;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IntegrationRepository {
pub id: String,
pub name: String,
pub full_name: String,
pub description: String,
pub clone_url: String,
pub ssh_url: String,
pub web_url: String,
pub updated_at: String,
pub private: bool,
}
#[derive(Debug, Deserialize)]
struct GitLabProject {
id: u64,
name: String,
path_with_namespace: String,
#[serde(default)]
description: Option<String>,
http_url_to_repo: String,
#[serde(default)]
ssh_url_to_repo: String,
#[serde(default)]
web_url: String,
#[serde(default)]
last_activity_at: String,
#[serde(default)]
visibility: String,
}
#[derive(Debug, Deserialize)]
struct GiteaRepository {
id: u64,
name: String,
full_name: String,
#[serde(default)]
description: String,
clone_url: String,
#[serde(default)]
ssh_url: String,
#[serde(default)]
html_url: String,
#[serde(default)]
updated_at: String,
#[serde(default)]
private: bool,
}
#[derive(Debug, Deserialize)]
struct AzureRepositoryList {
#[serde(default)]
value: Vec<AzureRepository>,
}
#[derive(Debug, Deserialize)]
struct AzureRepository {
id: String,
name: String,
project: AzureProject,
#[serde(default, rename = "remoteUrl")]
remote_url: String,
#[serde(default, rename = "sshUrl")]
ssh_url: String,
#[serde(default, rename = "webUrl")]
web_url: String,
}
#[derive(Debug, Deserialize)]
struct AzureProject {
name: String,
}
fn integration_key(provider: &str, account_id: Option<&str>) -> Result<String, String> {
if provider == "azure-devops" {
if let Some(account_id) = account_id.filter(|value| !value.is_empty()) {
if account_id.len() > 80
|| !account_id.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
})
{
return Err("Invalid integration account identifier.".to_string());
}
if account_id != "default" {
return Ok(format!("integration:{provider}:{account_id}"));
}
}
}
Ok(format!("integration:{provider}"))
}
fn client() -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(25))
.build()
.map_err(|err| format!("Could not initialize the integration client: {err}"))
}
fn normalized_base_url(base_url: &str) -> Result<String, String> {
let base_url = base_url.trim().trim_end_matches('/');
if !(base_url.starts_with("https://") || base_url.starts_with("http://")) {
return Err("The integration URL must start with http:// or https://.".to_string());
}
Ok(base_url.to_string())
}
fn response_error(response: Response, provider: &str) -> String {
let status = response.status();
let detail = response.text().ok().and_then(|body| {
serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|value| {
value
.get("message")
.or_else(|| value.get("error"))
.and_then(|value| value.as_str())
.map(str::to_string)
})
});
match detail {
Some(detail) if !detail.trim().is_empty() => {
format!("{provider} returned {status}: {detail}")
}
_ => format!("{provider} returned {status}."),
}
}
fn gitlab_repositories(
client: &Client,
base_url: &str,
token: &str,
) -> Result<Vec<IntegrationRepository>, String> {
let mut repositories = Vec::new();
let mut page = 1usize;
loop {
let response = client
.get(format!("{base_url}/api/v4/projects"))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.header("PRIVATE-TOKEN", token)
.query(&[
("membership", "true"),
("simple", "true"),
("order_by", "last_activity_at"),
("sort", "desc"),
("per_page", &PAGE_SIZE.to_string()),
("page", &page.to_string()),
])
.send()
.map_err(|err| format!("Could not reach GitLab: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "GitLab"));
}
let next_page = response
.headers()
.get("x-next-page")
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string();
let projects = response
.json::<Vec<GitLabProject>>()
.map_err(|err| format!("GitLab returned an unreadable repository list: {err}"))?;
repositories.extend(projects.into_iter().map(|project| IntegrationRepository {
id: project.id.to_string(),
name: project.name,
full_name: project.path_with_namespace,
description: project.description.unwrap_or_default(),
clone_url: project.http_url_to_repo,
ssh_url: project.ssh_url_to_repo,
web_url: project.web_url,
updated_at: project.last_activity_at,
private: project.visibility == "private",
}));
if next_page.is_empty() {
break;
}
page = next_page.parse().unwrap_or(page + 1);
}
Ok(repositories)
}
fn gitea_repositories(
client: &Client,
base_url: &str,
token: &str,
) -> Result<Vec<IntegrationRepository>, String> {
let mut repositories = Vec::new();
let mut page = 1usize;
loop {
let response = client
.get(format!("{base_url}/api/v1/user/repos"))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.header("Authorization", format!("token {token}"))
.query(&[
("limit", PAGE_SIZE.to_string()),
("page", page.to_string()),
("sort", "updated".to_string()),
])
.send()
.map_err(|err| format!("Could not reach Gitea: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "Gitea"));
}
let page_repositories = response
.json::<Vec<GiteaRepository>>()
.map_err(|err| format!("Gitea returned an unreadable repository list: {err}"))?;
let count = page_repositories.len();
repositories.extend(page_repositories.into_iter().map(|repository| {
IntegrationRepository {
id: repository.id.to_string(),
name: repository.name,
full_name: repository.full_name,
description: repository.description,
clone_url: repository.clone_url,
ssh_url: repository.ssh_url,
web_url: repository.html_url,
updated_at: repository.updated_at,
private: repository.private,
}
}));
if count < PAGE_SIZE {
break;
}
page += 1;
}
Ok(repositories)
}
fn azure_repositories(
client: &Client,
base_url: &str,
username: &str,
token: &str,
) -> Result<Vec<IntegrationRepository>, String> {
let response = client
.get(format!("{base_url}/_apis/git/repositories"))
.header(USER_AGENT, "Gitty")
.header(ACCEPT, "application/json")
.basic_auth(username, Some(token))
.query(&[("api-version", "7.1")])
.send()
.map_err(|err| format!("Could not reach Azure DevOps: {err}"))?;
if !response.status().is_success() {
return Err(response_error(response, "Azure DevOps"));
}
let repositories = response
.json::<AzureRepositoryList>()
.map_err(|err| format!("Azure DevOps returned an unreadable repository list: {err}"))?;
Ok(repositories
.value
.into_iter()
.map(|repository| IntegrationRepository {
id: repository.id,
full_name: format!("{}/{}", repository.project.name, repository.name),
name: repository.name,
description: String::new(),
clone_url: repository.remote_url,
ssh_url: repository.ssh_url,
web_url: repository.web_url,
updated_at: String::new(),
private: true,
})
.collect())
}
#[tauri::command]
pub async fn list_integration_repositories(
provider: String,
base_url: String,
account_id: Option<String>,
) -> Result<Vec<IntegrationRepository>, String> {
tauri::async_runtime::spawn_blocking(move || {
let credential_key = integration_key(&provider, account_id.as_deref())?;
let credential = load_stored_credential(&credential_key)?
.ok_or_else(|| "No token is stored for this integration.".to_string())?;
let base_url = normalized_base_url(&base_url)?;
let client = client()?;
match provider.as_str() {
"gitlab" | "gitlab-self-hosted" => {
gitlab_repositories(&client, &base_url, &credential.password)
}
"azure-devops" => azure_repositories(
&client,
&base_url,
if credential.username.trim().is_empty() {
"gitty"
} else {
&credential.username
},
&credential.password,
),
"gitea" => gitea_repositories(&client, &base_url, &credential.password),
_ => Err("Unsupported integration provider.".to_string()),
}
})
.await
.map_err(|err| format!("Could not load integration repositories: {err}"))?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_urls_are_normalized_and_validated() {
assert_eq!(
normalized_base_url(" https://gitlab.example.com/ ").unwrap(),
"https://gitlab.example.com"
);
assert!(normalized_base_url("gitlab.example.com").is_err());
}
#[test]
fn integration_credential_keys_match_the_frontend() {
assert_eq!(integration_key("gitea", None).unwrap(), "integration:gitea");
assert_eq!(
integration_key("azure-devops", Some("org-123")).unwrap(),
"integration:azure-devops:org-123"
);
assert_eq!(
integration_key("azure-devops", Some("default")).unwrap(),
"integration:azure-devops"
);
assert!(integration_key("azure-devops", Some("../invalid")).is_err());
}
#[test]
fn provider_repository_payloads_deserialize() {
let gitlab: Vec<GitLabProject> = serde_json::from_str(
r#"[{"id":7,"name":"app","path_with_namespace":"team/app","description":"Demo","http_url_to_repo":"https://gitlab.test/team/app.git","ssh_url_to_repo":"git@gitlab.test:team/app.git","web_url":"https://gitlab.test/team/app","last_activity_at":"2026-08-29T10:00:00Z","visibility":"private"}]"#,
)
.unwrap();
assert_eq!(gitlab[0].path_with_namespace, "team/app");
assert_eq!(gitlab[0].visibility, "private");
let gitea: Vec<GiteaRepository> = serde_json::from_str(
r#"[{"id":8,"name":"api","full_name":"team/api","description":"","clone_url":"https://gitea.test/team/api.git","ssh_url":"git@gitea.test:team/api.git","html_url":"https://gitea.test/team/api","updated_at":"2026-08-29T11:00:00Z","private":false}]"#,
)
.unwrap();
assert_eq!(gitea[0].full_name, "team/api");
assert!(!gitea[0].private);
let azure: AzureRepositoryList = serde_json::from_str(
r#"{"value":[{"id":"repo-id","name":"web","project":{"name":"Platform"},"remoteUrl":"https://dev.azure.com/org/Platform/_git/web","sshUrl":"git@ssh.dev.azure.com:v3/org/Platform/web","webUrl":"https://dev.azure.com/org/Platform/_git/web"}]}"#,
)
.unwrap();
assert_eq!(azure.value[0].project.name, "Platform");
assert_eq!(
azure.value[0].remote_url,
"https://dev.azure.com/org/Platform/_git/web"
);
}
}
+3
View File
@@ -3,6 +3,7 @@
mod badge;
mod external_tools;
mod git;
mod integrations;
mod telemetry;
use badge::set_sync_badge;
@@ -32,6 +33,7 @@ use git::{
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
untrack_paths, update_remote,
};
use integrations::list_integration_repositories;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::{Emitter, Manager};
@@ -432,6 +434,7 @@ async fn main() {
cred_load,
cred_save,
cred_delete,
list_integration_repositories,
set_sync_badge,
close_splashscreen,
set_telemetry_enabled,