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 sandogasa_cli::ensure_secure_url(base_url)?;
242 let http = build_http_client(token)?;
243 Ok(Self {
244 http,
245 base_url: base_url.trim_end_matches('/').to_string(),
246 })
247 }
248
249 pub fn user_by_username(
253 &self,
254 username: &str,
255 ) -> Result<Option<User>, Box<dyn std::error::Error>> {
256 let url = format!("{}/users/{}", self.base_url, username);
257 let resp = self.http.get(&url).send()?;
258 if resp.status().as_u16() == 404 {
259 return Ok(None);
260 }
261 if !resp.status().is_success() {
262 let status = resp.status();
263 let text = resp.text()?;
264 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
265 }
266 Ok(Some(resp.json()?))
267 }
268
269 pub fn search_pull_requests(
276 &self,
277 query: &str,
278 ) -> Result<Vec<PullRequest>, Box<dyn std::error::Error>> {
279 let mut out: Vec<PullRequest> = Vec::new();
280 let mut page = 1u32;
281 loop {
282 let page_str = page.to_string();
283 let url = format!("{}/search/issues", self.base_url);
284 let resp = self
285 .http
286 .get(&url)
287 .query(&[("q", query), ("per_page", "100"), ("page", &page_str)])
288 .send()?;
289 if !resp.status().is_success() {
290 let status = resp.status();
291 let text = resp.text()?;
292 return Err(format!("GitHub search failed: {status}: {text}").into());
293 }
294 let batch: SearchIssuesResponse = resp.json()?;
295 let n = batch.items.len();
296 out.extend(batch.items);
297 if n < 100 || out.len() as u64 >= batch.total_count {
298 break;
299 }
300 page += 1;
301 }
302 Ok(out)
303 }
304
305 pub fn user_events(&self, username: &str) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
308 let mut out: Vec<Event> = Vec::new();
309 let mut page = 1u32;
310 loop {
311 let url = format!("{}/users/{}/events", self.base_url, username);
312 let page_str = page.to_string();
313 let resp = self
314 .http
315 .get(&url)
316 .query(&[("per_page", "100"), ("page", &page_str)])
317 .send()?;
318 if !resp.status().is_success() {
319 let status = resp.status();
320 let text = resp.text()?;
321 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
322 }
323 let batch: Vec<Event> = resp.json()?;
324 let n = batch.len();
325 out.extend(batch);
326 if n < 100 || page >= 3 {
328 break;
329 }
330 page += 1;
331 }
332 Ok(out)
333 }
334
335 pub fn count_authored_commits(
349 &self,
350 owner: &str,
351 repo: &str,
352 author: &str,
353 since: chrono::NaiveDate,
354 until: chrono::NaiveDate,
355 ) -> Result<u64, Box<dyn std::error::Error>> {
356 let url = format!("{}/repos/{}/{}/commits", self.base_url, owner, repo);
357 let since_str = format!("{since}T00:00:00Z");
358 let until_str = format!("{until}T23:59:59Z");
359 let mut total: u64 = 0;
360 let mut page = 1u32;
361 loop {
362 let page_str = page.to_string();
363 let query: Vec<(&str, &str)> = vec![
364 ("author", author),
365 ("since", &since_str),
366 ("until", &until_str),
367 ("per_page", "100"),
368 ("page", &page_str),
369 ];
370 let resp = self.http.get(&url).query(&query).send()?;
371 if resp.status().as_u16() == 404 || resp.status().as_u16() == 409 {
376 break;
377 }
378 if !resp.status().is_success() {
379 let status = resp.status();
380 let text = resp.text()?;
381 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
382 }
383 let batch: Vec<serde_json::Value> = resp.json()?;
384 let n = batch.len() as u64;
385 total += n;
386 if n < 100 {
387 break;
388 }
389 page += 1;
390 }
391 Ok(total)
392 }
393
394 pub fn list_tag_refs(
398 &self,
399 owner: &str,
400 repo: &str,
401 ) -> Result<Vec<GitTagRef>, Box<dyn std::error::Error>> {
402 let url = format!("{}/repos/{}/{}/git/refs/tags", self.base_url, owner, repo);
403 let resp = self.http.get(&url).send()?;
404 if resp.status().as_u16() == 404 || resp.status().as_u16() == 409 {
406 return Ok(Vec::new());
407 }
408 if !resp.status().is_success() {
409 let status = resp.status();
410 let text = resp.text()?;
411 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
412 }
413 let body: serde_json::Value = resp.json()?;
419 if body.is_array() {
420 Ok(serde_json::from_value(body)?)
421 } else {
422 Ok(vec![serde_json::from_value(body)?])
423 }
424 }
425
426 pub fn get_annotated_tag(
431 &self,
432 owner: &str,
433 repo: &str,
434 sha: &str,
435 ) -> Result<AnnotatedTag, Box<dyn std::error::Error>> {
436 let url = format!(
437 "{}/repos/{}/{}/git/tags/{}",
438 self.base_url, owner, repo, sha
439 );
440 let resp = self.http.get(&url).send()?;
441 if !resp.status().is_success() {
442 let status = resp.status();
443 let text = resp.text()?;
444 return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
445 }
446 Ok(resp.json()?)
447 }
448}
449
450pub fn validate_token(base_url: &str, token: &str) -> Result<bool, Box<dyn std::error::Error>> {
456 sandogasa_cli::ensure_secure_url(base_url)?;
457 let http = build_http_client(token)?;
458 let url = format!("{}/user", base_url.trim_end_matches('/'));
459 let resp = http.get(&url).send()?;
460 let status = resp.status();
461 if status.is_success() {
462 return Ok(true);
463 }
464 if status.as_u16() == 401 {
465 return Ok(false);
466 }
467 let text = resp.text().unwrap_or_default();
468 Err(format!("GitHub /user check failed: {status}: {text}").into())
469}
470
471fn build_http_client(token: &str) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
475 let mut headers = HeaderMap::new();
476 headers.insert(
477 HeaderName::from_static("authorization"),
478 HeaderValue::from_str(&format!("Bearer {token}"))?,
479 );
480 headers.insert(
481 HeaderName::from_static("accept"),
482 HeaderValue::from_static("application/vnd.github+json"),
483 );
484 headers.insert(
485 HeaderName::from_static("x-github-api-version"),
486 HeaderValue::from_static("2022-11-28"),
487 );
488 Ok(reqwest::blocking::Client::builder()
489 .user_agent(concat!("sandogasa-github/", env!("CARGO_PKG_VERSION")))
490 .default_headers(headers)
491 .timeout(DEFAULT_TIMEOUT)
492 .build()?)
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn new_rejects_plaintext_remote() {
501 assert!(Client::new("http://api.example.com", "tok").is_err());
503 }
504
505 #[test]
506 fn user_by_username_returns_user() {
507 let mut server = mockito::Server::new();
508 let mock = server
509 .mock("GET", "/users/octocat")
510 .match_header("authorization", "Bearer tok")
511 .with_status(200)
512 .with_body(r#"{"id": 1, "login": "octocat"}"#)
513 .create();
514 let client = Client::new(&server.url(), "tok").unwrap();
515 let user = client.user_by_username("octocat").unwrap().unwrap();
516 assert_eq!(user.id, 1);
517 assert_eq!(user.login, "octocat");
518 mock.assert();
519 }
520
521 #[test]
522 fn user_by_username_404_is_none() {
523 let mut server = mockito::Server::new();
524 let mock = server
525 .mock("GET", "/users/ghost")
526 .with_status(404)
527 .with_body(r#"{"message": "Not Found"}"#)
528 .create();
529 let client = Client::new(&server.url(), "tok").unwrap();
530 assert!(client.user_by_username("ghost").unwrap().is_none());
531 mock.assert();
532 }
533
534 #[test]
535 fn search_pull_requests_paginates() {
536 let mut server = mockito::Server::new();
537 let items_page1 = (1..=100)
539 .map(|i| {
540 format!(
541 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"}}}}"#
542 )
543 })
544 .collect::<Vec<_>>()
545 .join(",");
546 let mock_p1 = server
547 .mock("GET", "/search/issues")
548 .match_query(mockito::Matcher::AllOf(vec![
549 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
550 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
551 ]))
552 .with_status(200)
553 .with_body(format!(
554 r#"{{"total_count":105,"incomplete_results":false,"items":[{items_page1}]}}"#
555 ))
556 .create();
557 let mock_p2 = server
558 .mock("GET", "/search/issues")
559 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
560 .with_status(200)
561 .with_body(
562 r#"{"total_count":105,"incomplete_results":false,"items":[
563 {"number":101,"title":"x","state":"open","html_url":"https://github.com/o/r/pull/101"},
564 {"number":102,"title":"y","state":"open","html_url":"https://github.com/o/r/pull/102"},
565 {"number":103,"title":"z","state":"open","html_url":"https://github.com/o/r/pull/103"},
566 {"number":104,"title":"w","state":"open","html_url":"https://github.com/o/r/pull/104"},
567 {"number":105,"title":"v","state":"open","html_url":"https://github.com/o/r/pull/105"}
568 ]}"#,
569 )
570 .create();
571 let client = Client::new(&server.url(), "tok").unwrap();
572 let prs = client
573 .search_pull_requests("type:pr author:octocat")
574 .unwrap();
575 assert_eq!(prs.len(), 105);
576 mock_p1.assert();
577 mock_p2.assert();
578 }
579
580 #[test]
581 fn pull_request_repo_slug_from_html_url() {
582 let pr = PullRequest {
583 number: 42,
584 title: "x".into(),
585 state: "open".into(),
586 pull_request: None,
587 html_url: "https://github.com/slopfest/sandogasa/pull/42".into(),
588 repository_url: None,
589 };
590 assert_eq!(pr.repo_slug().as_deref(), Some("slopfest/sandogasa"));
591 }
592
593 #[test]
594 fn pull_request_repo_slug_from_repository_url() {
595 let pr = PullRequest {
596 number: 42,
597 title: "x".into(),
598 state: "open".into(),
599 pull_request: None,
600 html_url: "https://github.com/o/r/issues/42".into(),
601 repository_url: Some("https://api.github.com/repos/slopfest/sandogasa".into()),
602 };
603 let pr2 = PullRequest {
605 html_url: "garbage".into(),
606 ..pr
607 };
608 assert_eq!(pr2.repo_slug().as_deref(), Some("slopfest/sandogasa"));
609 }
610
611 #[test]
612 fn pull_request_merged_at_via_helper() {
613 let pr: PullRequest = serde_json::from_str(
614 r#"{"number":1,"title":"t","state":"closed",
615 "html_url":"https://github.com/o/r/pull/1",
616 "pull_request":{"merged_at":"2026-02-01T10:00:00Z"}}"#,
617 )
618 .unwrap();
619 assert_eq!(pr.merged_at(), Some("2026-02-01T10:00:00Z"));
620 }
621
622 #[test]
623 fn user_events_pagination_stops_at_300() {
624 let mut server = mockito::Server::new();
625 let make_event = |i: u64| {
629 format!(
630 r#"{{"id":"{i}","type":"PushEvent","repo":{{"id":1,"name":"o/r"}},"created_at":"2026-02-15T10:00:00Z"}}"#
631 )
632 };
633 let page1: String = (1..=100).map(make_event).collect::<Vec<_>>().join(",");
634 let page2: String = (101..=200).map(make_event).collect::<Vec<_>>().join(",");
635 let page3: String = (201..=250).map(make_event).collect::<Vec<_>>().join(",");
636 let m1 = server
637 .mock("GET", "/users/octocat/events")
638 .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
639 .with_status(200)
640 .with_body(format!("[{page1}]"))
641 .create();
642 let m2 = server
643 .mock("GET", "/users/octocat/events")
644 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
645 .with_status(200)
646 .with_body(format!("[{page2}]"))
647 .create();
648 let m3 = server
649 .mock("GET", "/users/octocat/events")
650 .match_query(mockito::Matcher::UrlEncoded("page".into(), "3".into()))
651 .with_status(200)
652 .with_body(format!("[{page3}]"))
653 .create();
654 let client = Client::new(&server.url(), "tok").unwrap();
655 let events = client.user_events("octocat").unwrap();
656 assert_eq!(events.len(), 250);
657 m1.assert();
658 m2.assert();
659 m3.assert();
660 }
661
662 #[test]
663 fn count_authored_commits_paginates_and_handles_409() {
664 let mut server = mockito::Server::new();
665 let m1 = server
666 .mock("GET", "/repos/o/r/commits")
667 .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
668 .with_status(200)
669 .with_body(format!("[{}]", vec!["{}"; 100].join(",")))
670 .create();
671 let m2 = server
672 .mock("GET", "/repos/o/r/commits")
673 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
674 .with_status(200)
675 .with_body("[{},{}]")
676 .create();
677 let client = Client::new(&server.url(), "tok").unwrap();
678 let n = client
679 .count_authored_commits(
680 "o",
681 "r",
682 "octocat",
683 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
684 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
685 )
686 .unwrap();
687 assert_eq!(n, 102);
688 m1.assert();
689 m2.assert();
690 }
691
692 #[test]
693 fn count_authored_commits_empty_repo_returns_zero() {
694 let mut server = mockito::Server::new();
695 let mock = server
696 .mock("GET", "/repos/o/empty/commits")
697 .match_query(mockito::Matcher::Any)
698 .with_status(409)
699 .with_body(r#"{"message":"Git Repository is empty."}"#)
700 .create();
701 let client = Client::new(&server.url(), "tok").unwrap();
702 let n = client
703 .count_authored_commits(
704 "o",
705 "empty",
706 "x",
707 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
708 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
709 )
710 .unwrap();
711 assert_eq!(n, 0);
712 mock.assert();
713 }
714
715 #[test]
716 fn validate_token_distinguishes_invalid_from_error() {
717 let mut server = mockito::Server::new();
718 let ok = server
720 .mock("GET", "/user")
721 .match_header("authorization", "Bearer good")
722 .with_status(200)
723 .with_body(r#"{"id": 1, "login": "octocat"}"#)
724 .create();
725 assert!(validate_token(&server.url(), "good").unwrap());
726 ok.assert();
727 let bad = server
729 .mock("GET", "/user")
730 .match_header("authorization", "Bearer bad")
731 .with_status(401)
732 .with_body(r#"{"message": "Bad credentials"}"#)
733 .create();
734 assert!(!validate_token(&server.url(), "bad").unwrap());
735 bad.assert();
736 }
737
738 #[test]
739 fn repository_slug_prefers_full_name() {
740 let repo = Repository {
741 id: 1,
742 full_name: Some("o/r".into()),
743 name: Some("ignored".into()),
744 html_url: None,
745 };
746 assert_eq!(repo.slug(), Some("o/r"));
747 let evt_repo = Repository {
749 id: 1,
750 full_name: None,
751 name: Some("o/r".into()),
752 html_url: None,
753 };
754 assert_eq!(evt_repo.slug(), Some("o/r"));
755 }
756}