Skip to main content

sandogasa_github/
lib.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2
3//! HTTP client for the GitHub REST API.
4//!
5//! Scoped to the surface `sandogasa-report` needs for activity
6//! reports: user identity lookup, token validation, paginated
7//! user events (to find which repos a user touched in a window),
8//! the Search API for pull requests, and per-repo
9//! authored-commit counts.
10//!
11//! Mirrors `sandogasa-gitlab` in shape so downstream tools can
12//! treat the two forges the same way structurally — host-keyed
13//! tokens and identities, optional org/group filter, etc.
14//!
15//! ```no_run
16//! use sandogasa_github::Client;
17//!
18//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
19//! let client = Client::new("https://api.github.com", "ghp_token")?;
20//! let user = client.user_by_username("octocat")?.expect("user exists");
21//! let prs = client.search_pull_requests(
22//!     &format!("type:pr author:{} created:2026-01-01..2026-03-31", user.login),
23//! )?;
24//! for pr in prs {
25//!     println!("{}: {}", pr.number, pr.title);
26//! }
27//! # Ok(())
28//! # }
29//! ```
30
31use std::time::Duration;
32
33use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
34use serde::{Deserialize, Serialize};
35
36/// Default production base URL for the GitHub REST API.
37pub const DEFAULT_BASE_URL: &str = "https://api.github.com";
38
39/// Upper bound on any single GitHub HTTP request. GitHub usually
40/// responds in well under 5s; this is a hang-catcher rather than
41/// a latency cap.
42const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
43
44/// A GitHub user as returned by `/users/{username}`. Only the
45/// fields downstream tools currently consume.
46#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct User {
48    pub id: u64,
49    pub login: String,
50    /// Public display name (`null` when the user hasn't set one).
51    #[serde(default)]
52    pub name: Option<String>,
53    /// Public email (`null` when the user keeps it private).
54    #[serde(default)]
55    pub email: Option<String>,
56}
57
58/// A repository as returned by event payloads and PR responses.
59#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct Repository {
61    pub id: u64,
62    /// `owner/name`, e.g. `slopfest/sandogasa`.
63    #[serde(default)]
64    pub full_name: Option<String>,
65    /// For event payloads where `repo.name` already carries the
66    /// `owner/name` form; preserved verbatim.
67    #[serde(default)]
68    pub name: Option<String>,
69    /// Web URL (when present on PR results).
70    #[serde(default)]
71    pub html_url: Option<String>,
72}
73
74impl Repository {
75    /// Best-effort `owner/name` extractor. Falls back to `name`
76    /// for event payloads where `repo.full_name` is omitted.
77    pub fn slug(&self) -> Option<&str> {
78        self.full_name.as_deref().or(self.name.as_deref())
79    }
80}
81
82/// A pull request as returned by the Search Issues endpoint
83/// when filtering on `type:pr`. The Search API actually returns
84/// "issue" objects with PR-specific fields populated, so we
85/// shape this around the union of useful fields rather than the
86/// fuller `/repos/{owner}/{repo}/pulls/{number}` model.
87#[derive(Debug, Clone, Deserialize, Serialize)]
88pub struct PullRequest {
89    pub number: u64,
90    pub title: String,
91    pub state: String,
92    /// Set when the PR has been merged. Some search responses
93    /// omit this field for open PRs.
94    #[serde(default)]
95    pub pull_request: Option<PullRequestRef>,
96    pub html_url: String,
97    /// The repo this PR lives in — derived from `html_url` since
98    /// the search response doesn't include a structured `repo`
99    /// object.
100    #[serde(default)]
101    pub repository_url: Option<String>,
102}
103
104impl PullRequest {
105    /// Extract the `owner/name` slug from this PR's HTML URL or
106    /// `repository_url`.
107    pub fn repo_slug(&self) -> Option<String> {
108        // html_url shape: `https://github.com/{owner}/{repo}/pull/N`
109        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        // Fallback: repository_url shape is
120        // `https://api.github.com/repos/{owner}/{repo}`.
121        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    /// Whether this PR has been merged. The Search API surfaces
130    /// merge state via the optional `pull_request.merged_at`
131    /// field; absence means "not merged".
132    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/// Auxiliary block on a search-result issue when it's actually
140/// a pull request. The Search Issues endpoint signals "is PR"
141/// by populating this; merged state lives here too.
142#[derive(Debug, Clone, Deserialize, Serialize)]
143pub struct PullRequestRef {
144    #[serde(default)]
145    pub merged_at: Option<String>,
146}
147
148/// Wire response wrapper for the Search Issues endpoint.
149#[derive(Debug, Deserialize)]
150struct SearchIssuesResponse {
151    total_count: u64,
152    items: Vec<PullRequest>,
153}
154
155/// One entry from `/users/{username}/events`. Fields are
156/// sparse; we keep just enough to identify the event type,
157/// associated repo, and the actor.
158#[derive(Debug, Clone, Deserialize, Serialize)]
159pub struct Event {
160    pub id: String,
161    /// `"PushEvent"`, `"PullRequestEvent"`,
162    /// `"PullRequestReviewCommentEvent"`, etc.
163    #[serde(rename = "type")]
164    pub event_type: String,
165    pub repo: Repository,
166    pub created_at: String,
167    /// Free-form payload — varies per event type.
168    #[serde(default)]
169    pub payload: serde_json::Value,
170}
171
172/// One entry from `/repos/{owner}/{repo}/git/refs/tags`. The
173/// underlying `object` distinguishes lightweight tags (point
174/// directly to a commit) from annotated tags (point to a Tag
175/// object that carries tagger info).
176#[derive(Debug, Clone, Deserialize, Serialize)]
177pub struct GitTagRef {
178    /// The fully-qualified ref, e.g. `refs/tags/v0.11.0`.
179    #[serde(rename = "ref")]
180    pub ref_name: String,
181    pub object: GitObject,
182}
183
184impl GitTagRef {
185    /// Strip the `refs/tags/` prefix to get just the tag name.
186    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/// The thing a Git ref points at. `object_type` is `"commit"`
194/// for lightweight tags and `"tag"` for annotated ones.
195#[derive(Debug, Clone, Deserialize, Serialize)]
196pub struct GitObject {
197    #[serde(rename = "type")]
198    pub object_type: String,
199    pub sha: String,
200}
201
202/// An annotated-tag object from
203/// `/repos/{owner}/{repo}/git/tags/{sha}`. Only annotated tags
204/// carry tagger metadata; lightweight tags don't have an
205/// addressable tag object.
206#[derive(Debug, Clone, Deserialize, Serialize)]
207pub struct AnnotatedTag {
208    pub tag: String,
209    pub tagger: Tagger,
210}
211
212/// `name` + `email` + `date` triple stamped on annotated-tag
213/// creation. `date` is ISO 8601 (e.g. `2026-05-15T17:03:15Z`).
214#[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    /// Helper for callers walking events to find touched repos —
223    /// returns the `owner/name` slug if available.
224    pub fn repo_slug(&self) -> Option<&str> {
225        self.repo.slug()
226    }
227}
228
229/// Blocking GitHub REST client.
230pub struct Client {
231    http: reqwest::blocking::Client,
232    base_url: String,
233}
234
235impl Client {
236    /// Build a client for `base_url` (typically
237    /// `https://api.github.com`, or a GHES instance's `/api/v3`
238    /// endpoint) authenticated with the given personal access
239    /// token.
240    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    /// Resolve a username to its full user object. Returns `None`
249    /// when the API responds with 404 (no such user); other
250    /// errors are returned as `Err`.
251    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    /// Run the Search Issues endpoint with the caller-supplied
269    /// query string and return every PR across all result pages.
270    ///
271    /// GitHub caps Search results at 1000 items total; if a
272    /// query exceeds that, the caller needs to narrow it (e.g.
273    /// by splitting the date window).
274    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    /// Paginate through the user-events endpoint up to GitHub's
305    /// 300-event cap. Returns the most recent events first.
306    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            // GitHub serves at most 300 events / 3 pages of 100.
326            if n < 100 || page >= 3 {
327                break;
328            }
329            page += 1;
330        }
331        Ok(out)
332    }
333
334    /// Count commits in `owner/repo` authored by `author` within
335    /// `[since, until]` (inclusive). Used as a cross-check
336    /// against push-event counts — see the GitLab equivalent
337    /// for the rationale.
338    ///
339    /// 404 and 409 responses are treated as "0 commits" rather
340    /// than errors. 404 means the repo was deleted or made
341    /// private between the events scan and this call; 409 means
342    /// the repo exists but is empty. Either way, a single
343    /// missing repo shouldn't abort the surrounding report.
344    /// The trade-off: real auth failures targeting a single
345    /// repo would also be hidden, but GitHub returns 401/403 for
346    /// those, not 404/409.
347    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            // GitHub returns 409 Conflict for empty repositories
371            // and 404 if the repo was deleted. Treat both as
372            // "no commits" rather than hard errors so a single
373            // gone repo doesn't abort the whole report.
374            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    /// List all tag refs for `owner/repo`. Returns an empty list
394    /// on 404 (gone repo) and 409 (empty repo) so callers can
395    /// iterate over many repos without per-repo error handling.
396    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        // 404 = repo gone or no tags ref namespace; 409 = empty repo.
404        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        // GitHub returns an object (not an array) when there's
413        // exactly one match. The standard `/git/refs/tags` shape
414        // returns an array for the namespace listing; we only see
415        // the object form when someone queries a single ref. Be
416        // defensive and accept both.
417        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    /// Fetch one annotated-tag object by SHA. Use only when the
426    /// matching `GitTagRef.object.object_type == "tag"` —
427    /// lightweight tags (where `object_type == "commit"`) have no
428    /// addressable tag object and this endpoint will 404.
429    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
449/// Check whether `token` works against `base_url` by hitting
450/// `/user`. Returns `Ok(true)` for valid tokens, `Ok(false)`
451/// for 401s, and `Err` for other transport / server errors so
452/// callers can distinguish "tried and was rejected" from
453/// "couldn't reach the server".
454pub 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
469/// Build a reqwest client preconfigured for the GitHub API: the
470/// Bearer token, the recommended JSON Accept header, a User-Agent
471/// (GitHub requires one), and our standard request timeout.
472fn 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        // First page: full 100 items, total_count says 105.
530        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        // html_url is consulted first, but works as a fallback path.
596        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        // GitHub serves at most 3 pages of events. Page 1 and 2
618        // return full pages, page 3 returns a final 50 to test the
619        // short-page break path.
620        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        // Valid token.
711        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        // Wrong token → 401.
720        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        // Event payloads only ship `name`.
740        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}