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 #[serde(default)]
52 pub name: Option<String>,
53 #[serde(default)]
55 pub email: Option<String>,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct Repository {
61 pub id: u64,
62 #[serde(default)]
64 pub full_name: Option<String>,
65 #[serde(default)]
68 pub name: Option<String>,
69 #[serde(default)]
71 pub html_url: Option<String>,
72}
73
74impl Repository {
75 pub fn slug(&self) -> Option<&str> {
78 self.full_name.as_deref().or(self.name.as_deref())
79 }
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize)]
88pub struct PullRequest {
89 pub number: u64,
90 pub title: String,
91 pub state: String,
92 #[serde(default)]
95 pub pull_request: Option<PullRequestRef>,
96 pub html_url: String,
97 #[serde(default)]
101 pub repository_url: Option<String>,
102}
103
104impl PullRequest {
105 pub fn repo_slug(&self) -> Option<String> {
108 if let Some(rest) = self
110 .html_url
111 .strip_prefix("https://github.com/")
112 .or_else(|| self.html_url.strip_prefix("https://"))
113 {
114 let parts: Vec<&str> = rest.splitn(4, '/').collect();
115 if parts.len() >= 2 {
116 return Some(format!("{}/{}", parts[0], parts[1]));
117 }
118 }
119 if let Some(repo) = &self.repository_url
122 && let Some(rest) = repo.split("/repos/").nth(1)
123 {
124 return Some(rest.to_string());
125 }
126 None
127 }
128
129 pub fn merged_at(&self) -> Option<&str> {
133 self.pull_request
134 .as_ref()
135 .and_then(|p| p.merged_at.as_deref())
136 }
137}
138
139#[derive(Debug, Clone, Deserialize, Serialize)]
143pub struct PullRequestRef {
144 #[serde(default)]
145 pub merged_at: Option<String>,
146}
147
148#[derive(Debug, Deserialize)]
150struct SearchIssuesResponse {
151 total_count: u64,
152 items: Vec<PullRequest>,
153}
154
155#[derive(Debug, Clone, Deserialize, Serialize)]
159pub struct Event {
160 pub id: String,
161 #[serde(rename = "type")]
164 pub event_type: String,
165 pub repo: Repository,
166 pub created_at: String,
167 #[serde(default)]
169 pub payload: serde_json::Value,
170}
171
172#[derive(Debug, Clone, Deserialize, Serialize)]
177pub struct GitTagRef {
178 #[serde(rename = "ref")]
180 pub ref_name: String,
181 pub object: GitObject,
182}
183
184impl GitTagRef {
185 pub fn tag_name(&self) -> &str {
187 self.ref_name
188 .strip_prefix("refs/tags/")
189 .unwrap_or(&self.ref_name)
190 }
191}
192
193#[derive(Debug, Clone, Deserialize, Serialize)]
196pub struct GitObject {
197 #[serde(rename = "type")]
198 pub object_type: String,
199 pub sha: String,
200}
201
202#[derive(Debug, Clone, Deserialize, Serialize)]
207pub struct AnnotatedTag {
208 pub tag: String,
209 pub tagger: Tagger,
210}
211
212#[derive(Debug, Clone, Deserialize, Serialize)]
215pub struct Tagger {
216 pub name: String,
217 pub email: String,
218 pub date: String,
219}
220
221impl Event {
222 pub fn repo_slug(&self) -> Option<&str> {
225 self.repo.slug()
226 }
227}
228
229pub struct Client {
231 http: reqwest::blocking::Client,
232 base_url: String,
233}
234
235impl Client {
236 pub fn new(base_url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
241 let http = build_http_client(token)?;
242 Ok(Self {
243 http,
244 base_url: base_url.trim_end_matches('/').to_string(),
245 })
246 }
247
248 pub fn user_by_username(
252 &self,
253 username: &str,
254 ) -> Result<Option<User>, Box<dyn std::error::Error>> {
255 let url = format!("{}/users/{}", self.base_url, username);
256 let resp = self.http.get(&url).send()?;
257 if resp.status().as_u16() == 404 {
258 return Ok(None);
259 }
260 if !resp.status().is_success() {
261 let status = resp.status();
262 let text = resp.text()?;
263 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
264 }
265 Ok(Some(resp.json()?))
266 }
267
268 pub fn search_pull_requests(
275 &self,
276 query: &str,
277 ) -> Result<Vec<PullRequest>, Box<dyn std::error::Error>> {
278 let mut out: Vec<PullRequest> = Vec::new();
279 let mut page = 1u32;
280 loop {
281 let page_str = page.to_string();
282 let url = format!("{}/search/issues", self.base_url);
283 let resp = self
284 .http
285 .get(&url)
286 .query(&[("q", query), ("per_page", "100"), ("page", &page_str)])
287 .send()?;
288 if !resp.status().is_success() {
289 let status = resp.status();
290 let text = resp.text()?;
291 return Err(format!("GitHub search failed: {status}: {text}").into());
292 }
293 let batch: SearchIssuesResponse = resp.json()?;
294 let n = batch.items.len();
295 out.extend(batch.items);
296 if n < 100 || out.len() as u64 >= batch.total_count {
297 break;
298 }
299 page += 1;
300 }
301 Ok(out)
302 }
303
304 pub fn user_events(&self, username: &str) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
307 let mut out: Vec<Event> = Vec::new();
308 let mut page = 1u32;
309 loop {
310 let url = format!("{}/users/{}/events", self.base_url, username);
311 let page_str = page.to_string();
312 let resp = self
313 .http
314 .get(&url)
315 .query(&[("per_page", "100"), ("page", &page_str)])
316 .send()?;
317 if !resp.status().is_success() {
318 let status = resp.status();
319 let text = resp.text()?;
320 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
321 }
322 let batch: Vec<Event> = resp.json()?;
323 let n = batch.len();
324 out.extend(batch);
325 if n < 100 || page >= 3 {
327 break;
328 }
329 page += 1;
330 }
331 Ok(out)
332 }
333
334 pub fn count_authored_commits(
348 &self,
349 owner: &str,
350 repo: &str,
351 author: &str,
352 since: chrono::NaiveDate,
353 until: chrono::NaiveDate,
354 ) -> Result<u64, Box<dyn std::error::Error>> {
355 let url = format!("{}/repos/{}/{}/commits", self.base_url, owner, repo);
356 let since_str = format!("{since}T00:00:00Z");
357 let until_str = format!("{until}T23:59:59Z");
358 let mut total: u64 = 0;
359 let mut page = 1u32;
360 loop {
361 let page_str = page.to_string();
362 let query: Vec<(&str, &str)> = vec![
363 ("author", author),
364 ("since", &since_str),
365 ("until", &until_str),
366 ("per_page", "100"),
367 ("page", &page_str),
368 ];
369 let resp = self.http.get(&url).query(&query).send()?;
370 if resp.status().as_u16() == 404 || resp.status().as_u16() == 409 {
375 break;
376 }
377 if !resp.status().is_success() {
378 let status = resp.status();
379 let text = resp.text()?;
380 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
381 }
382 let batch: Vec<serde_json::Value> = resp.json()?;
383 let n = batch.len() as u64;
384 total += n;
385 if n < 100 {
386 break;
387 }
388 page += 1;
389 }
390 Ok(total)
391 }
392
393 pub fn list_tag_refs(
397 &self,
398 owner: &str,
399 repo: &str,
400 ) -> Result<Vec<GitTagRef>, Box<dyn std::error::Error>> {
401 let url = format!("{}/repos/{}/{}/git/refs/tags", self.base_url, owner, repo);
402 let resp = self.http.get(&url).send()?;
403 if resp.status().as_u16() == 404 || resp.status().as_u16() == 409 {
405 return Ok(Vec::new());
406 }
407 if !resp.status().is_success() {
408 let status = resp.status();
409 let text = resp.text()?;
410 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
411 }
412 let body: serde_json::Value = resp.json()?;
418 if body.is_array() {
419 Ok(serde_json::from_value(body)?)
420 } else {
421 Ok(vec![serde_json::from_value(body)?])
422 }
423 }
424
425 pub fn get_annotated_tag(
430 &self,
431 owner: &str,
432 repo: &str,
433 sha: &str,
434 ) -> Result<AnnotatedTag, Box<dyn std::error::Error>> {
435 let url = format!(
436 "{}/repos/{}/{}/git/tags/{}",
437 self.base_url, owner, repo, sha
438 );
439 let resp = self.http.get(&url).send()?;
440 if !resp.status().is_success() {
441 let status = resp.status();
442 let text = resp.text()?;
443 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
444 }
445 Ok(resp.json()?)
446 }
447}
448
449pub fn validate_token(base_url: &str, token: &str) -> Result<bool, Box<dyn std::error::Error>> {
455 let http = build_http_client(token)?;
456 let url = format!("{}/user", base_url.trim_end_matches('/'));
457 let resp = http.get(&url).send()?;
458 let status = resp.status();
459 if status.is_success() {
460 return Ok(true);
461 }
462 if status.as_u16() == 401 {
463 return Ok(false);
464 }
465 let text = resp.text().unwrap_or_default();
466 Err(format!("GitHub /user check failed: {status}: {text}").into())
467}
468
469fn build_http_client(token: &str) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
473 let mut headers = HeaderMap::new();
474 headers.insert(
475 HeaderName::from_static("authorization"),
476 HeaderValue::from_str(&format!("Bearer {token}"))?,
477 );
478 headers.insert(
479 HeaderName::from_static("accept"),
480 HeaderValue::from_static("application/vnd.github+json"),
481 );
482 headers.insert(
483 HeaderName::from_static("x-github-api-version"),
484 HeaderValue::from_static("2022-11-28"),
485 );
486 Ok(reqwest::blocking::Client::builder()
487 .user_agent(concat!("sandogasa-github/", env!("CARGO_PKG_VERSION")))
488 .default_headers(headers)
489 .timeout(DEFAULT_TIMEOUT)
490 .build()?)
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn user_by_username_returns_user() {
499 let mut server = mockito::Server::new();
500 let mock = server
501 .mock("GET", "/users/octocat")
502 .match_header("authorization", "Bearer tok")
503 .with_status(200)
504 .with_body(r#"{"id": 1, "login": "octocat"}"#)
505 .create();
506 let client = Client::new(&server.url(), "tok").unwrap();
507 let user = client.user_by_username("octocat").unwrap().unwrap();
508 assert_eq!(user.id, 1);
509 assert_eq!(user.login, "octocat");
510 mock.assert();
511 }
512
513 #[test]
514 fn user_by_username_404_is_none() {
515 let mut server = mockito::Server::new();
516 let mock = server
517 .mock("GET", "/users/ghost")
518 .with_status(404)
519 .with_body(r#"{"message": "Not Found"}"#)
520 .create();
521 let client = Client::new(&server.url(), "tok").unwrap();
522 assert!(client.user_by_username("ghost").unwrap().is_none());
523 mock.assert();
524 }
525
526 #[test]
527 fn search_pull_requests_paginates() {
528 let mut server = mockito::Server::new();
529 let items_page1 = (1..=100)
531 .map(|i| {
532 format!(
533 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"}}}}"#
534 )
535 })
536 .collect::<Vec<_>>()
537 .join(",");
538 let mock_p1 = server
539 .mock("GET", "/search/issues")
540 .match_query(mockito::Matcher::AllOf(vec![
541 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
542 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
543 ]))
544 .with_status(200)
545 .with_body(format!(
546 r#"{{"total_count":105,"incomplete_results":false,"items":[{items_page1}]}}"#
547 ))
548 .create();
549 let mock_p2 = server
550 .mock("GET", "/search/issues")
551 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
552 .with_status(200)
553 .with_body(
554 r#"{"total_count":105,"incomplete_results":false,"items":[
555 {"number":101,"title":"x","state":"open","html_url":"https://github.com/o/r/pull/101"},
556 {"number":102,"title":"y","state":"open","html_url":"https://github.com/o/r/pull/102"},
557 {"number":103,"title":"z","state":"open","html_url":"https://github.com/o/r/pull/103"},
558 {"number":104,"title":"w","state":"open","html_url":"https://github.com/o/r/pull/104"},
559 {"number":105,"title":"v","state":"open","html_url":"https://github.com/o/r/pull/105"}
560 ]}"#,
561 )
562 .create();
563 let client = Client::new(&server.url(), "tok").unwrap();
564 let prs = client
565 .search_pull_requests("type:pr author:octocat")
566 .unwrap();
567 assert_eq!(prs.len(), 105);
568 mock_p1.assert();
569 mock_p2.assert();
570 }
571
572 #[test]
573 fn pull_request_repo_slug_from_html_url() {
574 let pr = PullRequest {
575 number: 42,
576 title: "x".into(),
577 state: "open".into(),
578 pull_request: None,
579 html_url: "https://github.com/slopfest/sandogasa/pull/42".into(),
580 repository_url: None,
581 };
582 assert_eq!(pr.repo_slug().as_deref(), Some("slopfest/sandogasa"));
583 }
584
585 #[test]
586 fn pull_request_repo_slug_from_repository_url() {
587 let pr = PullRequest {
588 number: 42,
589 title: "x".into(),
590 state: "open".into(),
591 pull_request: None,
592 html_url: "https://github.com/o/r/issues/42".into(),
593 repository_url: Some("https://api.github.com/repos/slopfest/sandogasa".into()),
594 };
595 let pr2 = PullRequest {
597 html_url: "garbage".into(),
598 ..pr
599 };
600 assert_eq!(pr2.repo_slug().as_deref(), Some("slopfest/sandogasa"));
601 }
602
603 #[test]
604 fn pull_request_merged_at_via_helper() {
605 let pr: PullRequest = serde_json::from_str(
606 r#"{"number":1,"title":"t","state":"closed",
607 "html_url":"https://github.com/o/r/pull/1",
608 "pull_request":{"merged_at":"2026-02-01T10:00:00Z"}}"#,
609 )
610 .unwrap();
611 assert_eq!(pr.merged_at(), Some("2026-02-01T10:00:00Z"));
612 }
613
614 #[test]
615 fn user_events_pagination_stops_at_300() {
616 let mut server = mockito::Server::new();
617 let make_event = |i: u64| {
621 format!(
622 r#"{{"id":"{i}","type":"PushEvent","repo":{{"id":1,"name":"o/r"}},"created_at":"2026-02-15T10:00:00Z"}}"#
623 )
624 };
625 let page1: String = (1..=100).map(make_event).collect::<Vec<_>>().join(",");
626 let page2: String = (101..=200).map(make_event).collect::<Vec<_>>().join(",");
627 let page3: String = (201..=250).map(make_event).collect::<Vec<_>>().join(",");
628 let m1 = server
629 .mock("GET", "/users/octocat/events")
630 .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
631 .with_status(200)
632 .with_body(format!("[{page1}]"))
633 .create();
634 let m2 = server
635 .mock("GET", "/users/octocat/events")
636 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
637 .with_status(200)
638 .with_body(format!("[{page2}]"))
639 .create();
640 let m3 = server
641 .mock("GET", "/users/octocat/events")
642 .match_query(mockito::Matcher::UrlEncoded("page".into(), "3".into()))
643 .with_status(200)
644 .with_body(format!("[{page3}]"))
645 .create();
646 let client = Client::new(&server.url(), "tok").unwrap();
647 let events = client.user_events("octocat").unwrap();
648 assert_eq!(events.len(), 250);
649 m1.assert();
650 m2.assert();
651 m3.assert();
652 }
653
654 #[test]
655 fn count_authored_commits_paginates_and_handles_409() {
656 let mut server = mockito::Server::new();
657 let m1 = server
658 .mock("GET", "/repos/o/r/commits")
659 .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
660 .with_status(200)
661 .with_body(format!("[{}]", vec!["{}"; 100].join(",")))
662 .create();
663 let m2 = server
664 .mock("GET", "/repos/o/r/commits")
665 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
666 .with_status(200)
667 .with_body("[{},{}]")
668 .create();
669 let client = Client::new(&server.url(), "tok").unwrap();
670 let n = client
671 .count_authored_commits(
672 "o",
673 "r",
674 "octocat",
675 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
676 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
677 )
678 .unwrap();
679 assert_eq!(n, 102);
680 m1.assert();
681 m2.assert();
682 }
683
684 #[test]
685 fn count_authored_commits_empty_repo_returns_zero() {
686 let mut server = mockito::Server::new();
687 let mock = server
688 .mock("GET", "/repos/o/empty/commits")
689 .match_query(mockito::Matcher::Any)
690 .with_status(409)
691 .with_body(r#"{"message":"Git Repository is empty."}"#)
692 .create();
693 let client = Client::new(&server.url(), "tok").unwrap();
694 let n = client
695 .count_authored_commits(
696 "o",
697 "empty",
698 "x",
699 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
700 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
701 )
702 .unwrap();
703 assert_eq!(n, 0);
704 mock.assert();
705 }
706
707 #[test]
708 fn validate_token_distinguishes_invalid_from_error() {
709 let mut server = mockito::Server::new();
710 let ok = server
712 .mock("GET", "/user")
713 .match_header("authorization", "Bearer good")
714 .with_status(200)
715 .with_body(r#"{"id": 1, "login": "octocat"}"#)
716 .create();
717 assert!(validate_token(&server.url(), "good").unwrap());
718 ok.assert();
719 let bad = server
721 .mock("GET", "/user")
722 .match_header("authorization", "Bearer bad")
723 .with_status(401)
724 .with_body(r#"{"message": "Bad credentials"}"#)
725 .create();
726 assert!(!validate_token(&server.url(), "bad").unwrap());
727 bad.assert();
728 }
729
730 #[test]
731 fn repository_slug_prefers_full_name() {
732 let repo = Repository {
733 id: 1,
734 full_name: Some("o/r".into()),
735 name: Some("ignored".into()),
736 html_url: None,
737 };
738 assert_eq!(repo.slug(), Some("o/r"));
739 let evt_repo = Repository {
741 id: 1,
742 full_name: None,
743 name: Some("o/r".into()),
744 html_url: None,
745 };
746 assert_eq!(evt_repo.slug(), Some("o/r"));
747 }
748}