Skip to main content

gitprint/
github.rs

1//! GitHub REST API v3 client.
2//!
3//! All functions operate on public data and work without authentication.
4//! Set `GITHUB_TOKEN` in the environment for higher rate limits (5 000/hr vs 60/hr)
5//! and access to private repositories.
6
7use anyhow::{Context, bail};
8use serde::Deserialize;
9
10const API_BASE: &str = "https://api.github.com";
11const VERSION: &str = env!("CARGO_PKG_VERSION");
12
13// ── Response types ─────────────────────────────────────────────────────────────
14
15#[derive(Debug, Deserialize)]
16pub struct GitHubUser {
17    pub login: String,
18    pub name: Option<String>,
19    pub bio: Option<String>,
20    pub location: Option<String>,
21    pub company: Option<String>,
22    pub blog: Option<String>,
23    pub email: Option<String>,
24    pub public_repos: u64,
25    pub followers: u64,
26    pub following: u64,
27    pub created_at: String,
28    pub html_url: String,
29}
30
31#[derive(Debug, Deserialize, Clone)]
32pub struct GitHubRepo {
33    pub name: String,
34    pub full_name: String,
35    pub html_url: String,
36    pub description: Option<String>,
37    pub language: Option<String>,
38    pub stargazers_count: u64,
39    pub forks_count: u64,
40    pub pushed_at: Option<String>,
41    pub updated_at: Option<String>,
42    pub fork: bool,
43    #[serde(default)]
44    pub open_issues_count: u64,
45    #[serde(default)]
46    pub size: u64, // in KB
47    #[serde(default)]
48    pub created_at: Option<String>,
49}
50
51#[derive(Debug, Deserialize)]
52pub struct GitHubEvent {
53    #[serde(rename = "type")]
54    pub kind: String,
55    pub repo: EventRepo,
56    pub payload: serde_json::Value,
57    pub created_at: String,
58}
59
60#[derive(Debug, Deserialize)]
61pub struct EventRepo {
62    pub name: String,
63}
64
65#[derive(Debug, Deserialize)]
66pub struct CommitDetail {
67    pub sha: String,
68    pub html_url: String,
69    pub commit: CommitInfo,
70    #[serde(default)]
71    pub files: Vec<CommitFile>,
72}
73
74#[derive(Debug, Deserialize)]
75pub struct CommitInfo {
76    pub message: String,
77    pub author: CommitAuthor,
78}
79
80#[derive(Debug, Deserialize)]
81pub struct CommitAuthor {
82    pub name: String,
83    pub date: String,
84}
85
86#[derive(Debug, Deserialize)]
87pub struct CommitFile {
88    pub filename: String,
89    pub status: String,
90    pub additions: u64,
91    pub deletions: u64,
92    pub patch: Option<String>,
93}
94
95// ── Client helpers ──────────────────────────────────────────────────────────────
96
97pub(crate) fn build_client() -> anyhow::Result<reqwest::Client> {
98    reqwest::Client::builder()
99        .user_agent(format!("gitprint/{VERSION}"))
100        .build()
101        .context("failed to build HTTP client")
102}
103
104fn auth_header(token: Option<&str>) -> Option<String> {
105    token.map(|t| format!("Bearer {t}"))
106}
107
108pub(crate) async fn get_json<T: for<'de> Deserialize<'de>>(
109    client: &reqwest::Client,
110    url: &str,
111    token: Option<&str>,
112) -> anyhow::Result<T> {
113    let mut req = client
114        .get(url)
115        .header("Accept", "application/vnd.github+json");
116    if let Some(auth) = auth_header(token) {
117        req = req.header("Authorization", auth);
118    }
119    let resp = req.send().await.with_context(|| format!("GET {url}"))?;
120    let status = resp.status();
121    if status == reqwest::StatusCode::NOT_FOUND {
122        bail!("not found: {url}");
123    }
124    if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::TOO_MANY_REQUESTS
125    {
126        bail!(
127            "GitHub API rate limit exceeded. Set GITHUB_TOKEN to increase limits:\n  \
128             export GITHUB_TOKEN=ghp_your_token_here"
129        );
130    }
131    if !status.is_success() {
132        bail!("GitHub API error {status}: {url}");
133    }
134    resp.json::<T>()
135        .await
136        .with_context(|| format!("parsing response from {url}"))
137}
138
139// ── Public API functions ────────────────────────────────────────────────────────
140
141/// Fetch a user's public profile.
142pub async fn get_user(username: &str, token: Option<&str>) -> anyhow::Result<GitHubUser> {
143    let client = build_client()?;
144    let url = format!("{API_BASE}/users/{username}");
145    get_json::<GitHubUser>(&client, &url, token)
146        .await
147        .with_context(|| format!("fetching user '{username}'"))
148}
149
150/// Wrapper for the GitHub search/repositories response.
151#[derive(Debug, Deserialize)]
152struct SearchReposResponse {
153    items: Vec<GitHubRepo>,
154}
155
156/// Fetch a user's top starred repositories via the Search API.
157///
158/// Uses `/search/repositories` because `/users/{u}/repos` does not support `sort=stars`.
159pub async fn get_user_starred_repos(
160    username: &str,
161    limit: usize,
162    token: Option<&str>,
163) -> anyhow::Result<Vec<GitHubRepo>> {
164    let client = build_client()?;
165    let per_page = limit.min(100);
166    let url = format!(
167        "{API_BASE}/search/repositories?q=user:{username}+fork:false&sort=stars&order=desc&per_page={per_page}"
168    );
169    get_json::<SearchReposResponse>(&client, &url, token)
170        .await
171        .map(|r| r.items)
172        .with_context(|| format!("fetching starred repos for '{username}'"))
173}
174
175/// Fetch a user's own repositories sorted by `sort` (`pushed` or `updated`).
176///
177/// `limit` is capped at 100 (GitHub's maximum per-page).
178/// Only returns repos the user owns directly (`type=owner`).
179pub async fn get_user_repos(
180    username: &str,
181    sort: &str,
182    limit: usize,
183    token: Option<&str>,
184) -> anyhow::Result<Vec<GitHubRepo>> {
185    let client = build_client()?;
186    let per_page = limit.min(100);
187    let url = format!(
188        "{API_BASE}/users/{username}/repos?type=owner&sort={sort}&direction=desc&per_page={per_page}"
189    );
190    get_json::<Vec<GitHubRepo>>(&client, &url, token)
191        .await
192        .with_context(|| format!("fetching repos for '{username}' (sort={sort})"))
193}
194
195/// Fetch a user's recent public events (max 100, GitHub returns up to 90 days).
196pub async fn get_user_events(
197    username: &str,
198    limit: usize,
199    token: Option<&str>,
200) -> anyhow::Result<Vec<GitHubEvent>> {
201    let client = build_client()?;
202    let per_page = limit.min(100);
203    let url = format!("{API_BASE}/users/{username}/events/public?per_page={per_page}");
204    get_json::<Vec<GitHubEvent>>(&client, &url, token)
205        .await
206        .with_context(|| format!("fetching events for '{username}'"))
207}
208
209/// Response envelope for the commits search endpoint.
210#[derive(Deserialize)]
211struct CommitSearchResponse {
212    items: Vec<CommitSearchItem>,
213}
214
215#[derive(Deserialize)]
216struct CommitSearchItem {
217    sha: String,
218    repository: CommitSearchRepo,
219    commit: CommitSearchMeta,
220}
221
222#[derive(Deserialize)]
223struct CommitSearchRepo {
224    full_name: String,
225}
226
227#[derive(Deserialize)]
228struct CommitSearchMeta {
229    message: String,
230}
231
232/// Search for the `limit` most recent public commits authored by `username` across all repos.
233///
234/// Uses `GET /search/commits?q=author:{username}` (stable since GitHub API v3 2022+).
235/// Returns `(owner/repo, sha, first-line-of-message)` tuples, newest first.
236/// Returns an empty Vec on error so the caller can degrade gracefully.
237pub async fn search_user_commits(
238    username: &str,
239    limit: usize,
240    token: Option<&str>,
241) -> anyhow::Result<Vec<(String, String, String)>> {
242    let client = build_client()?;
243    let per_page = limit.min(100);
244    let url = format!(
245        "{API_BASE}/search/commits?q=author:{username}&sort=committer-date&order=desc&per_page={per_page}"
246    );
247    get_json::<CommitSearchResponse>(&client, &url, token)
248        .await
249        .map(|r| {
250            r.items
251                .into_iter()
252                .map(|item| {
253                    let msg = item
254                        .commit
255                        .message
256                        .lines()
257                        .next()
258                        .unwrap_or(&item.commit.message)
259                        .to_string();
260                    (item.repository.full_name, item.sha, msg)
261                })
262                .collect()
263        })
264        .with_context(|| format!("searching commits by '{username}'"))
265}
266
267/// Fetch a single commit with its file patches.
268pub async fn get_commit_detail(
269    owner_repo: &str,
270    sha: &str,
271    token: Option<&str>,
272) -> anyhow::Result<CommitDetail> {
273    let client = build_client()?;
274    let url = format!("{API_BASE}/repos/{owner_repo}/commits/{sha}");
275    get_json::<CommitDetail>(&client, &url, token)
276        .await
277        .with_context(|| format!("fetching commit {sha} in {owner_repo}"))
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use httpmock::prelude::*;
284
285    #[test]
286    fn auth_header_some() {
287        assert_eq!(auth_header(Some("tok")), Some("Bearer tok".to_string()));
288    }
289
290    #[test]
291    fn auth_header_none() {
292        assert_eq!(auth_header(None), None);
293    }
294
295    #[tokio::test]
296    async fn parses_user_response() -> anyhow::Result<()> {
297        let server = MockServer::start();
298        server.mock(|when, then| {
299            when.method(GET).path("/users/alice");
300            then.status(200).json_body(serde_json::json!({
301                "login": "alice", "name": "Alice", "bio": null, "location": null,
302                "company": null, "blog": null, "email": null, "public_repos": 10,
303                "followers": 42, "following": 5, "created_at": "2020-01-01T00:00:00Z",
304                "html_url": "https://github.com/alice"
305            }));
306        });
307
308        let client = build_client()?;
309        let user: GitHubUser =
310            get_json(&client, &format!("{}/users/alice", server.base_url()), None).await?;
311        assert_eq!(user.login, "alice");
312        assert_eq!(user.public_repos, 10);
313        assert_eq!(user.followers, 42);
314        Ok(())
315    }
316
317    #[tokio::test]
318    async fn parses_repo_list_response() -> anyhow::Result<()> {
319        let server = MockServer::start();
320        server.mock(|when, then| {
321            when.method(GET).path("/users/alice/repos");
322            then.status(200).json_body(serde_json::json!([{
323                "name": "myrepo", "full_name": "alice/myrepo",
324                "html_url": "https://github.com/alice/myrepo", "description": null,
325                "language": "Rust", "stargazers_count": 7, "forks_count": 1,
326                "pushed_at": "2024-03-01T00:00:00Z", "updated_at": "2024-03-01T00:00:00Z",
327                "fork": false
328            }]));
329        });
330
331        let client = build_client()?;
332        let repos: Vec<GitHubRepo> = get_json(
333            &client,
334            &format!("{}/users/alice/repos", server.base_url()),
335            None,
336        )
337        .await?;
338        assert_eq!(repos.len(), 1);
339        assert_eq!(repos[0].name, "myrepo");
340        assert_eq!(repos[0].stargazers_count, 7);
341        Ok(())
342    }
343
344    #[tokio::test]
345    async fn parses_event_list_response() -> anyhow::Result<()> {
346        let server = MockServer::start();
347        server.mock(|when, then| {
348            when.method(GET).path("/users/alice/events/public");
349            then.status(200).json_body(serde_json::json!([{
350                "type": "PushEvent",
351                "repo": { "name": "alice/myrepo" },
352                "payload": { "ref": "refs/heads/main", "commits": [] },
353                "created_at": "2024-03-01T12:00:00Z"
354            }]));
355        });
356
357        let client = build_client()?;
358        let events: Vec<GitHubEvent> = get_json(
359            &client,
360            &format!("{}/users/alice/events/public", server.base_url()),
361            None,
362        )
363        .await?;
364        assert_eq!(events.len(), 1);
365        assert_eq!(events[0].kind, "PushEvent");
366        assert_eq!(events[0].repo.name, "alice/myrepo");
367        Ok(())
368    }
369
370    #[tokio::test]
371    async fn parses_commit_detail_response() -> anyhow::Result<()> {
372        let server = MockServer::start();
373        let sha = "abc1234abc1234abc1234abc1234abc1234abc1234";
374        server.mock(|when, then| {
375            when.method(GET)
376                .path(format!("/repos/alice/myrepo/commits/{sha}"));
377            then.status(200).json_body(serde_json::json!({
378                "sha": sha,
379                "html_url": "https://github.com/alice/myrepo/commit/abc1234",
380                "commit": {
381                    "message": "fix: handle edge case",
382                    "author": { "name": "Alice", "date": "2024-03-01T12:00:00Z" }
383                },
384                "files": [{
385                    "filename": "src/lib.rs", "status": "modified",
386                    "additions": 5, "deletions": 2, "patch": "+added line\n-removed line"
387                }]
388            }));
389        });
390
391        let client = build_client()?;
392        let detail: CommitDetail = get_json(
393            &client,
394            &format!("{}/repos/alice/myrepo/commits/{sha}", server.base_url()),
395            None,
396        )
397        .await?;
398        assert_eq!(detail.sha, sha);
399        assert_eq!(detail.commit.message, "fix: handle edge case");
400        assert_eq!(detail.files[0].additions, 5);
401        Ok(())
402    }
403
404    #[tokio::test]
405    async fn rate_limit_error_is_surfaced() {
406        let server = MockServer::start();
407        server.mock(|when, then| {
408            when.method(GET).path("/users/alice");
409            then.status(403);
410        });
411
412        let client = build_client().unwrap();
413        let err =
414            get_json::<GitHubUser>(&client, &format!("{}/users/alice", server.base_url()), None)
415                .await
416                .unwrap_err();
417        assert!(err.to_string().contains("rate limit"), "got: {err}");
418    }
419}