Skip to main content

ninox_core/
github.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use reqwest::{header, Client};
4use serde::Deserialize;
5
6use crate::types::Comment;
7
8// ---------------------------------------------------------------------------
9// Public data types
10// ---------------------------------------------------------------------------
11
12#[derive(Debug, Clone)]
13pub struct PrStatus {
14    pub merged:    bool,
15    pub state:     String,   // "open" | "closed"
16    pub mergeable: Option<bool>,
17    pub title:     String,
18    pub number:    u64,
19    pub head_sha:  String,
20}
21
22/// A PR found by searching for an existing head branch (`find_open_pr_for_branch`),
23/// independent of any metadata the `gh` wrapper hook may or may not have recorded.
24#[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,      // "queued" | "in_progress" | "completed"
34    pub conclusion:  Option<String>, // "success" | "failure" | "neutral" | ...
35}
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,  // "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED"
45    pub created_at: i64,     // unix millis
46}
47
48// ---------------------------------------------------------------------------
49// Internal API response shapes
50// ---------------------------------------------------------------------------
51
52#[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/// Shape returned by the PR *list* endpoint (`GET .../pulls?head=...`) — much
111/// thinner than the single-PR shape (`GhPr`): no `merged`/`mergeable`, so it
112/// cannot reuse `GhPr`.
113#[derive(Deserialize)]
114struct GhPrListItem {
115    number:   u64,
116    html_url: String,
117}
118
119// ---------------------------------------------------------------------------
120// Trait — allows the poller to be driven by a fake in tests, without any
121// network access.
122// ---------------------------------------------------------------------------
123
124#[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    /// The PR's main conversation-tab comments (`GET
130    /// /repos/{owner}/{repo}/issues/{pr_number}/comments`) — distinct from
131    /// `get_review_threads`, which only covers review submissions and inline
132    /// diff comments. Mapped directly into `Comment` (`path`/`line` are
133    /// always `None` — issue comments aren't anchored to a diff position).
134    async fn get_issue_comments(&self, owner: &str, repo: &str, pr_number: u64) -> Result<Vec<Comment>>;
135    /// Find an open PR whose head branch is `branch`, independent of any
136    /// metadata the `gh` wrapper hook may or may not have recorded — the
137    /// active fallback for PRs created outside the wrapped `gh pr create`.
138    async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<Option<PrRef>>;
139}
140
141// ---------------------------------------------------------------------------
142// Client
143// ---------------------------------------------------------------------------
144
145#[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        // Also fetch inline review comments
250        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
340// ---------------------------------------------------------------------------
341// Helpers
342// ---------------------------------------------------------------------------
343
344/// Parse a git remote URL or bare slug into (owner, repo). Handles
345/// "owner/repo", "https://github.com/owner/repo(.git)",
346/// "ssh://[user@]host/owner/repo(.git)", and scp-like SSH syntax
347/// "[user@]host:owner/repo(.git)" — including a *custom* ssh config host
348/// alias (e.g. `git@github.com-work:owner/repo.git`), which multi-remote
349/// setups (a personal `origin` alongside an internal mirror reached via an
350/// aliased SSH host) rely on and which a fixed `github.com` prefix can't
351/// recognize.
352pub 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        // scp-like syntax: the host is whatever precedes the colon —
360        // deliberately not matched against a literal `github.com`.
361        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
372/// Every configured git remote's GitHub repo slug (`owner/repo`) for
373/// `workspace`, `origin` first (the common case, tried with no extra
374/// requests) then any other remote in `git remote` order. Repos here
375/// routinely carry a personal `origin` alongside an internal mirror remote —
376/// PR detection must not assume a PR always lives against `origin`.
377/// Returns an empty `Vec` if `workspace` isn't a git repo or has no remotes.
378pub 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
417/// The branch currently checked out in `workspace`. `None` on detached HEAD
418/// or if `workspace` isn't a git repo.
419pub 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
434/// Days since the Unix epoch for a proleptic-Gregorian civil date — Howard
435/// Hinnant's `days_from_civil`, used by `parse_github_timestamp` since no
436/// date/time crate is a workspace dependency.
437fn 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; // [0, 399]
441    let mp = if m > 2 { m - 3 } else { m + 9 }; // [0, 11]
442    let doy = (153 * mp + 2) / 5 + d - 1; // [0, 365]
443    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
444    era * 146097 + doe - 719468
445}
446
447/// Parse a GitHub API timestamp (`"2024-01-02T03:04:05Z"`, always UTC) into
448/// Unix epoch milliseconds. Returns `0` for anything unparseable rather than
449/// failing the whole fetch over one bad timestamp.
450fn 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
469/// Resolve GitHub token: config value → GITHUB_TOKEN env → `gh auth token`.
470pub 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    /// A custom ssh config host alias (e.g. `Host github.com-work` pointing
516    /// at a different account/key) is exactly how this environment's
517    /// internal mirror remote is configured — the host must be recognized
518    /// structurally (anything before the `:`), not by matching a literal
519    /// `github.com`.
520    #[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        // 2024-01-02T03:04:05Z, cross-checked against `date -u -d ... +%s`.
550        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    /// The environment this bug was filed for has both a personal `origin`
585    /// (HTTPS) remote and an internal mirror reached over SSH through a
586    /// custom host alias — `origin` must come first (no extra requests in
587    /// the common case), but the mirror must still be found.
588    #[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}