Files
tolaria/src-tauri/src/github/api.rs

327 lines
11 KiB
Rust

use super::{GitHubUser, GithubRepo};
/// Lists the authenticated user's GitHub repositories.
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
github_list_repos_with_base(token, "https://api.github.com").await
}
async fn github_list_repos_with_base(
token: &str,
api_base: &str,
) -> Result<Vec<GithubRepo>, String> {
let client = reqwest::Client::new();
let mut all_repos: Vec<GithubRepo> = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"{}/user/repos?per_page=100&sort=updated&page={}",
api_base, page
);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Tolaria-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
let repos: Vec<GithubRepo> = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
let count = repos.len();
all_repos.extend(repos);
if count < 100 {
break;
}
page += 1;
if page > 10 {
break; // safety limit: 1000 repos max
}
}
Ok(all_repos)
}
/// Creates a new GitHub repository for the authenticated user.
pub async fn github_create_repo(
token: &str,
name: &str,
private: bool,
) -> Result<GithubRepo, String> {
github_create_repo_with_base(token, name, private, "https://api.github.com").await
}
async fn github_create_repo_with_base(
token: &str,
name: &str,
private: bool,
api_base: &str,
) -> Result<GithubRepo, String> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"name": name,
"private": private,
"auto_init": true,
"description": "Tolaria vault"
});
let response = client
.post(format!("{}/user/repos", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Tolaria-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&body)
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 422 && body.contains("name already exists") {
return Err("Repository name already exists on your account".to_string());
}
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GithubRepo>()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
}
/// Gets the authenticated GitHub user's profile.
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
github_get_user_with_base(token, "https://api.github.com").await
}
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/user", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Tolaria-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub user request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GitHubUser>()
.await
.map_err(|e| format!("Failed to parse user response: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
async fn mock_json(
method: &str,
path: &str,
status: usize,
body: &str,
) -> (mockito::ServerGuard, mockito::Mock) {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock(method, path)
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
(server, mock)
}
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
let (server, mock) = mock_json(
"GET",
"/user/repos?per_page=100&sort=updated&page=1",
status,
body,
)
.await;
let result = github_list_repos_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
let (server, mock) = mock_json("GET", "/user", status, body).await;
let result = github_get_user_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
#[tokio::test]
async fn test_github_list_repos_success() {
let repos = mock_list_repos(
200,
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
)
.await
.unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "my-repo");
}
#[tokio::test]
async fn test_github_list_repos_empty() {
let repos = mock_list_repos(200, "[]").await.unwrap();
assert!(repos.is_empty());
}
#[tokio::test]
async fn test_github_list_repos_auth_error() {
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error"));
}
#[tokio::test]
async fn test_github_list_repos_paginated() {
let mut server = mockito::Server::new_async().await;
let repos_page1: Vec<serde_json::Value> = (0..100)
.map(|i| {
serde_json::json!({
"name": format!("repo-{}", i),
"full_name": format!("user/repo-{}", i),
"description": null,
"private": false,
"clone_url": format!("https://github.com/user/repo-{}.git", i),
"html_url": format!("https://github.com/user/repo-{}", i),
"updated_at": null
})
})
.collect();
let mock1 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&repos_page1).unwrap())
.create_async()
.await;
let mock2 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
)
.create_async()
.await;
let repos = github_list_repos_with_base("token", &server.url())
.await
.unwrap();
mock1.assert_async().await;
mock2.assert_async().await;
assert_eq!(repos.len(), 101);
assert_eq!(repos[100].name, "extra-repo");
}
#[tokio::test]
async fn test_github_create_repo_success() {
let repo = mock_create_repo(
201,
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Tolaria vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
)
.await
.unwrap();
assert_eq!(repo.name, "new-repo");
assert!(repo.private);
}
#[tokio::test]
async fn test_github_create_repo_name_exists() {
let err = mock_create_repo(
422,
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
)
.await
.unwrap_err();
assert!(err.contains("Repository name already exists"));
}
#[tokio::test]
async fn test_github_create_repo_server_error() {
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 500"));
}
#[tokio::test]
async fn test_github_get_user_success() {
let user = mock_user(
200,
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
)
.await
.unwrap();
assert_eq!(
user,
GitHubUser {
login: "lucaong".to_string(),
name: Some("Luca Ongaro".to_string()),
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
}
);
}
#[tokio::test]
async fn test_github_get_user_unauthorized() {
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 401"));
}
#[tokio::test]
async fn test_github_get_user_null_name() {
let user = mock_user(
200,
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
)
.await
.unwrap();
assert_eq!(user.login, "bot-account");
assert!(user.name.is_none());
}
}