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        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    /// Resolve a username to its full user object. Returns `None`
250    /// when the API responds with 404 (no such user); other
251    /// errors are returned as `Err`.
252    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    /// Run the Search Issues endpoint with the caller-supplied
270    /// query string and return every PR across all result pages.
271    ///
272    /// GitHub caps Search results at 1000 items total; if a
273    /// query exceeds that, the caller needs to narrow it (e.g.
274    /// by splitting the date window).
275    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    /// Paginate through the user-events endpoint up to GitHub's
306    /// 300-event cap. Returns the most recent events first.
307    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            // GitHub serves at most 300 events / 3 pages of 100.
327            if n < 100 || page >= 3 {
328                break;
329            }
330            page += 1;
331        }
332        Ok(out)
333    }
334
335    /// Count commits in `owner/repo` authored by `author` within
336    /// `[since, until]` (inclusive). Used as a cross-check
337    /// against push-event counts — see the GitLab equivalent
338    /// for the rationale.
339    ///
340    /// 404 and 409 responses are treated as "0 commits" rather
341    /// than errors. 404 means the repo was deleted or made
342    /// private between the events scan and this call; 409 means
343    /// the repo exists but is empty. Either way, a single
344    /// missing repo shouldn't abort the surrounding report.
345    /// The trade-off: real auth failures targeting a single
346    /// repo would also be hidden, but GitHub returns 401/403 for
347    /// those, not 404/409.
348    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            // GitHub returns 409 Conflict for empty repositories
372            // and 404 if the repo was deleted. Treat both as
373            // "no commits" rather than hard errors so a single
374            // gone repo doesn't abort the whole report.
375            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    /// List all tag refs for `owner/repo`. Returns an empty list
395    /// on 404 (gone repo) and 409 (empty repo) so callers can
396    /// iterate over many repos without per-repo error handling.
397    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        // 404 = repo gone or no tags ref namespace; 409 = empty repo.
405        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        // GitHub returns an object (not an array) when there's
414        // exactly one match. The standard `/git/refs/tags` shape
415        // returns an array for the namespace listing; we only see
416        // the object form when someone queries a single ref. Be
417        // defensive and accept both.
418        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    /// Fetch one annotated-tag object by SHA. Use only when the
427    /// matching `GitTagRef.object.object_type == "tag"` —
428    /// lightweight tags (where `object_type == "commit"`) have no
429    /// addressable tag object and this endpoint will 404.
430    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
450/// Check whether `token` works against `base_url` by hitting
451/// `/user`. Returns `Ok(true)` for valid tokens, `Ok(false)`
452/// for 401s, and `Err` for other transport / server errors so
453/// callers can distinguish "tried and was rejected" from
454/// "couldn't reach the server".
455pub 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
471/// Build a reqwest client preconfigured for the GitHub API: the
472/// Bearer token, the recommended JSON Accept header, a User-Agent
473/// (GitHub requires one), and our standard request timeout.
474fn 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        // A token must not be sent to a plaintext http non-loopback URL.
503        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        // First page: full 100 items, total_count says 105.
539        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        // html_url is consulted first, but works as a fallback path.
605        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        // GitHub serves at most 3 pages of events. Page 1 and 2
627        // return full pages, page 3 returns a final 50 to test the
628        // short-page break path.
629        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        // Valid token.
720        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        // Wrong token → 401.
729        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        // Event payloads only ship `name`.
749        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}