Skip to main content

ninox_core/
github.rs

1use anyhow::{Context, Result};
2use reqwest::{header, Client};
3use serde::Deserialize;
4
5// ---------------------------------------------------------------------------
6// Public data types
7// ---------------------------------------------------------------------------
8
9#[derive(Debug, Clone)]
10pub struct PrStatus {
11    pub merged:    bool,
12    pub state:     String,   // "open" | "closed"
13    pub mergeable: Option<bool>,
14    pub title:     String,
15    pub number:    u64,
16    pub head_sha:  String,
17}
18
19#[derive(Debug, Clone)]
20pub struct CheckRun {
21    pub name:        String,
22    pub status:      String,      // "queued" | "in_progress" | "completed"
23    pub conclusion:  Option<String>, // "success" | "failure" | "neutral" | ...
24}
25
26#[derive(Debug, Clone)]
27pub struct ReviewThread {
28    pub id:     i64,
29    pub author: String,
30    pub body:   String,
31    pub path:   Option<String>,
32    pub line:   Option<u32>,
33    pub state:  String,  // "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED"
34}
35
36// ---------------------------------------------------------------------------
37// Internal API response shapes
38// ---------------------------------------------------------------------------
39
40#[derive(Deserialize)]
41struct GhPrHead {
42    sha: String,
43}
44
45#[derive(Deserialize)]
46struct GhPr {
47    number:    u64,
48    title:     String,
49    state:     String,
50    merged:    bool,
51    mergeable: Option<bool>,
52    head:      GhPrHead,
53}
54
55#[derive(Deserialize)]
56struct GhCheckRunsResponse {
57    check_runs: Vec<GhCheckRun>,
58}
59
60#[derive(Deserialize)]
61struct GhCheckRun {
62    name:       String,
63    status:     String,
64    conclusion: Option<String>,
65}
66
67#[derive(Deserialize)]
68struct GhReview {
69    id:   i64,
70    user: GhUser,
71    body: String,
72    state: String,
73}
74
75#[derive(Deserialize)]
76struct GhReviewComment {
77    id:   i64,
78    user: GhUser,
79    body: String,
80    path: Option<String>,
81    line: Option<u32>,
82}
83
84#[derive(Deserialize)]
85struct GhUser { login: String }
86
87// ---------------------------------------------------------------------------
88// Client
89// ---------------------------------------------------------------------------
90
91#[derive(Clone)]
92pub struct GitHubClient {
93    http:  Client,
94    token: String,
95}
96
97impl GitHubClient {
98    pub fn new(token: String) -> Result<Self> {
99        let mut headers = header::HeaderMap::new();
100        headers.insert(
101            header::ACCEPT,
102            header::HeaderValue::from_static("application/vnd.github+json"),
103        );
104        headers.insert(
105            "X-GitHub-Api-Version",
106            header::HeaderValue::from_static("2022-11-28"),
107        );
108        let http = Client::builder()
109            .user_agent("ninox/0.1")
110            .default_headers(headers)
111            .build()
112            .context("failed to build HTTP client")?;
113        Ok(Self { http, token })
114    }
115
116    fn auth(&self) -> String {
117        format!("Bearer {}", self.token)
118    }
119
120    pub async fn get_pr_status(
121        &self,
122        owner: &str,
123        repo: &str,
124        pr_number: u64,
125    ) -> Result<PrStatus> {
126        let url = format!(
127            "https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
128        );
129        let gh: GhPr = self
130            .http
131            .get(&url)
132            .header(header::AUTHORIZATION, self.auth())
133            .send()
134            .await?
135            .error_for_status()?
136            .json()
137            .await?;
138        Ok(PrStatus {
139            merged:    gh.merged,
140            state:     gh.state,
141            mergeable: gh.mergeable,
142            title:     gh.title,
143            number:    gh.number,
144            head_sha:  gh.head.sha,
145        })
146    }
147
148    pub async fn get_ci_checks(
149        &self,
150        owner: &str,
151        repo: &str,
152        head_sha: &str,
153    ) -> Result<Vec<CheckRun>> {
154        let url = format!(
155            "https://api.github.com/repos/{owner}/{repo}/commits/{head_sha}/check-runs?per_page=100"
156        );
157        let resp: GhCheckRunsResponse = self
158            .http
159            .get(&url)
160            .header(header::AUTHORIZATION, self.auth())
161            .send()
162            .await?
163            .error_for_status()?
164            .json()
165            .await?;
166        Ok(resp.check_runs.into_iter().map(|r| CheckRun {
167            name:       r.name,
168            status:     r.status,
169            conclusion: r.conclusion,
170        }).collect())
171    }
172
173    pub async fn get_review_threads(
174        &self,
175        owner: &str,
176        repo: &str,
177        pr_number: u64,
178    ) -> Result<Vec<ReviewThread>> {
179        let url = format!(
180            "https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/reviews?per_page=100"
181        );
182        let reviews: Vec<GhReview> = self
183            .http
184            .get(&url)
185            .header(header::AUTHORIZATION, self.auth())
186            .send()
187            .await?
188            .error_for_status()?
189            .json()
190            .await?;
191
192        // Also fetch inline review comments
193        let comments_url = format!(
194            "https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments?per_page=100"
195        );
196        let comments: Vec<GhReviewComment> = self
197            .http
198            .get(&comments_url)
199            .header(header::AUTHORIZATION, self.auth())
200            .send()
201            .await?
202            .error_for_status()?
203            .json()
204            .await?;
205
206        let mut threads: Vec<ReviewThread> = reviews.into_iter().map(|r| ReviewThread {
207            id:     r.id,
208            author: r.user.login,
209            body:   r.body,
210            path:   None,
211            line:   None,
212            state:  r.state,
213        }).collect();
214
215        for c in comments {
216            threads.push(ReviewThread {
217                id:     c.id,
218                author: c.user.login,
219                body:   c.body,
220                path:   c.path,
221                line:   c.line,
222                state:  "COMMENTED".to_string(),
223            });
224        }
225
226        Ok(threads)
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Helpers
232// ---------------------------------------------------------------------------
233
234/// Parse "owner/repo" or "github.com/owner/repo" into (owner, repo).
235pub fn split_repo(s: &str) -> Option<(String, String)> {
236    let s = s.trim_start_matches("https://").trim_start_matches("github.com/");
237    let mut parts = s.trim_start_matches('/').splitn(2, '/');
238    let owner = parts.next()?.to_string();
239    let repo = parts.next()?.trim_end_matches(".git").to_string();
240    if owner.is_empty() || repo.is_empty() { return None; }
241    Some((owner, repo))
242}
243
244/// Resolve GitHub token: config value → GITHUB_TOKEN env → `gh auth token`.
245pub fn resolve_token(config_token: Option<String>) -> Option<String> {
246    config_token
247        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
248        .or_else(|| {
249            std::process::Command::new("gh")
250                .args(["auth", "token"])
251                .output()
252                .ok()
253                .filter(|o| o.status.success())
254                .and_then(|o| String::from_utf8(o.stdout).ok())
255                .map(|s| s.trim().to_string())
256                .filter(|s| !s.is_empty())
257        })
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn parse_repo_owner_from_url() {
266        let (owner, repo) = split_repo("Made-by-Moonlight/Athene").unwrap();
267        assert_eq!(owner, "Made-by-Moonlight");
268        assert_eq!(repo, "Athene");
269    }
270
271    #[test]
272    fn parse_repo_owner_strips_github_prefix() {
273        let (owner, repo) = split_repo("github.com/Made-by-Moonlight/Athene").unwrap();
274        assert_eq!(owner, "Made-by-Moonlight");
275        assert_eq!(repo, "Athene");
276    }
277
278    #[test]
279    fn invalid_repo_returns_none() {
280        assert!(split_repo("notarepo").is_none());
281    }
282
283    #[test]
284    fn resolve_token_prefers_config_over_env() {
285        let token = resolve_token(Some("config-token".to_string()));
286        assert_eq!(token, Some("config-token".to_string()));
287    }
288}