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 if let Ok(token) = std::env::var("GITHUB_TOKEN") {
40 return Ok(token);
41 }
42
43 if let Ok(token) = read_token_from_keychain() {
45 return Ok(token);
46 }
47
48 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 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 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#[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 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 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 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 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 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
327pub 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#[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}