use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::{Deserialize, Serialize};
pub const DEFAULT_BASE_URL: &str = "https://api.github.com";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct User {
pub id: u64,
pub login: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Repository {
pub id: u64,
#[serde(default)]
pub full_name: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub html_url: Option<String>,
}
impl Repository {
pub fn slug(&self) -> Option<&str> {
self.full_name.as_deref().or(self.name.as_deref())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PullRequest {
pub number: u64,
pub title: String,
pub state: String,
#[serde(default)]
pub pull_request: Option<PullRequestRef>,
pub html_url: String,
#[serde(default)]
pub repository_url: Option<String>,
}
impl PullRequest {
pub fn repo_slug(&self) -> Option<String> {
if let Some(rest) = self
.html_url
.strip_prefix("https://github.com/")
.or_else(|| self.html_url.strip_prefix("https://"))
{
let parts: Vec<&str> = rest.splitn(4, '/').collect();
if parts.len() >= 2 {
return Some(format!("{}/{}", parts[0], parts[1]));
}
}
if let Some(repo) = &self.repository_url
&& let Some(rest) = repo.split("/repos/").nth(1)
{
return Some(rest.to_string());
}
None
}
pub fn merged_at(&self) -> Option<&str> {
self.pull_request
.as_ref()
.and_then(|p| p.merged_at.as_deref())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PullRequestRef {
#[serde(default)]
pub merged_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SearchIssuesResponse {
total_count: u64,
items: Vec<PullRequest>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Event {
pub id: String,
#[serde(rename = "type")]
pub event_type: String,
pub repo: Repository,
pub created_at: String,
#[serde(default)]
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitTagRef {
#[serde(rename = "ref")]
pub ref_name: String,
pub object: GitObject,
}
impl GitTagRef {
pub fn tag_name(&self) -> &str {
self.ref_name
.strip_prefix("refs/tags/")
.unwrap_or(&self.ref_name)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitObject {
#[serde(rename = "type")]
pub object_type: String,
pub sha: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AnnotatedTag {
pub tag: String,
pub tagger: Tagger,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Tagger {
pub name: String,
pub email: String,
pub date: String,
}
impl Event {
pub fn repo_slug(&self) -> Option<&str> {
self.repo.slug()
}
}
pub struct Client {
http: reqwest::blocking::Client,
base_url: String,
}
impl Client {
pub fn new(base_url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
sandogasa_cli::ensure_secure_url(base_url)?;
let http = build_http_client(token)?;
Ok(Self {
http,
base_url: base_url.trim_end_matches('/').to_string(),
})
}
pub fn user_by_username(
&self,
username: &str,
) -> Result<Option<User>, Box<dyn std::error::Error>> {
let url = format!("{}/users/{}", self.base_url, username);
let resp = self.http.get(&url).send()?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text()?;
return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
}
Ok(Some(resp.json()?))
}
pub fn search_pull_requests(
&self,
query: &str,
) -> Result<Vec<PullRequest>, Box<dyn std::error::Error>> {
let mut out: Vec<PullRequest> = Vec::new();
let mut page = 1u32;
loop {
let page_str = page.to_string();
let url = format!("{}/search/issues", self.base_url);
let resp = self
.http
.get(&url)
.query(&[("q", query), ("per_page", "100"), ("page", &page_str)])
.send()?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text()?;
return Err(format!("GitHub search failed: {status}: {text}").into());
}
let batch: SearchIssuesResponse = resp.json()?;
let n = batch.items.len();
out.extend(batch.items);
if n < 100 || out.len() as u64 >= batch.total_count {
break;
}
page += 1;
}
Ok(out)
}
pub fn user_events(&self, username: &str) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
let mut out: Vec<Event> = Vec::new();
let mut page = 1u32;
loop {
let url = format!("{}/users/{}/events", self.base_url, username);
let page_str = page.to_string();
let resp = self
.http
.get(&url)
.query(&[("per_page", "100"), ("page", &page_str)])
.send()?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text()?;
return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
}
let batch: Vec<Event> = resp.json()?;
let n = batch.len();
out.extend(batch);
if n < 100 || page >= 3 {
break;
}
page += 1;
}
Ok(out)
}
pub fn count_authored_commits(
&self,
owner: &str,
repo: &str,
author: &str,
since: chrono::NaiveDate,
until: chrono::NaiveDate,
) -> Result<u64, Box<dyn std::error::Error>> {
let url = format!("{}/repos/{}/{}/commits", self.base_url, owner, repo);
let since_str = format!("{since}T00:00:00Z");
let until_str = format!("{until}T23:59:59Z");
let mut total: u64 = 0;
let mut page = 1u32;
loop {
let page_str = page.to_string();
let query: Vec<(&str, &str)> = vec![
("author", author),
("since", &since_str),
("until", &until_str),
("per_page", "100"),
("page", &page_str),
];
let resp = self.http.get(&url).query(&query).send()?;
if resp.status().as_u16() == 404 || resp.status().as_u16() == 409 {
break;
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text()?;
return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
}
let batch: Vec<serde_json::Value> = resp.json()?;
let n = batch.len() as u64;
total += n;
if n < 100 {
break;
}
page += 1;
}
Ok(total)
}
pub fn list_tag_refs(
&self,
owner: &str,
repo: &str,
) -> Result<Vec<GitTagRef>, Box<dyn std::error::Error>> {
let url = format!("{}/repos/{}/{}/git/refs/tags", self.base_url, owner, repo);
let resp = self.http.get(&url).send()?;
if resp.status().as_u16() == 404 || resp.status().as_u16() == 409 {
return Ok(Vec::new());
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text()?;
return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
}
let body: serde_json::Value = resp.json()?;
if body.is_array() {
Ok(serde_json::from_value(body)?)
} else {
Ok(vec![serde_json::from_value(body)?])
}
}
pub fn get_annotated_tag(
&self,
owner: &str,
repo: &str,
sha: &str,
) -> Result<AnnotatedTag, Box<dyn std::error::Error>> {
let url = format!(
"{}/repos/{}/{}/git/tags/{}",
self.base_url, owner, repo, sha
);
let resp = self.http.get(&url).send()?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text()?;
return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
}
Ok(resp.json()?)
}
}
pub fn validate_token(base_url: &str, token: &str) -> Result<bool, Box<dyn std::error::Error>> {
sandogasa_cli::ensure_secure_url(base_url)?;
let http = build_http_client(token)?;
let url = format!("{}/user", base_url.trim_end_matches('/'));
let resp = http.get(&url).send()?;
let status = resp.status();
if status.is_success() {
return Ok(true);
}
if status.as_u16() == 401 {
return Ok(false);
}
let text = resp.text().unwrap_or_default();
Err(format!("GitHub /user check failed: {status}: {text}").into())
}
fn build_http_client(token: &str) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&format!("Bearer {token}"))?,
);
headers.insert(
HeaderName::from_static("accept"),
HeaderValue::from_static("application/vnd.github+json"),
);
headers.insert(
HeaderName::from_static("x-github-api-version"),
HeaderValue::from_static("2022-11-28"),
);
Ok(reqwest::blocking::Client::builder()
.user_agent(concat!("sandogasa-github/", env!("CARGO_PKG_VERSION")))
.default_headers(headers)
.timeout(DEFAULT_TIMEOUT)
.build()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_rejects_plaintext_remote() {
assert!(Client::new("http://api.example.com", "tok").is_err());
}
#[test]
fn user_by_username_returns_user() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/users/octocat")
.match_header("authorization", "Bearer tok")
.with_status(200)
.with_body(r#"{"id": 1, "login": "octocat"}"#)
.create();
let client = Client::new(&server.url(), "tok").unwrap();
let user = client.user_by_username("octocat").unwrap().unwrap();
assert_eq!(user.id, 1);
assert_eq!(user.login, "octocat");
mock.assert();
}
#[test]
fn user_by_username_404_is_none() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/users/ghost")
.with_status(404)
.with_body(r#"{"message": "Not Found"}"#)
.create();
let client = Client::new(&server.url(), "tok").unwrap();
assert!(client.user_by_username("ghost").unwrap().is_none());
mock.assert();
}
#[test]
fn search_pull_requests_paginates() {
let mut server = mockito::Server::new();
let items_page1 = (1..=100)
.map(|i| {
format!(
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"}}}}"#
)
})
.collect::<Vec<_>>()
.join(",");
let mock_p1 = server
.mock("GET", "/search/issues")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("page".into(), "1".into()),
mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
]))
.with_status(200)
.with_body(format!(
r#"{{"total_count":105,"incomplete_results":false,"items":[{items_page1}]}}"#
))
.create();
let mock_p2 = server
.mock("GET", "/search/issues")
.match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
.with_status(200)
.with_body(
r#"{"total_count":105,"incomplete_results":false,"items":[
{"number":101,"title":"x","state":"open","html_url":"https://github.com/o/r/pull/101"},
{"number":102,"title":"y","state":"open","html_url":"https://github.com/o/r/pull/102"},
{"number":103,"title":"z","state":"open","html_url":"https://github.com/o/r/pull/103"},
{"number":104,"title":"w","state":"open","html_url":"https://github.com/o/r/pull/104"},
{"number":105,"title":"v","state":"open","html_url":"https://github.com/o/r/pull/105"}
]}"#,
)
.create();
let client = Client::new(&server.url(), "tok").unwrap();
let prs = client
.search_pull_requests("type:pr author:octocat")
.unwrap();
assert_eq!(prs.len(), 105);
mock_p1.assert();
mock_p2.assert();
}
#[test]
fn pull_request_repo_slug_from_html_url() {
let pr = PullRequest {
number: 42,
title: "x".into(),
state: "open".into(),
pull_request: None,
html_url: "https://github.com/slopfest/sandogasa/pull/42".into(),
repository_url: None,
};
assert_eq!(pr.repo_slug().as_deref(), Some("slopfest/sandogasa"));
}
#[test]
fn pull_request_repo_slug_from_repository_url() {
let pr = PullRequest {
number: 42,
title: "x".into(),
state: "open".into(),
pull_request: None,
html_url: "https://github.com/o/r/issues/42".into(),
repository_url: Some("https://api.github.com/repos/slopfest/sandogasa".into()),
};
let pr2 = PullRequest {
html_url: "garbage".into(),
..pr
};
assert_eq!(pr2.repo_slug().as_deref(), Some("slopfest/sandogasa"));
}
#[test]
fn pull_request_merged_at_via_helper() {
let pr: PullRequest = serde_json::from_str(
r#"{"number":1,"title":"t","state":"closed",
"html_url":"https://github.com/o/r/pull/1",
"pull_request":{"merged_at":"2026-02-01T10:00:00Z"}}"#,
)
.unwrap();
assert_eq!(pr.merged_at(), Some("2026-02-01T10:00:00Z"));
}
#[test]
fn user_events_pagination_stops_at_300() {
let mut server = mockito::Server::new();
let make_event = |i: u64| {
format!(
r#"{{"id":"{i}","type":"PushEvent","repo":{{"id":1,"name":"o/r"}},"created_at":"2026-02-15T10:00:00Z"}}"#
)
};
let page1: String = (1..=100).map(make_event).collect::<Vec<_>>().join(",");
let page2: String = (101..=200).map(make_event).collect::<Vec<_>>().join(",");
let page3: String = (201..=250).map(make_event).collect::<Vec<_>>().join(",");
let m1 = server
.mock("GET", "/users/octocat/events")
.match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
.with_status(200)
.with_body(format!("[{page1}]"))
.create();
let m2 = server
.mock("GET", "/users/octocat/events")
.match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
.with_status(200)
.with_body(format!("[{page2}]"))
.create();
let m3 = server
.mock("GET", "/users/octocat/events")
.match_query(mockito::Matcher::UrlEncoded("page".into(), "3".into()))
.with_status(200)
.with_body(format!("[{page3}]"))
.create();
let client = Client::new(&server.url(), "tok").unwrap();
let events = client.user_events("octocat").unwrap();
assert_eq!(events.len(), 250);
m1.assert();
m2.assert();
m3.assert();
}
#[test]
fn count_authored_commits_paginates_and_handles_409() {
let mut server = mockito::Server::new();
let m1 = server
.mock("GET", "/repos/o/r/commits")
.match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
.with_status(200)
.with_body(format!("[{}]", vec!["{}"; 100].join(",")))
.create();
let m2 = server
.mock("GET", "/repos/o/r/commits")
.match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
.with_status(200)
.with_body("[{},{}]")
.create();
let client = Client::new(&server.url(), "tok").unwrap();
let n = client
.count_authored_commits(
"o",
"r",
"octocat",
chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
)
.unwrap();
assert_eq!(n, 102);
m1.assert();
m2.assert();
}
#[test]
fn count_authored_commits_empty_repo_returns_zero() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/o/empty/commits")
.match_query(mockito::Matcher::Any)
.with_status(409)
.with_body(r#"{"message":"Git Repository is empty."}"#)
.create();
let client = Client::new(&server.url(), "tok").unwrap();
let n = client
.count_authored_commits(
"o",
"empty",
"x",
chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
)
.unwrap();
assert_eq!(n, 0);
mock.assert();
}
#[test]
fn validate_token_distinguishes_invalid_from_error() {
let mut server = mockito::Server::new();
let ok = server
.mock("GET", "/user")
.match_header("authorization", "Bearer good")
.with_status(200)
.with_body(r#"{"id": 1, "login": "octocat"}"#)
.create();
assert!(validate_token(&server.url(), "good").unwrap());
ok.assert();
let bad = server
.mock("GET", "/user")
.match_header("authorization", "Bearer bad")
.with_status(401)
.with_body(r#"{"message": "Bad credentials"}"#)
.create();
assert!(!validate_token(&server.url(), "bad").unwrap());
bad.assert();
}
#[test]
fn repository_slug_prefers_full_name() {
let repo = Repository {
id: 1,
full_name: Some("o/r".into()),
name: Some("ignored".into()),
html_url: None,
};
assert_eq!(repo.slug(), Some("o/r"));
let evt_repo = Repository {
id: 1,
full_name: None,
name: Some("o/r".into()),
html_url: None,
};
assert_eq!(evt_repo.slug(), Some("o/r"));
}
}