diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs new file mode 100644 index 00000000..3fd8e22e --- /dev/null +++ b/src-tauri/src/github.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::process::Command; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GithubRepo { + pub name: String, + pub full_name: String, + pub description: Option, + pub private: bool, + pub clone_url: String, + pub html_url: String, + pub updated_at: Option, +} + +/// Lists the authenticated user's GitHub repositories. +pub async fn github_list_repos(token: &str) -> Result, String> { + let client = reqwest::Client::new(); + let mut all_repos: Vec = Vec::new(); + let mut page = 1u32; + + loop { + let url = format!( + "https://api.github.com/user/repos?per_page=100&sort=updated&page={}", + page + ); + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "Laputa-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 = 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) +} + +#[derive(Debug, Deserialize, Serialize)] +struct CreateRepoResponse { + name: String, + full_name: String, + description: Option, + private: bool, + clone_url: String, + html_url: String, + updated_at: Option, +} + +/// Creates a new GitHub repository for the authenticated user. +pub async fn github_create_repo( + token: &str, + name: &str, + private: bool, +) -> Result { + let client = reqwest::Client::new(); + let body = serde_json::json!({ + "name": name, + "private": private, + "auto_init": true, + "description": "Laputa vault" + }); + + let response = client + .post("https://api.github.com/user/repos") + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "Laputa-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)); + } + + let created: CreateRepoResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse GitHub response: {}", e))?; + + Ok(GithubRepo { + name: created.name, + full_name: created.full_name, + description: created.description, + private: created.private, + clone_url: created.clone_url, + html_url: created.html_url, + updated_at: created.updated_at, + }) +} + +/// Clones a GitHub repo to a local path using HTTPS + token auth. +pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result { + let dest = Path::new(local_path); + + if dest.exists() && dest.read_dir().map(|mut d| d.next().is_some()).unwrap_or(false) { + return Err(format!( + "Destination '{}' already exists and is not empty", + local_path + )); + } + + // Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/... + let auth_url = inject_token_into_url(url, token)?; + + let output = Command::new("git") + .args(["clone", "--progress", &auth_url, local_path]) + .output() + .map_err(|e| format!("Failed to run git clone: {}", e))?; + + if !output.status.success() { + // Clean up partial clone on failure + if dest.exists() { + let _ = std::fs::remove_dir_all(dest); + } + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git clone failed: {}", stderr)); + } + + // Configure the remote to use token auth for future pushes + configure_remote_auth(local_path, url, token)?; + + Ok(format!("Cloned to {}", local_path)) +} + +/// Injects an OAuth token into an HTTPS GitHub URL. +fn inject_token_into_url(url: &str, token: &str) -> Result { + if let Some(rest) = url.strip_prefix("https://github.com/") { + Ok(format!("https://oauth2:{}@github.com/{}", token, rest)) + } else if let Some(rest) = url.strip_prefix("https://") { + // Handle URLs that already have a host + Ok(format!("https://oauth2:{}@{}", token, rest)) + } else { + Err(format!("Unsupported URL format: {}. Use an HTTPS URL.", url)) + } +} + +/// Sets up the git remote to use token-based HTTPS auth. +fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> { + let auth_url = inject_token_into_url(original_url, token)?; + let vault = Path::new(local_path); + + let output = Command::new("git") + .args(["remote", "set-url", "origin", &auth_url]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to configure remote: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to set remote URL: {}", stderr)); + } + + // Also configure git user if not set + let _ = Command::new("git") + .args(["config", "user.email", "laputa@app.local"]) + .current_dir(vault) + .output(); + let _ = Command::new("git") + .args(["config", "user.name", "Laputa App"]) + .current_dir(vault) + .output(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inject_token_basic_github_url() { + let url = "https://github.com/user/repo.git"; + let token = "gho_abc123"; + let result = inject_token_into_url(url, token).unwrap(); + assert_eq!(result, "https://oauth2:gho_abc123@github.com/user/repo.git"); + } + + #[test] + fn test_inject_token_generic_https_url() { + let url = "https://gitlab.com/user/repo.git"; + let token = "glpat-abc"; + let result = inject_token_into_url(url, token).unwrap(); + assert_eq!(result, "https://oauth2:glpat-abc@gitlab.com/user/repo.git"); + } + + #[test] + fn test_inject_token_ssh_url_rejected() { + let url = "git@github.com:user/repo.git"; + let result = inject_token_into_url(url, "token"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported URL format")); + } + + #[test] + fn test_clone_repo_nonempty_dest() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path(); + std::fs::write(path.join("existing.txt"), "data").unwrap(); + + let result = clone_repo("https://github.com/test/repo.git", "token", path.to_str().unwrap()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not empty")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7278a68d..1558191c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,13 @@ pub mod ai_chat; pub mod frontmatter; pub mod git; +pub mod github; pub mod settings; pub mod vault; use ai_chat::{AiChatRequest, AiChatResponse}; use git::{GitCommit, ModifiedFile}; +use github::GithubRepo; use settings::Settings; use vault::{VaultEntry, RenameResult}; use frontmatter::FrontmatterValue; @@ -90,6 +92,21 @@ fn save_settings(settings: Settings) -> Result<(), String> { settings::save_settings(settings) } +#[tauri::command] +async fn github_list_repos(token: String) -> Result, String> { + github::github_list_repos(&token).await +} + +#[tauri::command] +async fn github_create_repo(token: String, name: String, private: bool) -> Result { + github::github_create_repo(&token, &name, private).await +} + +#[tauri::command] +fn clone_repo(url: String, token: String, local_path: String) -> Result { + github::clone_repo(&url, &token, &local_path) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -142,7 +159,10 @@ pub fn run() { save_image, purge_trash, get_settings, - save_settings + save_settings, + github_list_repos, + github_create_repo, + clone_repo ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index a09208a5..baebb8b0 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -7,6 +7,7 @@ pub struct Settings { pub anthropic_key: Option, pub openai_key: Option, pub google_key: Option, + pub github_token: Option, } fn settings_path() -> Result { @@ -38,6 +39,7 @@ pub fn save_settings(settings: Settings) -> Result<(), String> { anthropic_key: settings.anthropic_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), openai_key: settings.openai_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), google_key: settings.google_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), + github_token: settings.github_token.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), }; let json = serde_json::to_string_pretty(&cleaned) @@ -65,6 +67,7 @@ mod tests { assert!(settings.anthropic_key.is_none()); assert!(settings.openai_key.is_none()); assert!(settings.google_key.is_none()); + assert!(settings.github_token.is_none()); } #[test] @@ -73,12 +76,14 @@ mod tests { anthropic_key: Some("sk-ant-test123".to_string()), openai_key: None, google_key: Some("AIza-test".to_string()), + github_token: Some("gho_xyz789".to_string()), }; let json = serde_json::to_string(&settings).unwrap(); let parsed: Settings = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.anthropic_key, Some("sk-ant-test123".to_string())); assert!(parsed.openai_key.is_none()); assert_eq!(parsed.google_key, Some("AIza-test".to_string())); + assert_eq!(parsed.github_token, Some("gho_xyz789".to_string())); } #[test] @@ -88,14 +93,17 @@ mod tests { anthropic_key: Some(" sk-ant-test ".to_string()), openai_key: Some(" ".to_string()), google_key: Some("".to_string()), + github_token: Some(" gho_abc ".to_string()), }; let cleaned = Settings { anthropic_key: settings.anthropic_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), openai_key: settings.openai_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), google_key: settings.google_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), + github_token: settings.github_token.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()), }; assert_eq!(cleaned.anthropic_key, Some("sk-ant-test".to_string())); assert!(cleaned.openai_key.is_none()); assert!(cleaned.google_key.is_none()); + assert_eq!(cleaned.github_token, Some("gho_abc".to_string())); } } diff --git a/src/types.ts b/src/types.ts index 9a079238..98920915 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,17 @@ export interface Settings { anthropic_key: string | null openai_key: string | null google_key: string | null + github_token: string | null +} + +export interface GithubRepo { + name: string + full_name: string + description: string | null + private: boolean + clone_url: string + html_url: string + updated_at: string | null } export type SidebarSelection =