1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use reqwest::{header, Client};
4use serde::Deserialize;
5
6#[derive(Debug, Clone)]
11pub struct PrStatus {
12 pub merged: bool,
13 pub state: String, pub mergeable: Option<bool>,
15 pub title: String,
16 pub number: u64,
17 pub head_sha: String,
18}
19
20#[derive(Debug, Clone, PartialEq)]
23pub struct PrRef {
24 pub number: u64,
25 pub url: String,
26}
27
28#[derive(Debug, Clone)]
29pub struct CheckRun {
30 pub name: String,
31 pub status: String, pub conclusion: Option<String>, }
34
35#[derive(Debug, Clone)]
36pub struct ReviewThread {
37 pub id: i64,
38 pub author: String,
39 pub body: String,
40 pub path: Option<String>,
41 pub line: Option<u32>,
42 pub state: String, }
44
45#[derive(Deserialize)]
50struct GhPrHead {
51 sha: String,
52}
53
54#[derive(Deserialize)]
55struct GhPr {
56 number: u64,
57 title: String,
58 state: String,
59 merged: bool,
60 mergeable: Option<bool>,
61 head: GhPrHead,
62}
63
64#[derive(Deserialize)]
65struct GhCheckRunsResponse {
66 check_runs: Vec<GhCheckRun>,
67}
68
69#[derive(Deserialize)]
70struct GhCheckRun {
71 name: String,
72 status: String,
73 conclusion: Option<String>,
74}
75
76#[derive(Deserialize)]
77struct GhReview {
78 id: i64,
79 user: GhUser,
80 body: String,
81 state: String,
82}
83
84#[derive(Deserialize)]
85struct GhReviewComment {
86 id: i64,
87 user: GhUser,
88 body: String,
89 path: Option<String>,
90 line: Option<u32>,
91}
92
93#[derive(Deserialize)]
94struct GhUser { login: String }
95
96#[derive(Deserialize)]
100struct GhPrListItem {
101 number: u64,
102 html_url: String,
103}
104
105#[async_trait]
111pub trait GithubApi: Send + Sync {
112 async fn get_pr_status(&self, owner: &str, repo: &str, pr_number: u64) -> Result<PrStatus>;
113 async fn get_ci_checks(&self, owner: &str, repo: &str, head_sha: &str) -> Result<Vec<CheckRun>>;
114 async fn get_review_threads(&self, owner: &str, repo: &str, pr_number: u64) -> Result<Vec<ReviewThread>>;
115 async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<Option<PrRef>>;
119}
120
121#[derive(Clone)]
126pub struct GitHubClient {
127 http: Client,
128 token: String,
129}
130
131impl GitHubClient {
132 pub fn new(token: String) -> Result<Self> {
133 let mut headers = header::HeaderMap::new();
134 headers.insert(
135 header::ACCEPT,
136 header::HeaderValue::from_static("application/vnd.github+json"),
137 );
138 headers.insert(
139 "X-GitHub-Api-Version",
140 header::HeaderValue::from_static("2022-11-28"),
141 );
142 let http = Client::builder()
143 .user_agent("ninox/0.1")
144 .default_headers(headers)
145 .build()
146 .context("failed to build HTTP client")?;
147 Ok(Self { http, token })
148 }
149
150 fn auth(&self) -> String {
151 format!("Bearer {}", self.token)
152 }
153}
154
155#[async_trait]
156impl GithubApi for GitHubClient {
157 async fn get_pr_status(
158 &self,
159 owner: &str,
160 repo: &str,
161 pr_number: u64,
162 ) -> Result<PrStatus> {
163 let url = format!(
164 "https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
165 );
166 let gh: GhPr = self
167 .http
168 .get(&url)
169 .header(header::AUTHORIZATION, self.auth())
170 .send()
171 .await?
172 .error_for_status()?
173 .json()
174 .await?;
175 Ok(PrStatus {
176 merged: gh.merged,
177 state: gh.state,
178 mergeable: gh.mergeable,
179 title: gh.title,
180 number: gh.number,
181 head_sha: gh.head.sha,
182 })
183 }
184
185 async fn get_ci_checks(
186 &self,
187 owner: &str,
188 repo: &str,
189 head_sha: &str,
190 ) -> Result<Vec<CheckRun>> {
191 let url = format!(
192 "https://api.github.com/repos/{owner}/{repo}/commits/{head_sha}/check-runs?per_page=100"
193 );
194 let resp: GhCheckRunsResponse = self
195 .http
196 .get(&url)
197 .header(header::AUTHORIZATION, self.auth())
198 .send()
199 .await?
200 .error_for_status()?
201 .json()
202 .await?;
203 Ok(resp.check_runs.into_iter().map(|r| CheckRun {
204 name: r.name,
205 status: r.status,
206 conclusion: r.conclusion,
207 }).collect())
208 }
209
210 async fn get_review_threads(
211 &self,
212 owner: &str,
213 repo: &str,
214 pr_number: u64,
215 ) -> Result<Vec<ReviewThread>> {
216 let url = format!(
217 "https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/reviews?per_page=100"
218 );
219 let reviews: Vec<GhReview> = self
220 .http
221 .get(&url)
222 .header(header::AUTHORIZATION, self.auth())
223 .send()
224 .await?
225 .error_for_status()?
226 .json()
227 .await?;
228
229 let comments_url = format!(
231 "https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments?per_page=100"
232 );
233 let comments: Vec<GhReviewComment> = self
234 .http
235 .get(&comments_url)
236 .header(header::AUTHORIZATION, self.auth())
237 .send()
238 .await?
239 .error_for_status()?
240 .json()
241 .await?;
242
243 let mut threads: Vec<ReviewThread> = reviews.into_iter().map(|r| ReviewThread {
244 id: r.id,
245 author: r.user.login,
246 body: r.body,
247 path: None,
248 line: None,
249 state: r.state,
250 }).collect();
251
252 for c in comments {
253 threads.push(ReviewThread {
254 id: c.id,
255 author: c.user.login,
256 body: c.body,
257 path: c.path,
258 line: c.line,
259 state: "COMMENTED".to_string(),
260 });
261 }
262
263 Ok(threads)
264 }
265
266 async fn find_open_pr_for_branch(
267 &self,
268 owner: &str,
269 repo: &str,
270 branch: &str,
271 ) -> Result<Option<PrRef>> {
272 let url = format!(
273 "https://api.github.com/repos/{owner}/{repo}/pulls?head={owner}:{branch}&state=open&per_page=5"
274 );
275 let items: Vec<GhPrListItem> = self
276 .http
277 .get(&url)
278 .header(header::AUTHORIZATION, self.auth())
279 .send()
280 .await?
281 .error_for_status()?
282 .json()
283 .await?;
284 Ok(items.into_iter().next().map(|p| PrRef { number: p.number, url: p.html_url }))
285 }
286}
287
288pub fn split_repo(s: &str) -> Option<(String, String)> {
301 let s = s.trim();
302 let tail = if let Some(rest) = s.strip_prefix("https://").or_else(|| s.strip_prefix("http://")) {
303 rest.trim_start_matches("github.com/")
304 } else if let Some(rest) = s.strip_prefix("ssh://") {
305 rest.split_once('/').map(|(_host, r)| r).unwrap_or(rest)
306 } else if let Some((_host, rest)) = s.split_once(':') {
307 rest
310 } else {
311 s.trim_start_matches("github.com/")
312 };
313 let mut parts = tail.trim_start_matches('/').splitn(2, '/');
314 let owner = parts.next()?.to_string();
315 let repo = parts.next()?.trim_end_matches(".git").to_string();
316 if owner.is_empty() || repo.is_empty() { return None; }
317 Some((owner, repo))
318}
319
320pub fn candidate_repos(workspace: &str) -> Vec<String> {
327 let Ok(output) = std::process::Command::new("git")
328 .args(["-C", workspace, "remote"])
329 .output()
330 else {
331 return Vec::new();
332 };
333 if !output.status.success() {
334 return Vec::new();
335 }
336 let mut names: Vec<String> = String::from_utf8_lossy(&output.stdout)
337 .lines()
338 .map(|l| l.trim().to_string())
339 .filter(|l| !l.is_empty())
340 .collect();
341 names.sort_by_key(|n| if n == "origin" { 0 } else { 1 });
342
343 let mut repos = Vec::new();
344 for name in names {
345 let Ok(out) = std::process::Command::new("git")
346 .args(["-C", workspace, "remote", "get-url", &name])
347 .output()
348 else {
349 continue;
350 };
351 if !out.status.success() {
352 continue;
353 }
354 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
355 if let Some((owner, repo)) = split_repo(&url) {
356 let slug = format!("{owner}/{repo}");
357 if !repos.contains(&slug) {
358 repos.push(slug);
359 }
360 }
361 }
362 repos
363}
364
365pub fn current_branch(workspace: &str) -> Option<String> {
368 let output = std::process::Command::new("git")
369 .args(["-C", workspace, "rev-parse", "--abbrev-ref", "HEAD"])
370 .output()
371 .ok()?;
372 if !output.status.success() {
373 return None;
374 }
375 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
376 if branch.is_empty() || branch == "HEAD" {
377 return None;
378 }
379 Some(branch)
380}
381
382pub fn resolve_token(config_token: Option<String>) -> Option<String> {
384 config_token
385 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
386 .or_else(|| {
387 std::process::Command::new("gh")
388 .args(["auth", "token"])
389 .output()
390 .ok()
391 .filter(|o| o.status.success())
392 .and_then(|o| String::from_utf8(o.stdout).ok())
393 .map(|s| s.trim().to_string())
394 .filter(|s| !s.is_empty())
395 })
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn parse_repo_owner_from_url() {
404 let (owner, repo) = split_repo("Made-by-Moonlight/Athene").unwrap();
405 assert_eq!(owner, "Made-by-Moonlight");
406 assert_eq!(repo, "Athene");
407 }
408
409 #[test]
410 fn parse_repo_owner_strips_github_prefix() {
411 let (owner, repo) = split_repo("github.com/Made-by-Moonlight/Athene").unwrap();
412 assert_eq!(owner, "Made-by-Moonlight");
413 assert_eq!(repo, "Athene");
414 }
415
416 #[test]
417 fn invalid_repo_returns_none() {
418 assert!(split_repo("notarepo").is_none());
419 }
420
421 #[test]
422 fn parse_repo_owner_from_ssh_scp_syntax() {
423 let (owner, repo) = split_repo("git@github.com:Made-by-Moonlight/Athene.git").unwrap();
424 assert_eq!(owner, "Made-by-Moonlight");
425 assert_eq!(repo, "Athene");
426 }
427
428 #[test]
434 fn parse_repo_owner_from_ssh_scp_syntax_with_custom_host_alias() {
435 let (owner, repo) = split_repo("git@github.com-synthesia:Synthesia-Technologies/ninox.git").unwrap();
436 assert_eq!(owner, "Synthesia-Technologies");
437 assert_eq!(repo, "ninox");
438 }
439
440 #[test]
441 fn parse_repo_owner_from_ssh_url_syntax() {
442 let (owner, repo) = split_repo("ssh://git@github.com/Made-by-Moonlight/Athene.git").unwrap();
443 assert_eq!(owner, "Made-by-Moonlight");
444 assert_eq!(repo, "Athene");
445 }
446
447 #[test]
448 fn parse_repo_owner_strips_git_suffix_from_https_url() {
449 let (owner, repo) = split_repo("https://github.com/Made-by-Moonlight/Athene.git").unwrap();
450 assert_eq!(owner, "Made-by-Moonlight");
451 assert_eq!(repo, "Athene");
452 }
453
454 #[test]
455 fn resolve_token_prefers_config_over_env() {
456 let token = resolve_token(Some("config-token".to_string()));
457 assert_eq!(token, Some("config-token".to_string()));
458 }
459
460 fn init_repo(dir: &std::path::Path) {
461 let run = |args: &[&str]| {
462 let status = std::process::Command::new("git")
463 .args(["-C", &dir.to_string_lossy()])
464 .args(args)
465 .status()
466 .unwrap();
467 assert!(status.success(), "git {args:?} failed");
468 };
469 run(&["init", "-q"]);
470 run(&["config", "user.email", "test@example.com"]);
471 run(&["config", "user.name", "Test"]);
472 run(&["commit", "--allow-empty", "-q", "-m", "init"]);
473 }
474
475 #[test]
476 fn candidate_repos_returns_empty_outside_a_git_repo() {
477 let dir = tempfile::tempdir().unwrap();
478 assert!(candidate_repos(&dir.path().to_string_lossy()).is_empty());
479 }
480
481 #[test]
486 fn candidate_repos_lists_origin_first_then_other_remotes() {
487 let dir = tempfile::tempdir().unwrap();
488 init_repo(dir.path());
489 let workspace = dir.path().to_string_lossy().to_string();
490 std::process::Command::new("git")
491 .args(["-C", &workspace, "remote", "add", "internal", "git@github.com-synthesia:Synthesia-Technologies/ninox.git"])
492 .status().unwrap();
493 std::process::Command::new("git")
494 .args(["-C", &workspace, "remote", "add", "origin", "https://github.com/Made-by-Moonlight/ninox.git"])
495 .status().unwrap();
496
497 let repos = candidate_repos(&workspace);
498 assert_eq!(repos, vec!["Made-by-Moonlight/ninox".to_string(), "Synthesia-Technologies/ninox".to_string()]);
499 }
500
501 #[test]
502 fn current_branch_reads_checked_out_branch() {
503 let dir = tempfile::tempdir().unwrap();
504 init_repo(dir.path());
505 let workspace = dir.path().to_string_lossy().to_string();
506 std::process::Command::new("git")
507 .args(["-C", &workspace, "checkout", "-q", "-b", "feat/my-fix"])
508 .status().unwrap();
509 assert_eq!(current_branch(&workspace).as_deref(), Some("feat/my-fix"));
510 }
511
512 #[test]
513 fn current_branch_none_outside_a_git_repo() {
514 let dir = tempfile::tempdir().unwrap();
515 assert_eq!(current_branch(&dir.path().to_string_lossy()), None);
516 }
517}