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