feat(integrations): support GitHub repositories and enhance repo browser UI
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.
This commit is contained in:
@@ -38,6 +38,24 @@ struct GitLabProject {
|
||||
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,
|
||||
@@ -113,6 +131,14 @@ fn normalized_base_url(base_url: &str) -> Result<String, 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| {
|
||||
@@ -188,6 +214,57 @@ fn gitlab_repositories(
|
||||
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,
|
||||
@@ -286,6 +363,7 @@ pub async fn list_integration_repositories(
|
||||
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)
|
||||
}
|
||||
@@ -318,10 +396,19 @@ mod tests {
|
||||
"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(),
|
||||
@@ -336,6 +423,13 @@ mod tests {
|
||||
|
||||
#[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"}]"#,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user