garbage_code_hunter/pr_title_hunter/
github.rs1use anyhow::{bail, Context, Result};
4use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
5use serde::Deserialize;
6
7use super::types::{PrEntry, PrSource};
8
9#[derive(Debug, Clone)]
11pub struct GitHubConfig {
12 pub repo: String,
14 pub state: String,
16 pub limit: usize,
18 pub token: Option<String>,
20 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#[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
49pub 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 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}