Add GitHub repository support to integrations and update related tests. Implement server-side GitHub API calls and normalize GitHub base URLs. Improve the repository browser UI with compact tabs and updated icons. Add a custom, accessible scrollbar with pointer and keyboard support. - Add paging, auth headers, and error handling when listing GitHub repos. - Default GitHub token username to "x-access-token" when saving credentials. - Introduce compact repo tab styles, new icons, and a draggable scrollbar.
458 lines
16 KiB
Rust
458 lines
16 KiB
Rust
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 GitHubRepository {
|
|
id: u64,
|
|
name: String,
|
|
full_name: String,
|
|
#[serde(default)]
|
|
description: Option<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 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 github_api_base_url(base_url: &str) -> Result<String, String> {
|
|
match normalized_base_url(base_url)?.to_ascii_lowercase().as_str() {
|
|
"https://github.com" | "https://www.github.com" => Ok("https://api.github.com".to_string()),
|
|
"https://api.github.com" => Ok("https://api.github.com".to_string()),
|
|
_ => Err("The GitHub integration URL must be https://github.com.".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 github_repositories(
|
|
client: &Client,
|
|
base_url: &str,
|
|
token: &str,
|
|
) -> Result<Vec<IntegrationRepository>, String> {
|
|
let api_base_url = github_api_base_url(base_url)?;
|
|
let mut repositories = Vec::new();
|
|
let mut page = 1usize;
|
|
loop {
|
|
let response = client
|
|
.get(format!("{api_base_url}/user/repos"))
|
|
.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", PAGE_SIZE.to_string()),
|
|
("page", page.to_string()),
|
|
("sort", "updated".to_string()),
|
|
("direction", "desc".to_string()),
|
|
])
|
|
.send()
|
|
.map_err(|err| format!("Could not reach GitHub: {err}"))?;
|
|
if !response.status().is_success() {
|
|
return Err(response_error(response, "GitHub"));
|
|
}
|
|
let page_repositories = response
|
|
.json::<Vec<GitHubRepository>>()
|
|
.map_err(|err| format!("GitHub 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.unwrap_or_default(),
|
|
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 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() {
|
|
"github" => github_repositories(&client, &base_url, &credential.password),
|
|
"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());
|
|
assert_eq!(
|
|
github_api_base_url("https://github.com/").unwrap(),
|
|
"https://api.github.com"
|
|
);
|
|
assert!(github_api_base_url("https://github.example.com").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn integration_credential_keys_match_the_frontend() {
|
|
assert_eq!(
|
|
integration_key("github", None).unwrap(),
|
|
"integration:github"
|
|
);
|
|
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 github: Vec<GitHubRepository> = serde_json::from_str(
|
|
r#"[{"id":6,"name":"desktop","full_name":"team/desktop","description":null,"clone_url":"https://github.com/team/desktop.git","ssh_url":"git@github.com:team/desktop.git","html_url":"https://github.com/team/desktop","updated_at":"2026-08-29T09:00:00Z","private":true}]"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(github[0].full_name, "team/desktop");
|
|
assert!(github[0].description.is_none());
|
|
|
|
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"
|
|
);
|
|
}
|
|
}
|