1use anyhow::{Context, Result};
2use reqwest::{header, Client};
3use serde::Deserialize;
4
5#[derive(Debug, Clone)]
10pub struct PrStatus {
11 pub merged: bool,
12 pub state: String, 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, pub conclusion: Option<String>, }
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, }
35
36#[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#[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 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
230pub 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
244pub 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}