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 sandogasa_cli::install_crypto_provider();
489 Ok(reqwest::blocking::Client::builder()
490 .user_agent(concat!("sandogasa-github/", env!("CARGO_PKG_VERSION")))
491 .default_headers(headers)
492 .timeout(DEFAULT_TIMEOUT)
493 .build()?)
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn new_rejects_plaintext_remote() {
502 assert!(Client::new("http://api.example.com", "tok").is_err());
504 }
505
506 #[test]
507 fn user_by_username_returns_user() {
508 let mut server = mockito::Server::new();
509 let mock = server
510 .mock("GET", "/users/octocat")
511 .match_header("authorization", "Bearer tok")
512 .with_status(200)
513 .with_body(r#"{"id": 1, "login": "octocat"}"#)
514 .create();
515 let client = Client::new(&server.url(), "tok").unwrap();
516 let user = client.user_by_username("octocat").unwrap().unwrap();
517 assert_eq!(user.id, 1);
518 assert_eq!(user.login, "octocat");
519 mock.assert();
520 }
521
522 #[test]
523 fn user_by_username_404_is_none() {
524 let mut server = mockito::Server::new();
525 let mock = server
526 .mock("GET", "/users/ghost")
527 .with_status(404)
528 .with_body(r#"{"message": "Not Found"}"#)
529 .create();
530 let client = Client::new(&server.url(), "tok").unwrap();
531 assert!(client.user_by_username("ghost").unwrap().is_none());
532 mock.assert();
533 }
534
535 #[test]
536 fn search_pull_requests_paginates() {
537 let mut server = mockito::Server::new();
538 let items_page1 = (1..=100)
540 .map(|i| {
541 format!(
542 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"}}}}"#
543 )
544 })
545 .collect::<Vec<_>>()
546 .join(",");
547 let mock_p1 = server
548 .mock("GET", "/search/issues")
549 .match_query(mockito::Matcher::AllOf(vec![
550 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
551 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
552 ]))
553 .with_status(200)
554 .with_body(format!(
555 r#"{{"total_count":105,"incomplete_results":false,"items":[{items_page1}]}}"#
556 ))
557 .create();
558 let mock_p2 = server
559 .mock("GET", "/search/issues")
560 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
561 .with_status(200)
562 .with_body(
563 r#"{"total_count":105,"incomplete_results":false,"items":[
564 {"number":101,"title":"x","state":"open","html_url":"https://github.com/o/r/pull/101"},
565 {"number":102,"title":"y","state":"open","html_url":"https://github.com/o/r/pull/102"},
566 {"number":103,"title":"z","state":"open","html_url":"https://github.com/o/r/pull/103"},
567 {"number":104,"title":"w","state":"open","html_url":"https://github.com/o/r/pull/104"},
568 {"number":105,"title":"v","state":"open","html_url":"https://github.com/o/r/pull/105"}
569 ]}"#,
570 )
571 .create();
572 let client = Client::new(&server.url(), "tok").unwrap();
573 let prs = client
574 .search_pull_requests("type:pr author:octocat")
575 .unwrap();
576 assert_eq!(prs.len(), 105);
577 mock_p1.assert();
578 mock_p2.assert();
579 }
580
581 #[test]
582 fn pull_request_repo_slug_from_html_url() {
583 let pr = PullRequest {
584 number: 42,
585 title: "x".into(),
586 state: "open".into(),
587 pull_request: None,
588 html_url: "https://github.com/slopfest/sandogasa/pull/42".into(),
589 repository_url: None,
590 };
591 assert_eq!(pr.repo_slug().as_deref(), Some("slopfest/sandogasa"));
592 }
593
594 #[test]
595 fn pull_request_repo_slug_from_repository_url() {
596 let pr = PullRequest {
597 number: 42,
598 title: "x".into(),
599 state: "open".into(),
600 pull_request: None,
601 html_url: "https://github.com/o/r/issues/42".into(),
602 repository_url: Some("https://api.github.com/repos/slopfest/sandogasa".into()),
603 };
604 let pr2 = PullRequest {
606 html_url: "garbage".into(),
607 ..pr
608 };
609 assert_eq!(pr2.repo_slug().as_deref(), Some("slopfest/sandogasa"));
610 }
611
612 #[test]
613 fn pull_request_merged_at_via_helper() {
614 let pr: PullRequest = serde_json::from_str(
615 r#"{"number":1,"title":"t","state":"closed",
616 "html_url":"https://github.com/o/r/pull/1",
617 "pull_request":{"merged_at":"2026-02-01T10:00:00Z"}}"#,
618 )
619 .unwrap();
620 assert_eq!(pr.merged_at(), Some("2026-02-01T10:00:00Z"));
621 }
622
623 #[test]
624 fn user_events_pagination_stops_at_300() {
625 let mut server = mockito::Server::new();
626 let make_event = |i: u64| {
630 format!(
631 r#"{{"id":"{i}","type":"PushEvent","repo":{{"id":1,"name":"o/r"}},"created_at":"2026-02-15T10:00:00Z"}}"#
632 )
633 };
634 let page1: String = (1..=100).map(make_event).collect::<Vec<_>>().join(",");
635 let page2: String = (101..=200).map(make_event).collect::<Vec<_>>().join(",");
636 let page3: String = (201..=250).map(make_event).collect::<Vec<_>>().join(",");
637 let m1 = server
638 .mock("GET", "/users/octocat/events")
639 .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
640 .with_status(200)
641 .with_body(format!("[{page1}]"))
642 .create();
643 let m2 = server
644 .mock("GET", "/users/octocat/events")
645 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
646 .with_status(200)
647 .with_body(format!("[{page2}]"))
648 .create();
649 let m3 = server
650 .mock("GET", "/users/octocat/events")
651 .match_query(mockito::Matcher::UrlEncoded("page".into(), "3".into()))
652 .with_status(200)
653 .with_body(format!("[{page3}]"))
654 .create();
655 let client = Client::new(&server.url(), "tok").unwrap();
656 let events = client.user_events("octocat").unwrap();
657 assert_eq!(events.len(), 250);
658 m1.assert();
659 m2.assert();
660 m3.assert();
661 }
662
663 #[test]
664 fn count_authored_commits_paginates_and_handles_409() {
665 let mut server = mockito::Server::new();
666 let m1 = server
667 .mock("GET", "/repos/o/r/commits")
668 .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
669 .with_status(200)
670 .with_body(format!("[{}]", vec!["{}"; 100].join(",")))
671 .create();
672 let m2 = server
673 .mock("GET", "/repos/o/r/commits")
674 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
675 .with_status(200)
676 .with_body("[{},{}]")
677 .create();
678 let client = Client::new(&server.url(), "tok").unwrap();
679 let n = client
680 .count_authored_commits(
681 "o",
682 "r",
683 "octocat",
684 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
685 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
686 )
687 .unwrap();
688 assert_eq!(n, 102);
689 m1.assert();
690 m2.assert();
691 }
692
693 #[test]
694 fn count_authored_commits_empty_repo_returns_zero() {
695 let mut server = mockito::Server::new();
696 let mock = server
697 .mock("GET", "/repos/o/empty/commits")
698 .match_query(mockito::Matcher::Any)
699 .with_status(409)
700 .with_body(r#"{"message":"Git Repository is empty."}"#)
701 .create();
702 let client = Client::new(&server.url(), "tok").unwrap();
703 let n = client
704 .count_authored_commits(
705 "o",
706 "empty",
707 "x",
708 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
709 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
710 )
711 .unwrap();
712 assert_eq!(n, 0);
713 mock.assert();
714 }
715
716 #[test]
717 fn validate_token_distinguishes_invalid_from_error() {
718 let mut server = mockito::Server::new();
719 let ok = server
721 .mock("GET", "/user")
722 .match_header("authorization", "Bearer good")
723 .with_status(200)
724 .with_body(r#"{"id": 1, "login": "octocat"}"#)
725 .create();
726 assert!(validate_token(&server.url(), "good").unwrap());
727 ok.assert();
728 let bad = server
730 .mock("GET", "/user")
731 .match_header("authorization", "Bearer bad")
732 .with_status(401)
733 .with_body(r#"{"message": "Bad credentials"}"#)
734 .create();
735 assert!(!validate_token(&server.url(), "bad").unwrap());
736 bad.assert();
737 }
738
739 #[test]
740 fn repository_slug_prefers_full_name() {
741 let repo = Repository {
742 id: 1,
743 full_name: Some("o/r".into()),
744 name: Some("ignored".into()),
745 html_url: None,
746 };
747 assert_eq!(repo.slug(), Some("o/r"));
748 let evt_repo = Repository {
750 id: 1,
751 full_name: None,
752 name: Some("o/r".into()),
753 html_url: None,
754 };
755 assert_eq!(evt_repo.slug(), Some("o/r"));
756 }
757}