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:
+12
-10
@@ -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)]
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user