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