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
6// ---------------------------------------------------------------------------
7// Public data types
8// ---------------------------------------------------------------------------
9
10#[derive(Debug, Clone)]
11pub struct PrStatus {
12    pub merged:    bool,
13    pub state:     String,   // "open" | "closed"
14    pub mergeable: Option<bool>,
15    pub title:     String,
16    pub number:    u64,
17    pub head_sha:  String,
18}
19
20/// A PR found by searching for an existing head branch (`find_open_pr_for_branch`),
21/// independent of any metadata the `gh` wrapper hook may or may not have recorded.
22#[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,      // "queued" | "in_progress" | "completed"
32    pub conclusion:  Option<String>, // "success" | "failure" | "neutral" | ...
33}
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,  // "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED"
43}
44
45// ---------------------------------------------------------------------------
46// Internal API response shapes
47// ---------------------------------------------------------------------------
48
49#[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/// Shape returned by the PR *list* endpoint (`GET .../pulls?head=...`) — much
97/// thinner than the single-PR shape (`GhPr`): no `merged`/`mergeable`, so it
98/// cannot reuse `GhPr`.
99#[derive(Deserialize)]
100struct GhPrListItem {
101    number:   u64,
102    html_url: String,
103}
104
105// ---------------------------------------------------------------------------
106// Trait — allows the poller to be driven by a fake in tests, without any
107// network access.
108// ---------------------------------------------------------------------------
109
110#[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    /// Find an open PR whose head branch is `branch`, independent of any
116    /// metadata the `gh` wrapper hook may or may not have recorded — the
117    /// active fallback for PRs created outside the wrapped `gh pr create`.
118    async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<Option<PrRef>>;
119}
120
121// ---------------------------------------------------------------------------
122// Client
123// ---------------------------------------------------------------------------
124
125#[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        // Also fetch inline review comments
230        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
288// ---------------------------------------------------------------------------
289// Helpers
290// ---------------------------------------------------------------------------
291
292/// Parse a git remote URL or bare slug into (owner, repo). Handles
293/// "owner/repo", "https://github.com/owner/repo(.git)",
294/// "ssh://[user@]host/owner/repo(.git)", and scp-like SSH syntax
295/// "[user@]host:owner/repo(.git)" — including a *custom* ssh config host
296/// alias (e.g. `git@github.com-work:owner/repo.git`), which multi-remote
297/// setups (a personal `origin` alongside an internal mirror reached via an
298/// aliased SSH host) rely on and which a fixed `github.com` prefix can't
299/// recognize.
300pub 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        // scp-like syntax: the host is whatever precedes the colon —
308        // deliberately not matched against a literal `github.com`.
309        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
320/// Every configured git remote's GitHub repo slug (`owner/repo`) for
321/// `workspace`, `origin` first (the common case, tried with no extra
322/// requests) then any other remote in `git remote` order. Repos here
323/// routinely carry a personal `origin` alongside an internal mirror remote —
324/// PR detection must not assume a PR always lives against `origin`.
325/// Returns an empty `Vec` if `workspace` isn't a git repo or has no remotes.
326pub 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
365/// The branch currently checked out in `workspace`. `None` on detached HEAD
366/// or if `workspace` isn't a git repo.
367pub 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
382/// Resolve GitHub token: config value → GITHUB_TOKEN env → `gh auth token`.
383pub 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    /// A custom ssh config host alias (e.g. `Host github.com-work` pointing
429    /// at a different account/key) is exactly how this environment's
430    /// internal mirror remote is configured — the host must be recognized
431    /// structurally (anything before the `:`), not by matching a literal
432    /// `github.com`.
433    #[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    /// The environment this bug was filed for has both a personal `origin`
482    /// (HTTPS) remote and an internal mirror reached over SSH through a
483    /// custom host alias — `origin` must come first (no extra requests in
484    /// the common case), but the mirror must still be found.
485    #[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}