1use std::time::Duration;
32
33use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
34use serde::{Deserialize, Serialize};
35
36pub const DEFAULT_BASE_URL: &str = "https://api.github.com";
38
39const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
43
44#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct User {
48 pub id: u64,
49 pub login: String,
50}
51
52#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Repository {
55 pub id: u64,
56 #[serde(default)]
58 pub full_name: Option<String>,
59 #[serde(default)]
62 pub name: Option<String>,
63 #[serde(default)]
65 pub html_url: Option<String>,
66}
67
68impl Repository {
69 pub fn slug(&self) -> Option<&str> {
72 self.full_name.as_deref().or(self.name.as_deref())
73 }
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct PullRequest {
83 pub number: u64,
84 pub title: String,
85 pub state: String,
86 #[serde(default)]
89 pub pull_request: Option<PullRequestRef>,
90 pub html_url: String,
91 #[serde(default)]
95 pub repository_url: Option<String>,
96}
97
98impl PullRequest {
99 pub fn repo_slug(&self) -> Option<String> {
102 if let Some(rest) = self
104 .html_url
105 .strip_prefix("https://github.com/")
106 .or_else(|| self.html_url.strip_prefix("https://"))
107 {
108 let parts: Vec<&str> = rest.splitn(4, '/').collect();
109 if parts.len() >= 2 {
110 return Some(format!("{}/{}", parts[0], parts[1]));
111 }
112 }
113 if let Some(repo) = &self.repository_url
116 && let Some(rest) = repo.split("/repos/").nth(1)
117 {
118 return Some(rest.to_string());
119 }
120 None
121 }
122
123 pub fn merged_at(&self) -> Option<&str> {
127 self.pull_request
128 .as_ref()
129 .and_then(|p| p.merged_at.as_deref())
130 }
131}
132
133#[derive(Debug, Clone, Deserialize, Serialize)]
137pub struct PullRequestRef {
138 #[serde(default)]
139 pub merged_at: Option<String>,
140}
141
142#[derive(Debug, Deserialize)]
144struct SearchIssuesResponse {
145 total_count: u64,
146 items: Vec<PullRequest>,
147}
148
149#[derive(Debug, Clone, Deserialize, Serialize)]
153pub struct Event {
154 pub id: String,
155 #[serde(rename = "type")]
158 pub event_type: String,
159 pub repo: Repository,
160 pub created_at: String,
161 #[serde(default)]
163 pub payload: serde_json::Value,
164}
165
166impl Event {
167 pub fn repo_slug(&self) -> Option<&str> {
170 self.repo.slug()
171 }
172}
173
174pub struct Client {
176 http: reqwest::blocking::Client,
177 base_url: String,
178}
179
180impl Client {
181 pub fn new(base_url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
186 let http = build_http_client(token)?;
187 Ok(Self {
188 http,
189 base_url: base_url.trim_end_matches('/').to_string(),
190 })
191 }
192
193 pub fn user_by_username(
197 &self,
198 username: &str,
199 ) -> Result<Option<User>, Box<dyn std::error::Error>> {
200 let url = format!("{}/users/{}", self.base_url, username);
201 let resp = self.http.get(&url).send()?;
202 if resp.status().as_u16() == 404 {
203 return Ok(None);
204 }
205 if !resp.status().is_success() {
206 let status = resp.status();
207 let text = resp.text()?;
208 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
209 }
210 Ok(Some(resp.json()?))
211 }
212
213 pub fn search_pull_requests(
220 &self,
221 query: &str,
222 ) -> Result<Vec<PullRequest>, Box<dyn std::error::Error>> {
223 let mut out: Vec<PullRequest> = Vec::new();
224 let mut page = 1u32;
225 loop {
226 let page_str = page.to_string();
227 let url = format!("{}/search/issues", self.base_url);
228 let resp = self
229 .http
230 .get(&url)
231 .query(&[("q", query), ("per_page", "100"), ("page", &page_str)])
232 .send()?;
233 if !resp.status().is_success() {
234 let status = resp.status();
235 let text = resp.text()?;
236 return Err(format!("GitHub search failed: {status}: {text}").into());
237 }
238 let batch: SearchIssuesResponse = resp.json()?;
239 let n = batch.items.len();
240 out.extend(batch.items);
241 if n < 100 || out.len() as u64 >= batch.total_count {
242 break;
243 }
244 page += 1;
245 }
246 Ok(out)
247 }
248
249 pub fn user_events(&self, username: &str) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
252 let mut out: Vec<Event> = Vec::new();
253 let mut page = 1u32;
254 loop {
255 let url = format!("{}/users/{}/events", self.base_url, username);
256 let page_str = page.to_string();
257 let resp = self
258 .http
259 .get(&url)
260 .query(&[("per_page", "100"), ("page", &page_str)])
261 .send()?;
262 if !resp.status().is_success() {
263 let status = resp.status();
264 let text = resp.text()?;
265 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
266 }
267 let batch: Vec<Event> = resp.json()?;
268 let n = batch.len();
269 out.extend(batch);
270 if n < 100 || page >= 3 {
272 break;
273 }
274 page += 1;
275 }
276 Ok(out)
277 }
278
279 pub fn count_authored_commits(
293 &self,
294 owner: &str,
295 repo: &str,
296 author: &str,
297 since: chrono::NaiveDate,
298 until: chrono::NaiveDate,
299 ) -> Result<u64, Box<dyn std::error::Error>> {
300 let url = format!("{}/repos/{}/{}/commits", self.base_url, owner, repo);
301 let since_str = format!("{since}T00:00:00Z");
302 let until_str = format!("{until}T23:59:59Z");
303 let mut total: u64 = 0;
304 let mut page = 1u32;
305 loop {
306 let page_str = page.to_string();
307 let query: Vec<(&str, &str)> = vec![
308 ("author", author),
309 ("since", &since_str),
310 ("until", &until_str),
311 ("per_page", "100"),
312 ("page", &page_str),
313 ];
314 let resp = self.http.get(&url).query(&query).send()?;
315 if resp.status().as_u16() == 404 || resp.status().as_u16() == 409 {
320 break;
321 }
322 if !resp.status().is_success() {
323 let status = resp.status();
324 let text = resp.text()?;
325 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
326 }
327 let batch: Vec<serde_json::Value> = resp.json()?;
328 let n = batch.len() as u64;
329 total += n;
330 if n < 100 {
331 break;
332 }
333 page += 1;
334 }
335 Ok(total)
336 }
337}
338
339pub fn validate_token(base_url: &str, token: &str) -> Result<bool, Box<dyn std::error::Error>> {
345 let http = build_http_client(token)?;
346 let url = format!("{}/user", base_url.trim_end_matches('/'));
347 let resp = http.get(&url).send()?;
348 let status = resp.status();
349 if status.is_success() {
350 return Ok(true);
351 }
352 if status.as_u16() == 401 {
353 return Ok(false);
354 }
355 let text = resp.text().unwrap_or_default();
356 Err(format!("GitHub /user check failed: {status}: {text}").into())
357}
358
359fn build_http_client(token: &str) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
363 let mut headers = HeaderMap::new();
364 headers.insert(
365 HeaderName::from_static("authorization"),
366 HeaderValue::from_str(&format!("Bearer {token}"))?,
367 );
368 headers.insert(
369 HeaderName::from_static("accept"),
370 HeaderValue::from_static("application/vnd.github+json"),
371 );
372 headers.insert(
373 HeaderName::from_static("x-github-api-version"),
374 HeaderValue::from_static("2022-11-28"),
375 );
376 Ok(reqwest::blocking::Client::builder()
377 .user_agent(concat!("sandogasa-github/", env!("CARGO_PKG_VERSION")))
378 .default_headers(headers)
379 .timeout(DEFAULT_TIMEOUT)
380 .build()?)
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn user_by_username_returns_user() {
389 let mut server = mockito::Server::new();
390 let mock = server
391 .mock("GET", "/users/octocat")
392 .match_header("authorization", "Bearer tok")
393 .with_status(200)
394 .with_body(r#"{"id": 1, "login": "octocat"}"#)
395 .create();
396 let client = Client::new(&server.url(), "tok").unwrap();
397 let user = client.user_by_username("octocat").unwrap().unwrap();
398 assert_eq!(user.id, 1);
399 assert_eq!(user.login, "octocat");
400 mock.assert();
401 }
402
403 #[test]
404 fn user_by_username_404_is_none() {
405 let mut server = mockito::Server::new();
406 let mock = server
407 .mock("GET", "/users/ghost")
408 .with_status(404)
409 .with_body(r#"{"message": "Not Found"}"#)
410 .create();
411 let client = Client::new(&server.url(), "tok").unwrap();
412 assert!(client.user_by_username("ghost").unwrap().is_none());
413 mock.assert();
414 }
415
416 #[test]
417 fn search_pull_requests_paginates() {
418 let mut server = mockito::Server::new();
419 let items_page1 = (1..=100)
421 .map(|i| {
422 format!(
423 r#"{{"number":{i},"title":"PR {i}","state":"closed","html_url":"https://github.com/o/r/pull/{i}","pull_request":{{"merged_at":"2026-02-01T10:00:00Z"}}}}"#
424 )
425 })
426 .collect::<Vec<_>>()
427 .join(",");
428 let mock_p1 = server
429 .mock("GET", "/search/issues")
430 .match_query(mockito::Matcher::AllOf(vec![
431 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
432 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
433 ]))
434 .with_status(200)
435 .with_body(format!(
436 r#"{{"total_count":105,"incomplete_results":false,"items":[{items_page1}]}}"#
437 ))
438 .create();
439 let mock_p2 = server
440 .mock("GET", "/search/issues")
441 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
442 .with_status(200)
443 .with_body(
444 r#"{"total_count":105,"incomplete_results":false,"items":[
445 {"number":101,"title":"x","state":"open","html_url":"https://github.com/o/r/pull/101"},
446 {"number":102,"title":"y","state":"open","html_url":"https://github.com/o/r/pull/102"},
447 {"number":103,"title":"z","state":"open","html_url":"https://github.com/o/r/pull/103"},
448 {"number":104,"title":"w","state":"open","html_url":"https://github.com/o/r/pull/104"},
449 {"number":105,"title":"v","state":"open","html_url":"https://github.com/o/r/pull/105"}
450 ]}"#,
451 )
452 .create();
453 let client = Client::new(&server.url(), "tok").unwrap();
454 let prs = client
455 .search_pull_requests("type:pr author:octocat")
456 .unwrap();
457 assert_eq!(prs.len(), 105);
458 mock_p1.assert();
459 mock_p2.assert();
460 }
461
462 #[test]
463 fn pull_request_repo_slug_from_html_url() {
464 let pr = PullRequest {
465 number: 42,
466 title: "x".into(),
467 state: "open".into(),
468 pull_request: None,
469 html_url: "https://github.com/slopfest/sandogasa/pull/42".into(),
470 repository_url: None,
471 };
472 assert_eq!(pr.repo_slug().as_deref(), Some("slopfest/sandogasa"));
473 }
474
475 #[test]
476 fn pull_request_repo_slug_from_repository_url() {
477 let pr = PullRequest {
478 number: 42,
479 title: "x".into(),
480 state: "open".into(),
481 pull_request: None,
482 html_url: "https://github.com/o/r/issues/42".into(),
483 repository_url: Some("https://api.github.com/repos/slopfest/sandogasa".into()),
484 };
485 let pr2 = PullRequest {
487 html_url: "garbage".into(),
488 ..pr
489 };
490 assert_eq!(pr2.repo_slug().as_deref(), Some("slopfest/sandogasa"));
491 }
492
493 #[test]
494 fn pull_request_merged_at_via_helper() {
495 let pr: PullRequest = serde_json::from_str(
496 r#"{"number":1,"title":"t","state":"closed",
497 "html_url":"https://github.com/o/r/pull/1",
498 "pull_request":{"merged_at":"2026-02-01T10:00:00Z"}}"#,
499 )
500 .unwrap();
501 assert_eq!(pr.merged_at(), Some("2026-02-01T10:00:00Z"));
502 }
503
504 #[test]
505 fn user_events_pagination_stops_at_300() {
506 let mut server = mockito::Server::new();
507 let make_event = |i: u64| {
511 format!(
512 r#"{{"id":"{i}","type":"PushEvent","repo":{{"id":1,"name":"o/r"}},"created_at":"2026-02-15T10:00:00Z"}}"#
513 )
514 };
515 let page1: String = (1..=100).map(make_event).collect::<Vec<_>>().join(",");
516 let page2: String = (101..=200).map(make_event).collect::<Vec<_>>().join(",");
517 let page3: String = (201..=250).map(make_event).collect::<Vec<_>>().join(",");
518 let m1 = server
519 .mock("GET", "/users/octocat/events")
520 .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
521 .with_status(200)
522 .with_body(format!("[{page1}]"))
523 .create();
524 let m2 = server
525 .mock("GET", "/users/octocat/events")
526 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
527 .with_status(200)
528 .with_body(format!("[{page2}]"))
529 .create();
530 let m3 = server
531 .mock("GET", "/users/octocat/events")
532 .match_query(mockito::Matcher::UrlEncoded("page".into(), "3".into()))
533 .with_status(200)
534 .with_body(format!("[{page3}]"))
535 .create();
536 let client = Client::new(&server.url(), "tok").unwrap();
537 let events = client.user_events("octocat").unwrap();
538 assert_eq!(events.len(), 250);
539 m1.assert();
540 m2.assert();
541 m3.assert();
542 }
543
544 #[test]
545 fn count_authored_commits_paginates_and_handles_409() {
546 let mut server = mockito::Server::new();
547 let m1 = server
548 .mock("GET", "/repos/o/r/commits")
549 .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
550 .with_status(200)
551 .with_body(format!("[{}]", vec!["{}"; 100].join(",")))
552 .create();
553 let m2 = server
554 .mock("GET", "/repos/o/r/commits")
555 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
556 .with_status(200)
557 .with_body("[{},{}]")
558 .create();
559 let client = Client::new(&server.url(), "tok").unwrap();
560 let n = client
561 .count_authored_commits(
562 "o",
563 "r",
564 "octocat",
565 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
566 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
567 )
568 .unwrap();
569 assert_eq!(n, 102);
570 m1.assert();
571 m2.assert();
572 }
573
574 #[test]
575 fn count_authored_commits_empty_repo_returns_zero() {
576 let mut server = mockito::Server::new();
577 let mock = server
578 .mock("GET", "/repos/o/empty/commits")
579 .match_query(mockito::Matcher::Any)
580 .with_status(409)
581 .with_body(r#"{"message":"Git Repository is empty."}"#)
582 .create();
583 let client = Client::new(&server.url(), "tok").unwrap();
584 let n = client
585 .count_authored_commits(
586 "o",
587 "empty",
588 "x",
589 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
590 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
591 )
592 .unwrap();
593 assert_eq!(n, 0);
594 mock.assert();
595 }
596
597 #[test]
598 fn validate_token_distinguishes_invalid_from_error() {
599 let mut server = mockito::Server::new();
600 let ok = server
602 .mock("GET", "/user")
603 .match_header("authorization", "Bearer good")
604 .with_status(200)
605 .with_body(r#"{"id": 1, "login": "octocat"}"#)
606 .create();
607 assert!(validate_token(&server.url(), "good").unwrap());
608 ok.assert();
609 let bad = server
611 .mock("GET", "/user")
612 .match_header("authorization", "Bearer bad")
613 .with_status(401)
614 .with_body(r#"{"message": "Bad credentials"}"#)
615 .create();
616 assert!(!validate_token(&server.url(), "bad").unwrap());
617 bad.assert();
618 }
619
620 #[test]
621 fn repository_slug_prefers_full_name() {
622 let repo = Repository {
623 id: 1,
624 full_name: Some("o/r".into()),
625 name: Some("ignored".into()),
626 html_url: None,
627 };
628 assert_eq!(repo.slug(), Some("o/r"));
629 let evt_repo = Repository {
631 id: 1,
632 full_name: None,
633 name: Some("o/r".into()),
634 html_url: None,
635 };
636 assert_eq!(evt_repo.slug(), Some("o/r"));
637 }
638}