Skip to main content

garbage_code_hunter/pr_title_hunter/
github.rs

1//! GitHub API client for fetching PR titles from remote repositories.
2
3use anyhow::{bail, Context, Result};
4use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
5use serde::Deserialize;
6
7use super::types::{PrEntry, PrSource};
8
9/// Configuration for GitHub API access.
10#[derive(Debug, Clone)]
11pub struct GitHubConfig {
12    /// Repository in "owner/repo" format.
13    pub repo: String,
14    /// PR state filter: "open", "closed", or "all".
15    pub state: String,
16    /// Max PRs to fetch.
17    pub limit: usize,
18    /// Optional GitHub token for authentication.
19    pub token: Option<String>,
20    /// Optional author filter (login name).
21    pub author: Option<String>,
22}
23
24impl Default for GitHubConfig {
25    fn default() -> Self {
26        Self {
27            repo: String::new(),
28            state: "all".to_string(),
29            limit: 50,
30            token: None,
31            author: None,
32        }
33    }
34}
35
36/// GitHub PR response (subset of fields we need).
37#[derive(Debug, Deserialize)]
38struct GhPullRequest {
39    number: u64,
40    title: String,
41    user: GhUser,
42}
43
44#[derive(Debug, Deserialize)]
45struct GhUser {
46    login: String,
47}
48
49/// Fetch PRs from GitHub API and convert to PrEntry list.
50pub fn fetch_prs(config: &GitHubConfig) -> Result<Vec<PrEntry>> {
51    if config.repo.is_empty() || !config.repo.contains('/') {
52        bail!(
53            "Invalid repo format '{}', expected 'owner/repo'",
54            config.repo
55        );
56    }
57
58    let url = format!(
59        "https://api.github.com/repos/{}/pulls?state={}&per_page={}&sort=created&direction=desc",
60        config.repo,
61        config.state,
62        config.limit.min(100),
63    );
64
65    let mut headers = HeaderMap::new();
66    headers.insert(
67        ACCEPT,
68        HeaderValue::from_static("application/vnd.github+json"),
69    );
70    headers.insert(USER_AGENT, HeaderValue::from_static("garbage-code-hunter"));
71
72    if let Some(token) = &config.token {
73        let auth_value = format!("Bearer {}", token);
74        headers.insert(
75            AUTHORIZATION,
76            HeaderValue::from_str(&auth_value).context("Invalid GitHub token")?,
77        );
78    }
79
80    let client = reqwest::blocking::Client::builder()
81        .default_headers(headers)
82        .build()?;
83
84    let response = client
85        .get(&url)
86        .send()
87        .with_context(|| format!("Failed to fetch PRs from {}", config.repo))?;
88
89    if !response.status().is_success() {
90        let status = response.status();
91        let body = response.text().unwrap_or_default();
92        bail!("GitHub API error {}: {}", status, body);
93    }
94
95    let prs: Vec<GhPullRequest> = response
96        .json()
97        .context("Failed to parse GitHub API response")?;
98
99    let entries: Vec<PrEntry> = prs
100        .into_iter()
101        .filter(|pr| {
102            // Filter by author if specified
103            if let Some(author_filter) = &config.author {
104                pr.user.login.eq_ignore_ascii_case(author_filter)
105            } else {
106                true
107            }
108        })
109        .map(|pr| PrEntry {
110            id: pr.number.to_string(),
111            title: pr.title,
112            author: Some(pr.user.login),
113            source: PrSource::GitHub {
114                repo: config.repo.clone(),
115            },
116        })
117        .collect();
118
119    Ok(entries)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_github_config_default() {
128        let config = GitHubConfig::default();
129        assert_eq!(config.state, "all");
130        assert_eq!(config.limit, 50);
131        assert!(config.token.is_none());
132    }
133
134    #[test]
135    fn test_fetch_prs_empty_repo() {
136        let config = GitHubConfig {
137            repo: String::new(),
138            ..Default::default()
139        };
140        let result = fetch_prs(&config);
141        assert!(result.is_err());
142    }
143
144    #[test]
145    fn test_fetch_prs_invalid_repo() {
146        let config = GitHubConfig {
147            repo: "not-a-valid-repo".to_string(),
148            ..Default::default()
149        };
150        let result = fetch_prs(&config);
151        assert!(result.is_err());
152    }
153}