1use anyhow::{Context, bail};
8use serde::Deserialize;
9
10const API_BASE: &str = "https://api.github.com";
11const VERSION: &str = env!("CARGO_PKG_VERSION");
12
13#[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, #[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
95pub(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
139pub 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#[derive(Debug, Deserialize)]
152struct SearchReposResponse {
153 items: Vec<GitHubRepo>,
154}
155
156pub 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
175pub 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
195pub 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#[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
232pub 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
267pub 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}