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