1use super::cache;
7use super::provider_trait::{ContextProvider, ProviderParams};
8use super::{ProviderItem, ProviderResult};
9
10const DEFAULT_PER_PAGE: usize = 20;
11const CACHE_TTL_SECS: u64 = 120;
12
13#[derive(Debug, Clone)]
18pub struct GitHubConfig {
19 pub token: String,
20 pub owner: Option<String>,
21 pub repo: Option<String>,
22 pub api_base: String,
23}
24
25impl GitHubConfig {
26 pub fn from_env() -> Result<Self, String> {
27 let token = std::env::var("GITHUB_TOKEN")
28 .or_else(|_| std::env::var("GH_TOKEN"))
29 .or_else(|_| std::env::var("LEAN_CTX_GITHUB_TOKEN"))
30 .map_err(|_| {
31 "No GitHub token found. Set GITHUB_TOKEN or LEAN_CTX_GITHUB_TOKEN.".to_string()
32 })?;
33
34 let api_base = std::env::var("GITHUB_API_URL")
35 .unwrap_or_else(|_| "https://api.github.com".to_string());
36
37 let (owner, repo) = detect_owner_repo();
38
39 Ok(Self {
40 token,
41 owner,
42 repo,
43 api_base,
44 })
45 }
46
47 pub fn repo_slug(&self) -> Option<String> {
48 match (&self.owner, &self.repo) {
49 (Some(o), Some(r)) => Some(format!("{o}/{r}")),
50 _ => None,
51 }
52 }
53
54 fn api_url(&self, endpoint: &str) -> String {
55 format!("{}{endpoint}", self.api_base)
56 }
57}
58
59fn detect_owner_repo() -> (Option<String>, Option<String>) {
60 if let Ok(full) = std::env::var("GITHUB_REPOSITORY") {
61 if let Some((owner, repo)) = full.split_once('/') {
62 return (Some(owner.to_string()), Some(repo.to_string()));
63 }
64 }
65 if let (Ok(o), Ok(r)) = (
66 std::env::var("GITHUB_REPOSITORY_OWNER"),
67 std::env::var("GITHUB_REPO"),
68 ) {
69 return (Some(o), Some(r));
70 }
71
72 for remote in &["origin", "github", "upstream"] {
73 let output = match std::process::Command::new("git")
74 .args(["remote", "get-url", remote])
75 .output()
76 {
77 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
78 _ => continue,
79 };
80 let result = parse_github_remote(&output);
81 if result.0.is_some() {
82 return result;
83 }
84 }
85 (None, None)
86}
87
88fn parse_github_remote(url: &str) -> (Option<String>, Option<String>) {
89 if let Some(rest) = url.strip_prefix("git@github.com:") {
91 let clean = rest.trim_end_matches(".git");
92 if let Some((owner, repo)) = clean.split_once('/') {
93 return (Some(owner.to_string()), Some(repo.to_string()));
94 }
95 }
96
97 if let Some(rest) = url
99 .strip_prefix("https://github.com/")
100 .or_else(|| url.strip_prefix("http://github.com/"))
101 {
102 let clean = rest.trim_end_matches(".git");
103 if let Some((owner, repo)) = clean.split_once('/') {
104 return (Some(owner.to_string()), Some(repo.to_string()));
105 }
106 }
107
108 (None, None)
109}
110
111fn api_get(config: &GitHubConfig, endpoint: &str) -> Result<String, String> {
116 let url = config.api_url(endpoint);
117 let res = ureq::get(&url)
118 .header("Authorization", &format!("Bearer {}", config.token))
119 .header("Accept", "application/vnd.github+json")
120 .header("X-GitHub-Api-Version", "2022-11-28")
121 .call()
122 .map_err(|e| format!("GitHub API error: {e}"))?;
123
124 if res.status() != 200 {
125 return Err(format!("GitHub API returned status {}", res.status()));
126 }
127
128 res.into_body()
129 .read_to_string()
130 .map_err(|e| format!("Failed to read response: {e}"))
131}
132
133pub fn list_issues(
138 config: &GitHubConfig,
139 state: Option<&str>,
140 limit: Option<usize>,
141) -> Result<ProviderResult, String> {
142 let slug = config
143 .repo_slug()
144 .ok_or("No GitHub repo configured. Set GITHUB_REPOSITORY or configure git remote.")?;
145
146 let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
147 let state_param = state.unwrap_or("open");
148
149 let endpoint = format!(
150 "/repos/{slug}/issues?per_page={per_page}&state={state_param}&sort=updated&direction=desc"
151 );
152
153 let cache_key = format!("github:issues:{slug}:{state_param}:{per_page}");
154 if let Some(cached) = cache::get_cached(&cache_key) {
155 if let Ok(result) = serde_json::from_str::<ProviderResult>(&cached) {
156 return Ok(result);
157 }
158 }
159
160 let body = api_get(config, &endpoint)?;
161 let items: Vec<serde_json::Value> =
162 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
163
164 let result = ProviderResult {
165 provider: "github".to_string(),
166 resource_type: "issues".to_string(),
167 total_count: None,
168 truncated: items.len() >= per_page,
169 items: items
170 .iter()
171 .filter(|v| v.get("pull_request").is_none_or(serde_json::Value::is_null))
172 .map(parse_issue)
173 .collect(),
174 };
175
176 if let Ok(json) = serde_json::to_string(&result) {
177 cache::set_cached(&cache_key, &json, CACHE_TTL_SECS);
178 }
179 Ok(result)
180}
181
182pub fn list_pull_requests(
183 config: &GitHubConfig,
184 state: Option<&str>,
185 limit: Option<usize>,
186) -> Result<ProviderResult, String> {
187 let slug = config.repo_slug().ok_or("No GitHub repo configured.")?;
188
189 let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
190 let state_param = state.unwrap_or("open");
191
192 let endpoint = format!(
193 "/repos/{slug}/pulls?per_page={per_page}&state={state_param}&sort=updated&direction=desc"
194 );
195
196 let cache_key = format!("github:prs:{slug}:{state_param}:{per_page}");
197 if let Some(cached) = cache::get_cached(&cache_key) {
198 if let Ok(result) = serde_json::from_str::<ProviderResult>(&cached) {
199 return Ok(result);
200 }
201 }
202
203 let body = api_get(config, &endpoint)?;
204 let items: Vec<serde_json::Value> =
205 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
206
207 let result = ProviderResult {
208 provider: "github".to_string(),
209 resource_type: "pull_requests".to_string(),
210 total_count: None,
211 truncated: items.len() >= per_page,
212 items: items.iter().map(parse_pr).collect(),
213 };
214
215 if let Ok(json) = serde_json::to_string(&result) {
216 cache::set_cached(&cache_key, &json, CACHE_TTL_SECS);
217 }
218 Ok(result)
219}
220
221pub fn list_actions(
222 config: &GitHubConfig,
223 status: Option<&str>,
224 limit: Option<usize>,
225) -> Result<ProviderResult, String> {
226 let slug = config.repo_slug().ok_or("No GitHub repo configured.")?;
227
228 let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(30);
229 let mut endpoint = format!("/repos/{slug}/actions/runs?per_page={per_page}");
230 if let Some(s) = status {
231 endpoint.push_str(&format!("&status={s}"));
232 }
233
234 let body = api_get(config, &endpoint)?;
235 let json: serde_json::Value =
236 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
237
238 let runs = json["workflow_runs"]
239 .as_array()
240 .cloned()
241 .unwrap_or_default();
242
243 Ok(ProviderResult {
244 provider: "github".to_string(),
245 resource_type: "actions".to_string(),
246 total_count: json["total_count"].as_u64().map(|n| n as usize),
247 truncated: runs.len() >= per_page,
248 items: runs
249 .iter()
250 .map(|r| ProviderItem {
251 id: r["id"].as_u64().unwrap_or(0).to_string(),
252 title: r["name"].as_str().unwrap_or("").to_string(),
253 state: r["conclusion"]
254 .as_str()
255 .or_else(|| r["status"].as_str())
256 .map(String::from),
257 author: r["actor"]["login"].as_str().map(String::from),
258 created_at: r["created_at"].as_str().map(String::from),
259 updated_at: r["updated_at"].as_str().map(String::from),
260 url: r["html_url"].as_str().map(String::from),
261 labels: Vec::new(),
262 body: None,
263 ..Default::default()
264 })
265 .collect(),
266 })
267}
268
269fn parse_issue(v: &serde_json::Value) -> ProviderItem {
274 ProviderItem {
275 id: v["number"].as_u64().unwrap_or(0).to_string(),
276 title: v["title"].as_str().unwrap_or("").to_string(),
277 state: v["state"].as_str().map(String::from),
278 author: v["user"]["login"].as_str().map(String::from),
279 created_at: v["created_at"].as_str().map(String::from),
280 updated_at: v["updated_at"].as_str().map(String::from),
281 url: v["html_url"].as_str().map(String::from),
282 labels: v["labels"]
283 .as_array()
284 .map(|arr| {
285 arr.iter()
286 .filter_map(|l| l["name"].as_str().map(String::from))
287 .collect()
288 })
289 .unwrap_or_default(),
290 body: v["body"].as_str().map(String::from),
291 ..Default::default()
292 }
293}
294
295fn parse_pr(v: &serde_json::Value) -> ProviderItem {
296 ProviderItem {
297 id: v["number"].as_u64().unwrap_or(0).to_string(),
298 title: v["title"].as_str().unwrap_or("").to_string(),
299 state: v["state"].as_str().map(String::from),
300 author: v["user"]["login"].as_str().map(String::from),
301 created_at: v["created_at"].as_str().map(String::from),
302 updated_at: v["updated_at"].as_str().map(String::from),
303 url: v["html_url"].as_str().map(String::from),
304 labels: v["labels"]
305 .as_array()
306 .map(|arr| {
307 arr.iter()
308 .filter_map(|l| l["name"].as_str().map(String::from))
309 .collect()
310 })
311 .unwrap_or_default(),
312 body: v["body"].as_str().map(String::from),
313 ..Default::default()
314 }
315}
316
317pub struct GitHubProvider {
322 config: Result<GitHubConfig, String>,
323}
324
325impl GitHubProvider {
326 pub fn new() -> Self {
327 Self {
328 config: GitHubConfig::from_env(),
329 }
330 }
331}
332
333impl Default for GitHubProvider {
334 fn default() -> Self {
335 Self::new()
336 }
337}
338
339impl ContextProvider for GitHubProvider {
340 fn id(&self) -> &'static str {
341 "github"
342 }
343
344 fn display_name(&self) -> &'static str {
345 "GitHub"
346 }
347
348 fn supported_actions(&self) -> &[&str] {
349 &["issues", "pull_requests", "actions"]
350 }
351
352 fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
353 let config = self.config.as_ref().map_err(std::clone::Clone::clone)?;
354 match action {
355 "issues" => list_issues(config, params.state.as_deref(), params.limit),
356 "pull_requests" => list_pull_requests(config, params.state.as_deref(), params.limit),
357 "actions" => list_actions(config, params.state.as_deref(), params.limit),
358 _ => Err(format!("Unknown GitHub action: {action}")),
359 }
360 }
361
362 fn cache_ttl_secs(&self) -> u64 {
363 CACHE_TTL_SECS
364 }
365
366 fn is_available(&self) -> bool {
367 self.config.is_ok()
368 }
369}
370
371#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn parse_github_remote_ssh() {
381 let (owner, repo) = parse_github_remote("git@github.com:yvgude/lean-ctx.git");
382 assert_eq!(owner.as_deref(), Some("yvgude"));
383 assert_eq!(repo.as_deref(), Some("lean-ctx"));
384 }
385
386 #[test]
387 fn parse_github_remote_https() {
388 let (owner, repo) = parse_github_remote("https://github.com/yvgude/lean-ctx.git");
389 assert_eq!(owner.as_deref(), Some("yvgude"));
390 assert_eq!(repo.as_deref(), Some("lean-ctx"));
391 }
392
393 #[test]
394 fn parse_github_remote_no_match() {
395 let (owner, repo) = parse_github_remote("git@gitlab.com:foo/bar.git");
396 assert!(owner.is_none());
397 assert!(repo.is_none());
398 }
399
400 #[test]
401 fn provider_unavailable_without_token() {
402 std::env::remove_var("GITHUB_TOKEN");
403 std::env::remove_var("GH_TOKEN");
404 std::env::remove_var("LEAN_CTX_GITHUB_TOKEN");
405 let provider = GitHubProvider::new();
406 assert!(!provider.is_available());
407 }
408
409 #[test]
410 fn provider_reports_correct_id_and_actions() {
411 let provider = GitHubProvider::new();
412 assert_eq!(provider.id(), "github");
413 assert_eq!(provider.display_name(), "GitHub");
414 assert!(provider.supported_actions().contains(&"issues"));
415 assert!(provider.supported_actions().contains(&"pull_requests"));
416 assert!(provider.supported_actions().contains(&"actions"));
417 }
418}