Skip to main content

repokai_core/
lib.rs

1pub use octocrab::Octocrab;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4use std::process::Command;
5use thiserror::Error;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Repo {
9    pub owner: String,
10    pub name: String,
11    pub description: Option<String>,
12    pub url: String,
13    pub language: Option<String>,
14    pub license: Option<String>,
15    pub stars: u32,
16    pub visibility: String,
17    pub last_updated: String,
18    pub readme: Option<String>,
19}
20
21#[derive(Debug, Error)]
22pub enum RepoKaiError {
23    #[error("no GitHub token found (set GITHUB_TOKEN or log in with `gh auth login`)")]
24    MissingToken,
25    #[error("GitHub API error: {0}")]
26    GitHub(#[from] octocrab::Error),
27    #[error("base64 decode error: {0}")]
28    Base64(#[from] base64::DecodeError),
29    #[error("UTF-8 decode error: {0}")]
30    Utf8(#[from] std::string::FromUtf8Error),
31    #[error("git error: {0}")]
32    Git(String),
33    #[error("path error: {0}")]
34    Path(String),
35}
36
37fn resolve_token() -> Result<String, RepoKaiError> {
38    // 1. Environment variable
39    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
40        return Ok(token);
41    }
42
43    // 2. macOS Keychain (works even when launched from dock)
44    if let Ok(token) = read_token_from_keychain() {
45        return Ok(token);
46    }
47
48    // 3. Fall back to `gh auth token`
49    for gh_path in &["gh", "/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
50        if let Ok(output) = Command::new(gh_path).args(["auth", "token"]).output() {
51            if output.status.success() {
52                let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
53                if !token.is_empty() {
54                    return Ok(token);
55                }
56            }
57        }
58    }
59
60    Err(RepoKaiError::MissingToken)
61}
62
63fn read_token_from_keychain() -> Result<String, RepoKaiError> {
64    let output = Command::new("security")
65        .args(["find-generic-password", "-s", "gh:github.com", "-a", "", "-w"])
66        .output()
67        .map_err(|_| RepoKaiError::MissingToken)?;
68
69    if !output.status.success() {
70        return Err(RepoKaiError::MissingToken);
71    }
72
73    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
74
75    // gh stores tokens as "go-keyring-base64:BASE64_ENCODED_TOKEN"
76    if let Some(encoded) = raw.strip_prefix("go-keyring-base64:") {
77        use base64::Engine;
78        let bytes = base64::engine::general_purpose::STANDARD
79            .decode(encoded)
80            .map_err(|_| RepoKaiError::MissingToken)?;
81        String::from_utf8(bytes).map_err(|_| RepoKaiError::MissingToken)
82    } else if raw.starts_with("ghp_") || raw.starts_with("gho_") || raw.starts_with("github_pat_") {
83        // Plain token without encoding
84        Ok(raw)
85    } else {
86        Err(RepoKaiError::MissingToken)
87    }
88}
89
90pub async fn create_client() -> Result<Octocrab, RepoKaiError> {
91    let token = resolve_token()?;
92    Ok(Octocrab::builder().personal_token(token).build()?)
93}
94
95pub async fn get_authenticated_user(client: &Octocrab) -> Result<String, RepoKaiError> {
96    let user = client.current().user().await?;
97    Ok(user.login)
98}
99
100fn map_repo(repo: &octocrab::models::Repository) -> Repo {
101    Repo {
102        owner: repo
103            .owner
104            .as_ref()
105            .map(|o| o.login.clone())
106            .unwrap_or_default(),
107        name: repo.name.clone(),
108        description: repo.description.clone(),
109        url: repo
110            .html_url
111            .as_ref()
112            .map(|u| u.to_string())
113            .unwrap_or_default(),
114        language: repo.language.as_ref().and_then(|v| v.as_str()).map(String::from),
115        license: repo.license.as_ref().map(|l| l.name.clone()),
116        stars: repo.stargazers_count.unwrap_or(0) as u32,
117        visibility: if repo.private.unwrap_or(false) {
118            "private".into()
119        } else {
120            "public".into()
121        },
122        last_updated: repo
123            .updated_at
124            .map(|dt| dt.to_string())
125            .unwrap_or_default(),
126        readme: None,
127    }
128}
129
130pub async fn fetch_repos(client: &Octocrab) -> Result<Vec<Repo>, RepoKaiError> {
131    let mut all_repos = Vec::new();
132    let mut page_num = 1u8;
133
134    loop {
135        let page = client
136            .current()
137            .list_repos_for_authenticated_user()
138            .sort("updated")
139            .per_page(100)
140            .page(page_num)
141            .send()
142            .await?;
143
144        if page.items.is_empty() {
145            break;
146        }
147
148        all_repos.extend(page.items.iter().map(map_repo));
149
150        if page.next.is_none() {
151            break;
152        }
153        page_num += 1;
154    }
155
156    Ok(all_repos)
157}
158
159pub async fn fetch_starred_repos(client: &Octocrab) -> Result<Vec<Repo>, RepoKaiError> {
160    let mut all_repos = Vec::new();
161    let mut page_num = 1u8;
162
163    loop {
164        let page = client
165            .current()
166            .list_repos_starred_by_authenticated_user()
167            .sort("updated")
168            .per_page(100)
169            .page(page_num)
170            .send()
171            .await?;
172
173        if page.items.is_empty() {
174            break;
175        }
176
177        all_repos.extend(page.items.iter().map(map_repo));
178
179        if page.next.is_none() {
180            break;
181        }
182        page_num += 1;
183    }
184
185    Ok(all_repos)
186}
187
188#[derive(Deserialize)]
189struct ReadmeResponse {
190    content: Option<String>,
191}
192
193pub async fn fetch_readme(
194    client: &Octocrab,
195    owner: &str,
196    repo: &str,
197) -> Result<Option<String>, RepoKaiError> {
198    let response: Result<ReadmeResponse, _> = client
199        .get(format!("/repos/{owner}/{repo}/readme"), None::<&()>)
200        .await;
201
202    match response {
203        Ok(readme) => {
204            if let Some(encoded) = readme.content {
205                let cleaned: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
206                use base64::Engine;
207                let bytes = base64::engine::general_purpose::STANDARD.decode(cleaned)?;
208                Ok(Some(String::from_utf8(bytes)?))
209            } else {
210                Ok(None)
211            }
212        }
213        Err(_) => Ok(None),
214    }
215}
216
217// ---- Publish local repo to GitHub ----
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct PublishOptions {
221    pub local_path: String,
222    pub name: String,
223    pub description: String,
224    pub private: bool,
225}
226
227pub async fn publish_local_repo(
228    client: &Octocrab,
229    opts: &PublishOptions,
230) -> Result<Repo, RepoKaiError> {
231    let path = Path::new(&opts.local_path);
232
233    // Verify it's a git repo
234    if !path.join(".git").exists() {
235        return Err(RepoKaiError::Path(format!(
236            "{} is not a git repository",
237            opts.local_path
238        )));
239    }
240
241    // Check if origin remote already exists
242    let remote_check = Command::new("git")
243        .args(["remote", "get-url", "origin"])
244        .current_dir(path)
245        .output()
246        .map_err(|e| RepoKaiError::Git(e.to_string()))?;
247
248    if remote_check.status.success() {
249        let existing = String::from_utf8_lossy(&remote_check.stdout).trim().to_string();
250        return Err(RepoKaiError::Git(format!(
251            "origin remote already exists: {existing}"
252        )));
253    }
254
255    // Create empty repo on GitHub (no auto-init)
256    let repo = client
257        .post(
258            "/user/repos",
259            Some(&serde_json::json!({
260                "name": opts.name,
261                "description": opts.description,
262                "private": opts.private,
263                "auto_init": false,
264            })),
265        )
266        .await
267        .map_err(|e| RepoKaiError::GitHub(e))?;
268
269    let repo: octocrab::models::Repository = repo;
270    let clone_url = repo
271        .clone_url
272        .as_ref()
273        .map(|u| u.to_string())
274        .unwrap_or_default();
275
276    // Add origin remote
277    let add_remote = Command::new("git")
278        .args(["remote", "add", "origin", &clone_url])
279        .current_dir(path)
280        .output()
281        .map_err(|e| RepoKaiError::Git(e.to_string()))?;
282
283    if !add_remote.status.success() {
284        let err = String::from_utf8_lossy(&add_remote.stderr).to_string();
285        return Err(RepoKaiError::Git(format!("failed to add remote: {err}")));
286    }
287
288    // Push all branches
289    let push = Command::new("git")
290        .args(["push", "-u", "origin", "--all"])
291        .current_dir(path)
292        .output()
293        .map_err(|e| RepoKaiError::Git(e.to_string()))?;
294
295    if !push.status.success() {
296        let err = String::from_utf8_lossy(&push.stderr).to_string();
297        return Err(RepoKaiError::Git(format!("failed to push: {err}")));
298    }
299
300    let owner = repo
301        .owner
302        .as_ref()
303        .map(|o| o.login.clone())
304        .unwrap_or_default();
305
306    Ok(Repo {
307        owner,
308        name: repo.name.clone(),
309        description: repo.description.clone(),
310        url: repo
311            .html_url
312            .as_ref()
313            .map(|u| u.to_string())
314            .unwrap_or_default(),
315        language: repo.language.as_ref().and_then(|v| v.as_str()).map(String::from),
316        license: repo.license.as_ref().map(|l| l.name.clone()),
317        stars: 0,
318        visibility: if opts.private { "private".into() } else { "public".into() },
319        last_updated: repo
320            .updated_at
321            .map(|dt| dt.to_string())
322            .unwrap_or_default(),
323        readme: None,
324    })
325}
326
327// ---- Clone repo locally ----
328
329pub fn clone_repo(url: &str, destination: &str) -> Result<(), RepoKaiError> {
330    let dest = Path::new(destination);
331    if dest.exists() && dest.read_dir().map(|mut d| d.next().is_some()).unwrap_or(false) {
332        return Err(RepoKaiError::Path(format!(
333            "{destination} already exists and is not empty"
334        )));
335    }
336
337    let output = Command::new("git")
338        .args(["clone", url, destination])
339        .output()
340        .map_err(|e| RepoKaiError::Git(e.to_string()))?;
341
342    if !output.status.success() {
343        let err = String::from_utf8_lossy(&output.stderr).to_string();
344        return Err(RepoKaiError::Git(format!("clone failed: {err}")));
345    }
346
347    Ok(())
348}
349
350// ---- Update repo settings ----
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct UpdateRepoOptions {
354    pub description: Option<String>,
355    pub private: Option<bool>,
356}
357
358pub async fn update_repo(
359    client: &Octocrab,
360    owner: &str,
361    repo: &str,
362    opts: &UpdateRepoOptions,
363) -> Result<(), RepoKaiError> {
364    let mut body = serde_json::Map::new();
365    if let Some(desc) = &opts.description {
366        body.insert("description".into(), serde_json::json!(desc));
367    }
368    if let Some(private) = opts.private {
369        body.insert("private".into(), serde_json::json!(private));
370    }
371
372    let _: serde_json::Value = client
373        .patch(
374            format!("/repos/{owner}/{repo}"),
375            Some(&serde_json::Value::Object(body)),
376        )
377        .await?;
378
379    Ok(())
380}