use anyhow::{Context, bail};
use serde::Deserialize;
const API_BASE: &str = "https://api.github.com";
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Deserialize)]
pub struct GitHubUser {
pub login: String,
pub name: Option<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub company: Option<String>,
pub blog: Option<String>,
pub email: Option<String>,
pub public_repos: u64,
pub followers: u64,
pub following: u64,
pub created_at: String,
pub html_url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct GitHubRepo {
pub name: String,
pub full_name: String,
pub html_url: String,
pub description: Option<String>,
pub language: Option<String>,
pub stargazers_count: u64,
pub forks_count: u64,
pub pushed_at: Option<String>,
pub updated_at: Option<String>,
pub fork: bool,
#[serde(default)]
pub open_issues_count: u64,
#[serde(default)]
pub size: u64, #[serde(default)]
pub created_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubEvent {
#[serde(rename = "type")]
pub kind: String,
pub repo: EventRepo,
pub payload: serde_json::Value,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
pub struct EventRepo {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct CommitDetail {
pub sha: String,
pub html_url: String,
pub commit: CommitInfo,
#[serde(default)]
pub files: Vec<CommitFile>,
}
#[derive(Debug, Deserialize)]
pub struct CommitInfo {
pub message: String,
pub author: CommitAuthor,
}
#[derive(Debug, Deserialize)]
pub struct CommitAuthor {
pub name: String,
pub date: String,
}
#[derive(Debug, Deserialize)]
pub struct CommitFile {
pub filename: String,
pub status: String,
pub additions: u64,
pub deletions: u64,
pub patch: Option<String>,
}
pub(crate) fn build_client() -> anyhow::Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(format!("gitprint/{VERSION}"))
.build()
.context("failed to build HTTP client")
}
fn auth_header(token: Option<&str>) -> Option<String> {
token.map(|t| format!("Bearer {t}"))
}
pub(crate) async fn get_json<T: for<'de> Deserialize<'de>>(
client: &reqwest::Client,
url: &str,
token: Option<&str>,
) -> anyhow::Result<T> {
let mut req = client
.get(url)
.header("Accept", "application/vnd.github+json");
if let Some(auth) = auth_header(token) {
req = req.header("Authorization", auth);
}
let resp = req.send().await.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
bail!("not found: {url}");
}
if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::TOO_MANY_REQUESTS
{
bail!(
"GitHub API rate limit exceeded. Set GITHUB_TOKEN to increase limits:\n \
export GITHUB_TOKEN=ghp_your_token_here"
);
}
if !status.is_success() {
bail!("GitHub API error {status}: {url}");
}
resp.json::<T>()
.await
.with_context(|| format!("parsing response from {url}"))
}
pub async fn get_user(username: &str, token: Option<&str>) -> anyhow::Result<GitHubUser> {
let client = build_client()?;
let url = format!("{API_BASE}/users/{username}");
get_json::<GitHubUser>(&client, &url, token)
.await
.with_context(|| format!("fetching user '{username}'"))
}
#[derive(Debug, Deserialize)]
struct SearchReposResponse {
items: Vec<GitHubRepo>,
}
pub async fn get_user_starred_repos(
username: &str,
limit: usize,
token: Option<&str>,
) -> anyhow::Result<Vec<GitHubRepo>> {
let client = build_client()?;
let per_page = limit.min(100);
let url = format!(
"{API_BASE}/search/repositories?q=user:{username}+fork:false&sort=stars&order=desc&per_page={per_page}"
);
get_json::<SearchReposResponse>(&client, &url, token)
.await
.map(|r| r.items)
.with_context(|| format!("fetching starred repos for '{username}'"))
}
pub async fn get_user_repos(
username: &str,
sort: &str,
limit: usize,
token: Option<&str>,
) -> anyhow::Result<Vec<GitHubRepo>> {
let client = build_client()?;
let per_page = limit.min(100);
let url = format!(
"{API_BASE}/users/{username}/repos?type=owner&sort={sort}&direction=desc&per_page={per_page}"
);
get_json::<Vec<GitHubRepo>>(&client, &url, token)
.await
.with_context(|| format!("fetching repos for '{username}' (sort={sort})"))
}
pub async fn get_user_events(
username: &str,
limit: usize,
token: Option<&str>,
) -> anyhow::Result<Vec<GitHubEvent>> {
let client = build_client()?;
let per_page = limit.min(100);
let url = format!("{API_BASE}/users/{username}/events/public?per_page={per_page}");
get_json::<Vec<GitHubEvent>>(&client, &url, token)
.await
.with_context(|| format!("fetching events for '{username}'"))
}
#[derive(Deserialize)]
struct CommitSearchResponse {
items: Vec<CommitSearchItem>,
}
#[derive(Deserialize)]
struct CommitSearchItem {
sha: String,
repository: CommitSearchRepo,
commit: CommitSearchMeta,
}
#[derive(Deserialize)]
struct CommitSearchRepo {
full_name: String,
}
#[derive(Deserialize)]
struct CommitSearchMeta {
message: String,
}
pub async fn search_user_commits(
username: &str,
limit: usize,
token: Option<&str>,
) -> anyhow::Result<Vec<(String, String, String)>> {
let client = build_client()?;
let per_page = limit.min(100);
let url = format!(
"{API_BASE}/search/commits?q=author:{username}&sort=committer-date&order=desc&per_page={per_page}"
);
get_json::<CommitSearchResponse>(&client, &url, token)
.await
.map(|r| {
r.items
.into_iter()
.map(|item| {
let msg = item
.commit
.message
.lines()
.next()
.unwrap_or(&item.commit.message)
.to_string();
(item.repository.full_name, item.sha, msg)
})
.collect()
})
.with_context(|| format!("searching commits by '{username}'"))
}
pub async fn get_commit_detail(
owner_repo: &str,
sha: &str,
token: Option<&str>,
) -> anyhow::Result<CommitDetail> {
let client = build_client()?;
let url = format!("{API_BASE}/repos/{owner_repo}/commits/{sha}");
get_json::<CommitDetail>(&client, &url, token)
.await
.with_context(|| format!("fetching commit {sha} in {owner_repo}"))
}
#[cfg(test)]
mod tests {
use super::*;
use httpmock::prelude::*;
#[test]
fn auth_header_some() {
assert_eq!(auth_header(Some("tok")), Some("Bearer tok".to_string()));
}
#[test]
fn auth_header_none() {
assert_eq!(auth_header(None), None);
}
#[tokio::test]
async fn parses_user_response() -> anyhow::Result<()> {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/users/alice");
then.status(200).json_body(serde_json::json!({
"login": "alice", "name": "Alice", "bio": null, "location": null,
"company": null, "blog": null, "email": null, "public_repos": 10,
"followers": 42, "following": 5, "created_at": "2020-01-01T00:00:00Z",
"html_url": "https://github.com/alice"
}));
});
let client = build_client()?;
let user: GitHubUser =
get_json(&client, &format!("{}/users/alice", server.base_url()), None).await?;
assert_eq!(user.login, "alice");
assert_eq!(user.public_repos, 10);
assert_eq!(user.followers, 42);
Ok(())
}
#[tokio::test]
async fn parses_repo_list_response() -> anyhow::Result<()> {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/users/alice/repos");
then.status(200).json_body(serde_json::json!([{
"name": "myrepo", "full_name": "alice/myrepo",
"html_url": "https://github.com/alice/myrepo", "description": null,
"language": "Rust", "stargazers_count": 7, "forks_count": 1,
"pushed_at": "2024-03-01T00:00:00Z", "updated_at": "2024-03-01T00:00:00Z",
"fork": false
}]));
});
let client = build_client()?;
let repos: Vec<GitHubRepo> = get_json(
&client,
&format!("{}/users/alice/repos", server.base_url()),
None,
)
.await?;
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "myrepo");
assert_eq!(repos[0].stargazers_count, 7);
Ok(())
}
#[tokio::test]
async fn parses_event_list_response() -> anyhow::Result<()> {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/users/alice/events/public");
then.status(200).json_body(serde_json::json!([{
"type": "PushEvent",
"repo": { "name": "alice/myrepo" },
"payload": { "ref": "refs/heads/main", "commits": [] },
"created_at": "2024-03-01T12:00:00Z"
}]));
});
let client = build_client()?;
let events: Vec<GitHubEvent> = get_json(
&client,
&format!("{}/users/alice/events/public", server.base_url()),
None,
)
.await?;
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, "PushEvent");
assert_eq!(events[0].repo.name, "alice/myrepo");
Ok(())
}
#[tokio::test]
async fn parses_commit_detail_response() -> anyhow::Result<()> {
let server = MockServer::start();
let sha = "abc1234abc1234abc1234abc1234abc1234abc1234";
server.mock(|when, then| {
when.method(GET)
.path(format!("/repos/alice/myrepo/commits/{sha}"));
then.status(200).json_body(serde_json::json!({
"sha": sha,
"html_url": "https://github.com/alice/myrepo/commit/abc1234",
"commit": {
"message": "fix: handle edge case",
"author": { "name": "Alice", "date": "2024-03-01T12:00:00Z" }
},
"files": [{
"filename": "src/lib.rs", "status": "modified",
"additions": 5, "deletions": 2, "patch": "+added line\n-removed line"
}]
}));
});
let client = build_client()?;
let detail: CommitDetail = get_json(
&client,
&format!("{}/repos/alice/myrepo/commits/{sha}", server.base_url()),
None,
)
.await?;
assert_eq!(detail.sha, sha);
assert_eq!(detail.commit.message, "fix: handle edge case");
assert_eq!(detail.files[0].additions, 5);
Ok(())
}
#[tokio::test]
async fn rate_limit_error_is_surfaced() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/users/alice");
then.status(403);
});
let client = build_client().unwrap();
let err =
get_json::<GitHubUser>(&client, &format!("{}/users/alice", server.base_url()), None)
.await
.unwrap_err();
assert!(err.to_string().contains("rate limit"), "got: {err}");
}
}