1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use reqwest::{header, Client};
4use serde::Deserialize;
5
6use crate::types::Comment;
7
8#[derive(Debug, Clone)]
13pub struct PrStatus {
14 pub merged: bool,
15 pub state: String, pub mergeable: Option<bool>,
17 pub title: String,
18 pub number: u64,
19 pub head_sha: String,
20}
21
22#[derive(Debug, Clone, PartialEq)]
25pub struct PrRef {
26 pub number: u64,
27 pub url: String,
28}
29
30#[derive(Debug, Clone)]
31pub struct CheckRun {
32 pub name: String,
33 pub status: String, pub conclusion: Option<String>, }
36
37#[derive(Debug, Clone)]
38pub struct ReviewThread {
39 pub id: i64,
40 pub author: String,
41 pub body: String,
42 pub path: Option<String>,
43 pub line: Option<u32>,
44 pub state: String, pub created_at: i64, }
47
48#[derive(Deserialize)]
53struct GhPrHead {
54 sha: String,
55}
56
57#[derive(Deserialize)]
58struct GhPr {
59 number: u64,
60 title: String,
61 state: String,
62 merged: bool,
63 mergeable: Option<bool>,
64 head: GhPrHead,
65}
66
67#[derive(Deserialize)]
68struct GhCheckRunsResponse {
69 check_runs: Vec<GhCheckRun>,
70}
71
72#[derive(Deserialize)]
73struct GhCheckRun {
74 name: String,
75 status: String,
76 conclusion: Option<String>,
77}
78
79#[derive(Deserialize)]
80struct GhReview {
81 id: i64,
82 user: GhUser,
83 body: String,
84 state: String,
85 #[serde(default)]
86 submitted_at: Option<String>,
87}
88
89#[derive(Deserialize)]
90struct GhReviewComment {
91 id: i64,
92 user: GhUser,
93 body: String,
94 path: Option<String>,
95 line: Option<u32>,
96 created_at: String,
97}
98
99#[derive(Deserialize)]
100struct GhIssueComment {
101 id: i64,
102 user: GhUser,
103 body: String,
104 created_at: String,
105}
106
107#[derive(Deserialize)]
108struct GhUser { login: String }
109
110#[derive(Deserialize)]
114struct GhPrListItem {
115 number: u64,
116 html_url: String,
117}
118
119#[async_trait]
125pub trait GithubApi: Send + Sync {
126 async fn get_pr_status(&self, owner: &str, repo: &str, pr_number: u64) -> Result<PrStatus>;
127 async fn get_ci_checks(&self, owner: &str, repo: &str, head_sha: &str) -> Result<Vec<CheckRun>>;
128 async fn get_review_threads(&self, owner: &str, repo: &str, pr_number: u64) -> Result<Vec<ReviewThread>>;
129 async fn get_issue_comments(&self, owner: &str, repo: &str, pr_number: u64) -> Result<Vec<Comment>>;
135 async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<Option<PrRef>>;
139}
140
141#[derive(Clone)]
146pub struct GitHubClient {
147 http: Client,
148 token: String,
149}
150
151impl GitHubClient {
152 pub fn new(token: String) -> Result<Self> {
153 let mut headers = header::HeaderMap::new();
154 headers.insert(
155 header::ACCEPT,
156 header::HeaderValue::from_static("application/vnd.github+json"),
157 );
158 headers.insert(
159 "X-GitHub-Api-Version",
160 header::HeaderValue::from_static("2022-11-28"),
161 );
162 let http = Client::builder()
163 .user_agent("ninox/0.1")
164 .default_headers(headers)
165 .build()
166 .context("failed to build HTTP client")?;
167 Ok(Self { http, token })
168 }
169
170 fn auth(&self) -> String {
171 format!("Bearer {}", self.token)
172 }
173}
174
175#[async_trait]
176impl GithubApi for GitHubClient {
177 async fn get_pr_status(
178 &self,
179 owner: &str,
180 repo: &str,
181 pr_number: u64,
182 ) -> Result<PrStatus> {
183 let url = format!(
184 "https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
185 );
186 let gh: GhPr = self
187 .http
188 .get(&url)
189 .header(header::AUTHORIZATION, self.auth())
190 .send()
191 .await?
192 .error_for_status()?
193 .json()
194 .await?;
195 Ok(PrStatus {
196 merged: gh.merged,
197 state: gh.state,
198 mergeable: gh.mergeable,
199 title: gh.title,
200 number: gh.number,
201 head_sha: gh.head.sha,
202 })
203 }
204
205 async fn get_ci_checks(
206 &self,
207 owner: &str,
208 repo: &str,
209 head_sha: &str,
210 ) -> Result<Vec<CheckRun>> {
211 let url = format!(
212 "https://api.github.com/repos/{owner}/{repo}/commits/{head_sha}/check-runs?per_page=100"
213 );
214 let resp: GhCheckRunsResponse = self
215 .http
216 .get(&url)
217 .header(header::AUTHORIZATION, self.auth())
218 .send()
219 .await?
220 .error_for_status()?
221 .json()
222 .await?;
223 Ok(resp.check_runs.into_iter().map(|r| CheckRun {
224 name: r.name,
225 status: r.status,
226 conclusion: r.conclusion,
227 }).collect())
228 }
229
230 async fn get_review_threads(
231 &self,
232 owner: &str,
233 repo: &str,
234 pr_number: u64,
235 ) -> Result<Vec<ReviewThread>> {
236 let url = format!(
237 "https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/reviews?per_page=100"
238 );
239 let reviews: Vec<GhReview> = self
240 .http
241 .get(&url)
242 .header(header::AUTHORIZATION, self.auth())
243 .send()
244 .await?
245 .error_for_status()?
246 .json()
247 .await?;
248
249 let comments_url = format!(
251 "https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments?per_page=100"
252 );
253 let comments: Vec<GhReviewComment> = self
254 .http
255 .get(&comments_url)
256 .header(header::AUTHORIZATION, self.auth())
257 .send()
258 .await?
259 .error_for_status()?
260 .json()
261 .await?;
262
263 let mut threads: Vec<ReviewThread> = reviews.into_iter().map(|r| ReviewThread {
264 id: r.id,
265 author: r.user.login,
266 body: r.body,
267 path: None,
268 line: None,
269 state: r.state,
270 created_at: r.submitted_at.as_deref().map(parse_github_timestamp).unwrap_or(0),
271 }).collect();
272
273 for c in comments {
274 threads.push(ReviewThread {
275 id: c.id,
276 author: c.user.login,
277 body: c.body,
278 path: c.path,
279 line: c.line,
280 state: "COMMENTED".to_string(),
281 created_at: parse_github_timestamp(&c.created_at),
282 });
283 }
284
285 Ok(threads)
286 }
287
288 async fn get_issue_comments(
289 &self,
290 owner: &str,
291 repo: &str,
292 pr_number: u64,
293 ) -> Result<Vec<Comment>> {
294 let url = format!(
295 "https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments?per_page=100"
296 );
297 let comments: Vec<GhIssueComment> = self
298 .http
299 .get(&url)
300 .header(header::AUTHORIZATION, self.auth())
301 .send()
302 .await?
303 .error_for_status()?
304 .json()
305 .await?;
306
307 Ok(comments.into_iter().map(|c| Comment {
308 id: c.id,
309 pr_id: pr_number as i64,
310 author: c.user.login,
311 body: c.body,
312 path: None,
313 line: None,
314 created_at: parse_github_timestamp(&c.created_at),
315 }).collect())
316 }
317
318 async fn find_open_pr_for_branch(
319 &self,
320 owner: &str,
321 repo: &str,
322 branch: &str,
323 ) -> Result<Option<PrRef>> {
324 let url = format!(
325 "https://api.github.com/repos/{owner}/{repo}/pulls?head={owner}:{branch}&state=open&per_page=5"
326 );
327 let items: Vec<GhPrListItem> = self
328 .http
329 .get(&url)
330 .header(header::AUTHORIZATION, self.auth())
331 .send()
332 .await?
333 .error_for_status()?
334 .json()
335 .await?;
336 Ok(items.into_iter().next().map(|p| PrRef { number: p.number, url: p.html_url }))
337 }
338}
339
340pub fn split_repo(s: &str) -> Option<(String, String)> {
353 let s = s.trim();
354 let tail = if let Some(rest) = s.strip_prefix("https://").or_else(|| s.strip_prefix("http://")) {
355 rest.trim_start_matches("github.com/")
356 } else if let Some(rest) = s.strip_prefix("ssh://") {
357 rest.split_once('/').map(|(_host, r)| r).unwrap_or(rest)
358 } else if let Some((_host, rest)) = s.split_once(':') {
359 rest
362 } else {
363 s.trim_start_matches("github.com/")
364 };
365 let mut parts = tail.trim_start_matches('/').splitn(2, '/');
366 let owner = parts.next()?.to_string();
367 let repo = parts.next()?.trim_end_matches(".git").to_string();
368 if owner.is_empty() || repo.is_empty() { return None; }
369 Some((owner, repo))
370}
371
372pub fn candidate_repos(workspace: &str) -> Vec<String> {
379 let Ok(output) = std::process::Command::new("git")
380 .args(["-C", workspace, "remote"])
381 .output()
382 else {
383 return Vec::new();
384 };
385 if !output.status.success() {
386 return Vec::new();
387 }
388 let mut names: Vec<String> = String::from_utf8_lossy(&output.stdout)
389 .lines()
390 .map(|l| l.trim().to_string())
391 .filter(|l| !l.is_empty())
392 .collect();
393 names.sort_by_key(|n| if n == "origin" { 0 } else { 1 });
394
395 let mut repos = Vec::new();
396 for name in names {
397 let Ok(out) = std::process::Command::new("git")
398 .args(["-C", workspace, "remote", "get-url", &name])
399 .output()
400 else {
401 continue;
402 };
403 if !out.status.success() {
404 continue;
405 }
406 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
407 if let Some((owner, repo)) = split_repo(&url) {
408 let slug = format!("{owner}/{repo}");
409 if !repos.contains(&slug) {
410 repos.push(slug);
411 }
412 }
413 }
414 repos
415}
416
417pub fn current_branch(workspace: &str) -> Option<String> {
420 let output = std::process::Command::new("git")
421 .args(["-C", workspace, "rev-parse", "--abbrev-ref", "HEAD"])
422 .output()
423 .ok()?;
424 if !output.status.success() {
425 return None;
426 }
427 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
428 if branch.is_empty() || branch == "HEAD" {
429 return None;
430 }
431 Some(branch)
432}
433
434fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
438 let y = if m <= 2 { y - 1 } else { y };
439 let era = if y >= 0 { y } else { y - 399 } / 400;
440 let yoe = y - era * 400; let mp = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146097 + doe - 719468
445}
446
447fn parse_github_timestamp(s: &str) -> i64 {
451 let b = s.as_bytes();
452 if b.len() < 19 {
453 return 0;
454 }
455 let (Ok(year), Ok(month), Ok(day), Ok(hour), Ok(min), Ok(sec)) = (
456 s[0..4].parse::<i64>(),
457 s[5..7].parse::<i64>(),
458 s[8..10].parse::<i64>(),
459 s[11..13].parse::<i64>(),
460 s[14..16].parse::<i64>(),
461 s[17..19].parse::<i64>(),
462 ) else {
463 return 0;
464 };
465 let days = days_from_civil(year, month, day);
466 (days * 86_400 + hour * 3_600 + min * 60 + sec) * 1000
467}
468
469pub fn resolve_token(config_token: Option<String>) -> Option<String> {
471 config_token
472 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
473 .or_else(|| {
474 std::process::Command::new("gh")
475 .args(["auth", "token"])
476 .output()
477 .ok()
478 .filter(|o| o.status.success())
479 .and_then(|o| String::from_utf8(o.stdout).ok())
480 .map(|s| s.trim().to_string())
481 .filter(|s| !s.is_empty())
482 })
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn parse_repo_owner_from_url() {
491 let (owner, repo) = split_repo("Made-by-Moonlight/Athene").unwrap();
492 assert_eq!(owner, "Made-by-Moonlight");
493 assert_eq!(repo, "Athene");
494 }
495
496 #[test]
497 fn parse_repo_owner_strips_github_prefix() {
498 let (owner, repo) = split_repo("github.com/Made-by-Moonlight/Athene").unwrap();
499 assert_eq!(owner, "Made-by-Moonlight");
500 assert_eq!(repo, "Athene");
501 }
502
503 #[test]
504 fn invalid_repo_returns_none() {
505 assert!(split_repo("notarepo").is_none());
506 }
507
508 #[test]
509 fn parse_repo_owner_from_ssh_scp_syntax() {
510 let (owner, repo) = split_repo("git@github.com:Made-by-Moonlight/Athene.git").unwrap();
511 assert_eq!(owner, "Made-by-Moonlight");
512 assert_eq!(repo, "Athene");
513 }
514
515 #[test]
521 fn parse_repo_owner_from_ssh_scp_syntax_with_custom_host_alias() {
522 let (owner, repo) = split_repo("git@github.com-synthesia:Synthesia-Technologies/ninox.git").unwrap();
523 assert_eq!(owner, "Synthesia-Technologies");
524 assert_eq!(repo, "ninox");
525 }
526
527 #[test]
528 fn parse_repo_owner_from_ssh_url_syntax() {
529 let (owner, repo) = split_repo("ssh://git@github.com/Made-by-Moonlight/Athene.git").unwrap();
530 assert_eq!(owner, "Made-by-Moonlight");
531 assert_eq!(repo, "Athene");
532 }
533
534 #[test]
535 fn parse_repo_owner_strips_git_suffix_from_https_url() {
536 let (owner, repo) = split_repo("https://github.com/Made-by-Moonlight/Athene.git").unwrap();
537 assert_eq!(owner, "Made-by-Moonlight");
538 assert_eq!(repo, "Athene");
539 }
540
541 #[test]
542 fn resolve_token_prefers_config_over_env() {
543 let token = resolve_token(Some("config-token".to_string()));
544 assert_eq!(token, Some("config-token".to_string()));
545 }
546
547 #[test]
548 fn parse_github_timestamp_matches_known_unix_millis() {
549 assert_eq!(parse_github_timestamp("2024-01-02T03:04:05Z"), 1_704_164_645_000);
551 }
552
553 #[test]
554 fn parse_github_timestamp_handles_epoch() {
555 assert_eq!(parse_github_timestamp("1970-01-01T00:00:00Z"), 0);
556 }
557
558 #[test]
559 fn parse_github_timestamp_returns_zero_for_garbage() {
560 assert_eq!(parse_github_timestamp("not-a-timestamp"), 0);
561 }
562
563 fn init_repo(dir: &std::path::Path) {
564 let run = |args: &[&str]| {
565 let status = std::process::Command::new("git")
566 .args(["-C", &dir.to_string_lossy()])
567 .args(args)
568 .status()
569 .unwrap();
570 assert!(status.success(), "git {args:?} failed");
571 };
572 run(&["init", "-q"]);
573 run(&["config", "user.email", "test@example.com"]);
574 run(&["config", "user.name", "Test"]);
575 run(&["commit", "--allow-empty", "-q", "-m", "init"]);
576 }
577
578 #[test]
579 fn candidate_repos_returns_empty_outside_a_git_repo() {
580 let dir = tempfile::tempdir().unwrap();
581 assert!(candidate_repos(&dir.path().to_string_lossy()).is_empty());
582 }
583
584 #[test]
589 fn candidate_repos_lists_origin_first_then_other_remotes() {
590 let dir = tempfile::tempdir().unwrap();
591 init_repo(dir.path());
592 let workspace = dir.path().to_string_lossy().to_string();
593 std::process::Command::new("git")
594 .args(["-C", &workspace, "remote", "add", "internal", "git@github.com-synthesia:Synthesia-Technologies/ninox.git"])
595 .status().unwrap();
596 std::process::Command::new("git")
597 .args(["-C", &workspace, "remote", "add", "origin", "https://github.com/Made-by-Moonlight/ninox.git"])
598 .status().unwrap();
599
600 let repos = candidate_repos(&workspace);
601 assert_eq!(repos, vec!["Made-by-Moonlight/ninox".to_string(), "Synthesia-Technologies/ninox".to_string()]);
602 }
603
604 #[test]
605 fn current_branch_reads_checked_out_branch() {
606 let dir = tempfile::tempdir().unwrap();
607 init_repo(dir.path());
608 let workspace = dir.path().to_string_lossy().to_string();
609 std::process::Command::new("git")
610 .args(["-C", &workspace, "checkout", "-q", "-b", "feat/my-fix"])
611 .status().unwrap();
612 assert_eq!(current_branch(&workspace).as_deref(), Some("feat/my-fix"));
613 }
614
615 #[test]
616 fn current_branch_none_outside_a_git_repo() {
617 let dir = tempfile::tempdir().unwrap();
618 assert_eq!(current_branch(&dir.path().to_string_lossy()), None);
619 }
620}