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}
51
52/// A repository as returned by event payloads and PR responses.
53#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Repository {
55    pub id: u64,
56    /// `owner/name`, e.g. `slopfest/sandogasa`.
57    #[serde(default)]
58    pub full_name: Option<String>,
59    /// For event payloads where `repo.name` already carries the
60    /// `owner/name` form; preserved verbatim.
61    #[serde(default)]
62    pub name: Option<String>,
63    /// Web URL (when present on PR results).
64    #[serde(default)]
65    pub html_url: Option<String>,
66}
67
68impl Repository {
69    /// Best-effort `owner/name` extractor. Falls back to `name`
70    /// for event payloads where `repo.full_name` is omitted.
71    pub fn slug(&self) -> Option<&str> {
72        self.full_name.as_deref().or(self.name.as_deref())
73    }
74}
75
76/// A pull request as returned by the Search Issues endpoint
77/// when filtering on `type:pr`. The Search API actually returns
78/// "issue" objects with PR-specific fields populated, so we
79/// shape this around the union of useful fields rather than the
80/// fuller `/repos/{owner}/{repo}/pulls/{number}` model.
81#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct PullRequest {
83    pub number: u64,
84    pub title: String,
85    pub state: String,
86    /// Set when the PR has been merged. Some search responses
87    /// omit this field for open PRs.
88    #[serde(default)]
89    pub pull_request: Option<PullRequestRef>,
90    pub html_url: String,
91    /// The repo this PR lives in — derived from `html_url` since
92    /// the search response doesn't include a structured `repo`
93    /// object.
94    #[serde(default)]
95    pub repository_url: Option<String>,
96}
97
98impl PullRequest {
99    /// Extract the `owner/name` slug from this PR's HTML URL or
100    /// `repository_url`.
101    pub fn repo_slug(&self) -> Option<String> {
102        // html_url shape: `https://github.com/{owner}/{repo}/pull/N`
103        if let Some(rest) = self
104            .html_url
105            .strip_prefix("https://github.com/")
106            .or_else(|| self.html_url.strip_prefix("https://"))
107        {
108            let parts: Vec<&str> = rest.splitn(4, '/').collect();
109            if parts.len() >= 2 {
110                return Some(format!("{}/{}", parts[0], parts[1]));
111            }
112        }
113        // Fallback: repository_url shape is
114        // `https://api.github.com/repos/{owner}/{repo}`.
115        if let Some(repo) = &self.repository_url
116            && let Some(rest) = repo.split("/repos/").nth(1)
117        {
118            return Some(rest.to_string());
119        }
120        None
121    }
122
123    /// Whether this PR has been merged. The Search API surfaces
124    /// merge state via the optional `pull_request.merged_at`
125    /// field; absence means "not merged".
126    pub fn merged_at(&self) -> Option<&str> {
127        self.pull_request
128            .as_ref()
129            .and_then(|p| p.merged_at.as_deref())
130    }
131}
132
133/// Auxiliary block on a search-result issue when it's actually
134/// a pull request. The Search Issues endpoint signals "is PR"
135/// by populating this; merged state lives here too.
136#[derive(Debug, Clone, Deserialize, Serialize)]
137pub struct PullRequestRef {
138    #[serde(default)]
139    pub merged_at: Option<String>,
140}
141
142/// Wire response wrapper for the Search Issues endpoint.
143#[derive(Debug, Deserialize)]
144struct SearchIssuesResponse {
145    total_count: u64,
146    items: Vec<PullRequest>,
147}
148
149/// One entry from `/users/{username}/events`. Fields are
150/// sparse; we keep just enough to identify the event type,
151/// associated repo, and the actor.
152#[derive(Debug, Clone, Deserialize, Serialize)]
153pub struct Event {
154    pub id: String,
155    /// `"PushEvent"`, `"PullRequestEvent"`,
156    /// `"PullRequestReviewCommentEvent"`, etc.
157    #[serde(rename = "type")]
158    pub event_type: String,
159    pub repo: Repository,
160    pub created_at: String,
161    /// Free-form payload — varies per event type.
162    #[serde(default)]
163    pub payload: serde_json::Value,
164}
165
166impl Event {
167    /// Helper for callers walking events to find touched repos —
168    /// returns the `owner/name` slug if available.
169    pub fn repo_slug(&self) -> Option<&str> {
170        self.repo.slug()
171    }
172}
173
174/// Blocking GitHub REST client.
175pub struct Client {
176    http: reqwest::blocking::Client,
177    base_url: String,
178}
179
180impl Client {
181    /// Build a client for `base_url` (typically
182    /// `https://api.github.com`, or a GHES instance's `/api/v3`
183    /// endpoint) authenticated with the given personal access
184    /// token.
185    pub fn new(base_url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
186        let http = build_http_client(token)?;
187        Ok(Self {
188            http,
189            base_url: base_url.trim_end_matches('/').to_string(),
190        })
191    }
192
193    /// Resolve a username to its full user object. Returns `None`
194    /// when the API responds with 404 (no such user); other
195    /// errors are returned as `Err`.
196    pub fn user_by_username(
197        &self,
198        username: &str,
199    ) -> Result<Option<User>, Box<dyn std::error::Error>> {
200        let url = format!("{}/users/{}", self.base_url, username);
201        let resp = self.http.get(&url).send()?;
202        if resp.status().as_u16() == 404 {
203            return Ok(None);
204        }
205        if !resp.status().is_success() {
206            let status = resp.status();
207            let text = resp.text()?;
208            return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
209        }
210        Ok(Some(resp.json()?))
211    }
212
213    /// Run the Search Issues endpoint with the caller-supplied
214    /// query string and return every PR across all result pages.
215    ///
216    /// GitHub caps Search results at 1000 items total; if a
217    /// query exceeds that, the caller needs to narrow it (e.g.
218    /// by splitting the date window).
219    pub fn search_pull_requests(
220        &self,
221        query: &str,
222    ) -> Result<Vec<PullRequest>, Box<dyn std::error::Error>> {
223        let mut out: Vec<PullRequest> = Vec::new();
224        let mut page = 1u32;
225        loop {
226            let page_str = page.to_string();
227            let url = format!("{}/search/issues", self.base_url);
228            let resp = self
229                .http
230                .get(&url)
231                .query(&[("q", query), ("per_page", "100"), ("page", &page_str)])
232                .send()?;
233            if !resp.status().is_success() {
234                let status = resp.status();
235                let text = resp.text()?;
236                return Err(format!("GitHub search failed: {status}: {text}").into());
237            }
238            let batch: SearchIssuesResponse = resp.json()?;
239            let n = batch.items.len();
240            out.extend(batch.items);
241            if n < 100 || out.len() as u64 >= batch.total_count {
242                break;
243            }
244            page += 1;
245        }
246        Ok(out)
247    }
248
249    /// Paginate through the user-events endpoint up to GitHub's
250    /// 300-event cap. Returns the most recent events first.
251    pub fn user_events(&self, username: &str) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
252        let mut out: Vec<Event> = Vec::new();
253        let mut page = 1u32;
254        loop {
255            let url = format!("{}/users/{}/events", self.base_url, username);
256            let page_str = page.to_string();
257            let resp = self
258                .http
259                .get(&url)
260                .query(&[("per_page", "100"), ("page", &page_str)])
261                .send()?;
262            if !resp.status().is_success() {
263                let status = resp.status();
264                let text = resp.text()?;
265                return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
266            }
267            let batch: Vec<Event> = resp.json()?;
268            let n = batch.len();
269            out.extend(batch);
270            // GitHub serves at most 300 events / 3 pages of 100.
271            if n < 100 || page >= 3 {
272                break;
273            }
274            page += 1;
275        }
276        Ok(out)
277    }
278
279    /// Count commits in `owner/repo` authored by `author` within
280    /// `[since, until]` (inclusive). Used as a cross-check
281    /// against push-event counts — see the GitLab equivalent
282    /// for the rationale.
283    ///
284    /// 404 and 409 responses are treated as "0 commits" rather
285    /// than errors. 404 means the repo was deleted or made
286    /// private between the events scan and this call; 409 means
287    /// the repo exists but is empty. Either way, a single
288    /// missing repo shouldn't abort the surrounding report.
289    /// The trade-off: real auth failures targeting a single
290    /// repo would also be hidden, but GitHub returns 401/403 for
291    /// those, not 404/409.
292    pub fn count_authored_commits(
293        &self,
294        owner: &str,
295        repo: &str,
296        author: &str,
297        since: chrono::NaiveDate,
298        until: chrono::NaiveDate,
299    ) -> Result<u64, Box<dyn std::error::Error>> {
300        let url = format!("{}/repos/{}/{}/commits", self.base_url, owner, repo);
301        let since_str = format!("{since}T00:00:00Z");
302        let until_str = format!("{until}T23:59:59Z");
303        let mut total: u64 = 0;
304        let mut page = 1u32;
305        loop {
306            let page_str = page.to_string();
307            let query: Vec<(&str, &str)> = vec![
308                ("author", author),
309                ("since", &since_str),
310                ("until", &until_str),
311                ("per_page", "100"),
312                ("page", &page_str),
313            ];
314            let resp = self.http.get(&url).query(&query).send()?;
315            // GitHub returns 409 Conflict for empty repositories
316            // and 404 if the repo was deleted. Treat both as
317            // "no commits" rather than hard errors so a single
318            // gone repo doesn't abort the whole report.
319            if resp.status().as_u16() == 404 || resp.status().as_u16() == 409 {
320                break;
321            }
322            if !resp.status().is_success() {
323                let status = resp.status();
324                let text = resp.text()?;
325                return Err(format!("GitHub GET {url} failed: {status}: {text}").into());
326            }
327            let batch: Vec<serde_json::Value> = resp.json()?;
328            let n = batch.len() as u64;
329            total += n;
330            if n < 100 {
331                break;
332            }
333            page += 1;
334        }
335        Ok(total)
336    }
337}
338
339/// Check whether `token` works against `base_url` by hitting
340/// `/user`. Returns `Ok(true)` for valid tokens, `Ok(false)`
341/// for 401s, and `Err` for other transport / server errors so
342/// callers can distinguish "tried and was rejected" from
343/// "couldn't reach the server".
344pub fn validate_token(base_url: &str, token: &str) -> Result<bool, Box<dyn std::error::Error>> {
345    let http = build_http_client(token)?;
346    let url = format!("{}/user", base_url.trim_end_matches('/'));
347    let resp = http.get(&url).send()?;
348    let status = resp.status();
349    if status.is_success() {
350        return Ok(true);
351    }
352    if status.as_u16() == 401 {
353        return Ok(false);
354    }
355    let text = resp.text().unwrap_or_default();
356    Err(format!("GitHub /user check failed: {status}: {text}").into())
357}
358
359/// Build a reqwest client preconfigured for the GitHub API: the
360/// Bearer token, the recommended JSON Accept header, a User-Agent
361/// (GitHub requires one), and our standard request timeout.
362fn build_http_client(token: &str) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
363    let mut headers = HeaderMap::new();
364    headers.insert(
365        HeaderName::from_static("authorization"),
366        HeaderValue::from_str(&format!("Bearer {token}"))?,
367    );
368    headers.insert(
369        HeaderName::from_static("accept"),
370        HeaderValue::from_static("application/vnd.github+json"),
371    );
372    headers.insert(
373        HeaderName::from_static("x-github-api-version"),
374        HeaderValue::from_static("2022-11-28"),
375    );
376    Ok(reqwest::blocking::Client::builder()
377        .user_agent(concat!("sandogasa-github/", env!("CARGO_PKG_VERSION")))
378        .default_headers(headers)
379        .timeout(DEFAULT_TIMEOUT)
380        .build()?)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn user_by_username_returns_user() {
389        let mut server = mockito::Server::new();
390        let mock = server
391            .mock("GET", "/users/octocat")
392            .match_header("authorization", "Bearer tok")
393            .with_status(200)
394            .with_body(r#"{"id": 1, "login": "octocat"}"#)
395            .create();
396        let client = Client::new(&server.url(), "tok").unwrap();
397        let user = client.user_by_username("octocat").unwrap().unwrap();
398        assert_eq!(user.id, 1);
399        assert_eq!(user.login, "octocat");
400        mock.assert();
401    }
402
403    #[test]
404    fn user_by_username_404_is_none() {
405        let mut server = mockito::Server::new();
406        let mock = server
407            .mock("GET", "/users/ghost")
408            .with_status(404)
409            .with_body(r#"{"message": "Not Found"}"#)
410            .create();
411        let client = Client::new(&server.url(), "tok").unwrap();
412        assert!(client.user_by_username("ghost").unwrap().is_none());
413        mock.assert();
414    }
415
416    #[test]
417    fn search_pull_requests_paginates() {
418        let mut server = mockito::Server::new();
419        // First page: full 100 items, total_count says 105.
420        let items_page1 = (1..=100)
421            .map(|i| {
422                format!(
423                    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"}}}}"#
424                )
425            })
426            .collect::<Vec<_>>()
427            .join(",");
428        let mock_p1 = server
429            .mock("GET", "/search/issues")
430            .match_query(mockito::Matcher::AllOf(vec![
431                mockito::Matcher::UrlEncoded("page".into(), "1".into()),
432                mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
433            ]))
434            .with_status(200)
435            .with_body(format!(
436                r#"{{"total_count":105,"incomplete_results":false,"items":[{items_page1}]}}"#
437            ))
438            .create();
439        let mock_p2 = server
440            .mock("GET", "/search/issues")
441            .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
442            .with_status(200)
443            .with_body(
444                r#"{"total_count":105,"incomplete_results":false,"items":[
445                    {"number":101,"title":"x","state":"open","html_url":"https://github.com/o/r/pull/101"},
446                    {"number":102,"title":"y","state":"open","html_url":"https://github.com/o/r/pull/102"},
447                    {"number":103,"title":"z","state":"open","html_url":"https://github.com/o/r/pull/103"},
448                    {"number":104,"title":"w","state":"open","html_url":"https://github.com/o/r/pull/104"},
449                    {"number":105,"title":"v","state":"open","html_url":"https://github.com/o/r/pull/105"}
450                ]}"#,
451            )
452            .create();
453        let client = Client::new(&server.url(), "tok").unwrap();
454        let prs = client
455            .search_pull_requests("type:pr author:octocat")
456            .unwrap();
457        assert_eq!(prs.len(), 105);
458        mock_p1.assert();
459        mock_p2.assert();
460    }
461
462    #[test]
463    fn pull_request_repo_slug_from_html_url() {
464        let pr = PullRequest {
465            number: 42,
466            title: "x".into(),
467            state: "open".into(),
468            pull_request: None,
469            html_url: "https://github.com/slopfest/sandogasa/pull/42".into(),
470            repository_url: None,
471        };
472        assert_eq!(pr.repo_slug().as_deref(), Some("slopfest/sandogasa"));
473    }
474
475    #[test]
476    fn pull_request_repo_slug_from_repository_url() {
477        let pr = PullRequest {
478            number: 42,
479            title: "x".into(),
480            state: "open".into(),
481            pull_request: None,
482            html_url: "https://github.com/o/r/issues/42".into(),
483            repository_url: Some("https://api.github.com/repos/slopfest/sandogasa".into()),
484        };
485        // html_url is consulted first, but works as a fallback path.
486        let pr2 = PullRequest {
487            html_url: "garbage".into(),
488            ..pr
489        };
490        assert_eq!(pr2.repo_slug().as_deref(), Some("slopfest/sandogasa"));
491    }
492
493    #[test]
494    fn pull_request_merged_at_via_helper() {
495        let pr: PullRequest = serde_json::from_str(
496            r#"{"number":1,"title":"t","state":"closed",
497                "html_url":"https://github.com/o/r/pull/1",
498                "pull_request":{"merged_at":"2026-02-01T10:00:00Z"}}"#,
499        )
500        .unwrap();
501        assert_eq!(pr.merged_at(), Some("2026-02-01T10:00:00Z"));
502    }
503
504    #[test]
505    fn user_events_pagination_stops_at_300() {
506        let mut server = mockito::Server::new();
507        // GitHub serves at most 3 pages of events. Page 1 and 2
508        // return full pages, page 3 returns a final 50 to test the
509        // short-page break path.
510        let make_event = |i: u64| {
511            format!(
512                r#"{{"id":"{i}","type":"PushEvent","repo":{{"id":1,"name":"o/r"}},"created_at":"2026-02-15T10:00:00Z"}}"#
513            )
514        };
515        let page1: String = (1..=100).map(make_event).collect::<Vec<_>>().join(",");
516        let page2: String = (101..=200).map(make_event).collect::<Vec<_>>().join(",");
517        let page3: String = (201..=250).map(make_event).collect::<Vec<_>>().join(",");
518        let m1 = server
519            .mock("GET", "/users/octocat/events")
520            .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
521            .with_status(200)
522            .with_body(format!("[{page1}]"))
523            .create();
524        let m2 = server
525            .mock("GET", "/users/octocat/events")
526            .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
527            .with_status(200)
528            .with_body(format!("[{page2}]"))
529            .create();
530        let m3 = server
531            .mock("GET", "/users/octocat/events")
532            .match_query(mockito::Matcher::UrlEncoded("page".into(), "3".into()))
533            .with_status(200)
534            .with_body(format!("[{page3}]"))
535            .create();
536        let client = Client::new(&server.url(), "tok").unwrap();
537        let events = client.user_events("octocat").unwrap();
538        assert_eq!(events.len(), 250);
539        m1.assert();
540        m2.assert();
541        m3.assert();
542    }
543
544    #[test]
545    fn count_authored_commits_paginates_and_handles_409() {
546        let mut server = mockito::Server::new();
547        let m1 = server
548            .mock("GET", "/repos/o/r/commits")
549            .match_query(mockito::Matcher::UrlEncoded("page".into(), "1".into()))
550            .with_status(200)
551            .with_body(format!("[{}]", vec!["{}"; 100].join(",")))
552            .create();
553        let m2 = server
554            .mock("GET", "/repos/o/r/commits")
555            .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
556            .with_status(200)
557            .with_body("[{},{}]")
558            .create();
559        let client = Client::new(&server.url(), "tok").unwrap();
560        let n = client
561            .count_authored_commits(
562                "o",
563                "r",
564                "octocat",
565                chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
566                chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
567            )
568            .unwrap();
569        assert_eq!(n, 102);
570        m1.assert();
571        m2.assert();
572    }
573
574    #[test]
575    fn count_authored_commits_empty_repo_returns_zero() {
576        let mut server = mockito::Server::new();
577        let mock = server
578            .mock("GET", "/repos/o/empty/commits")
579            .match_query(mockito::Matcher::Any)
580            .with_status(409)
581            .with_body(r#"{"message":"Git Repository is empty."}"#)
582            .create();
583        let client = Client::new(&server.url(), "tok").unwrap();
584        let n = client
585            .count_authored_commits(
586                "o",
587                "empty",
588                "x",
589                chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
590                chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
591            )
592            .unwrap();
593        assert_eq!(n, 0);
594        mock.assert();
595    }
596
597    #[test]
598    fn validate_token_distinguishes_invalid_from_error() {
599        let mut server = mockito::Server::new();
600        // Valid token.
601        let ok = server
602            .mock("GET", "/user")
603            .match_header("authorization", "Bearer good")
604            .with_status(200)
605            .with_body(r#"{"id": 1, "login": "octocat"}"#)
606            .create();
607        assert!(validate_token(&server.url(), "good").unwrap());
608        ok.assert();
609        // Wrong token → 401.
610        let bad = server
611            .mock("GET", "/user")
612            .match_header("authorization", "Bearer bad")
613            .with_status(401)
614            .with_body(r#"{"message": "Bad credentials"}"#)
615            .create();
616        assert!(!validate_token(&server.url(), "bad").unwrap());
617        bad.assert();
618    }
619
620    #[test]
621    fn repository_slug_prefers_full_name() {
622        let repo = Repository {
623            id: 1,
624            full_name: Some("o/r".into()),
625            name: Some("ignored".into()),
626            html_url: None,
627        };
628        assert_eq!(repo.slug(), Some("o/r"));
629        // Event payloads only ship `name`.
630        let evt_repo = Repository {
631            id: 1,
632            full_name: None,
633            name: Some("o/r".into()),
634            html_url: None,
635        };
636        assert_eq!(evt_repo.slug(), Some("o/r"));
637    }
638}