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 && let Some((owner, repo)) = full.split_once('/')
62 {
63 return (Some(owner.to_string()), Some(repo.to_string()));
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 token = format!("Bearer {}", config.token);
118 super::hardened_http::provider_get_with_headers(
119 "github",
120 &url,
121 &[
122 ("Authorization", &token),
123 ("Accept", "application/vnd.github+json"),
124 ("X-GitHub-Api-Version", "2022-11-28"),
125 ],
126 )
127 .into_body()
128 .map_err(|e| format!("GitHub API error: {e}"))
129}
130
131pub fn list_issues(
136 config: &GitHubConfig,
137 state: Option<&str>,
138 limit: Option<usize>,
139) -> Result<ProviderResult, String> {
140 let slug = config
141 .repo_slug()
142 .ok_or("No GitHub repo configured. Set GITHUB_REPOSITORY or configure git remote.")?;
143
144 let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
145 let state_param = state.unwrap_or("open");
146
147 let endpoint = format!(
148 "/repos/{slug}/issues?per_page={per_page}&state={state_param}&sort=updated&direction=desc"
149 );
150
151 let cache_key = format!("github:issues:{slug}:{state_param}:{per_page}");
152 if let Some(cached) = cache::get_cached(&cache_key)
153 && let Ok(result) = serde_json::from_str::<ProviderResult>(&cached)
154 {
155 return Ok(result);
156 }
157
158 let body = api_get(config, &endpoint)?;
159 let items: Vec<serde_json::Value> =
160 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
161
162 let result = ProviderResult {
163 provider: "github".to_string(),
164 resource_type: "issues".to_string(),
165 total_count: None,
166 truncated: items.len() >= per_page,
167 items: items
168 .iter()
169 .filter(|v| v.get("pull_request").is_none_or(serde_json::Value::is_null))
170 .map(parse_issue)
171 .collect(),
172 };
173
174 if let Ok(json) = serde_json::to_string(&result) {
175 cache::set_cached(&cache_key, &json, CACHE_TTL_SECS);
176 }
177 Ok(result)
178}
179
180pub fn list_pull_requests(
181 config: &GitHubConfig,
182 state: Option<&str>,
183 limit: Option<usize>,
184) -> Result<ProviderResult, String> {
185 let slug = config.repo_slug().ok_or("No GitHub repo configured.")?;
186
187 let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
188 let state_param = state.unwrap_or("open");
189
190 let endpoint = format!(
191 "/repos/{slug}/pulls?per_page={per_page}&state={state_param}&sort=updated&direction=desc"
192 );
193
194 let cache_key = format!("github:prs:{slug}:{state_param}:{per_page}");
195 if let Some(cached) = cache::get_cached(&cache_key)
196 && let Ok(result) = serde_json::from_str::<ProviderResult>(&cached)
197 {
198 return Ok(result);
199 }
200
201 let body = api_get(config, &endpoint)?;
202 let items: Vec<serde_json::Value> =
203 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
204
205 let result = ProviderResult {
206 provider: "github".to_string(),
207 resource_type: "pull_requests".to_string(),
208 total_count: None,
209 truncated: items.len() >= per_page,
210 items: items.iter().map(parse_pr).collect(),
211 };
212
213 if let Ok(json) = serde_json::to_string(&result) {
214 cache::set_cached(&cache_key, &json, CACHE_TTL_SECS);
215 }
216 Ok(result)
217}
218
219pub fn list_actions(
220 config: &GitHubConfig,
221 status: Option<&str>,
222 limit: Option<usize>,
223) -> Result<ProviderResult, String> {
224 let slug = config.repo_slug().ok_or("No GitHub repo configured.")?;
225
226 let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(30);
227 let mut endpoint = format!("/repos/{slug}/actions/runs?per_page={per_page}");
228 if let Some(s) = status {
229 endpoint.push_str(&format!("&status={s}"));
230 }
231
232 let body = api_get(config, &endpoint)?;
233 let json: serde_json::Value =
234 serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
235
236 let runs = json["workflow_runs"]
237 .as_array()
238 .cloned()
239 .unwrap_or_default();
240
241 Ok(ProviderResult {
242 provider: "github".to_string(),
243 resource_type: "actions".to_string(),
244 total_count: json["total_count"].as_u64().map(|n| n as usize),
245 truncated: runs.len() >= per_page,
246 items: runs
247 .iter()
248 .map(|r| ProviderItem {
249 id: r["id"].as_u64().unwrap_or(0).to_string(),
250 title: r["name"].as_str().unwrap_or("").to_string(),
251 state: r["conclusion"]
252 .as_str()
253 .or_else(|| r["status"].as_str())
254 .map(String::from),
255 author: r["actor"]["login"].as_str().map(String::from),
256 created_at: r["created_at"].as_str().map(String::from),
257 updated_at: r["updated_at"].as_str().map(String::from),
258 url: r["html_url"].as_str().map(String::from),
259 labels: Vec::new(),
260 body: None,
261 ..Default::default()
262 })
263 .collect(),
264 })
265}
266
267fn parse_issue(v: &serde_json::Value) -> ProviderItem {
272 ProviderItem {
273 id: v["number"].as_u64().unwrap_or(0).to_string(),
274 title: v["title"].as_str().unwrap_or("").to_string(),
275 state: v["state"].as_str().map(String::from),
276 author: v["user"]["login"].as_str().map(String::from),
277 created_at: v["created_at"].as_str().map(String::from),
278 updated_at: v["updated_at"].as_str().map(String::from),
279 url: v["html_url"].as_str().map(String::from),
280 labels: v["labels"]
281 .as_array()
282 .map(|arr| {
283 arr.iter()
284 .filter_map(|l| l["name"].as_str().map(String::from))
285 .collect()
286 })
287 .unwrap_or_default(),
288 body: v["body"].as_str().map(String::from),
289 ..Default::default()
290 }
291}
292
293fn parse_pr(v: &serde_json::Value) -> ProviderItem {
294 ProviderItem {
295 id: v["number"].as_u64().unwrap_or(0).to_string(),
296 title: v["title"].as_str().unwrap_or("").to_string(),
297 state: v["state"].as_str().map(String::from),
298 author: v["user"]["login"].as_str().map(String::from),
299 created_at: v["created_at"].as_str().map(String::from),
300 updated_at: v["updated_at"].as_str().map(String::from),
301 url: v["html_url"].as_str().map(String::from),
302 labels: v["labels"]
303 .as_array()
304 .map(|arr| {
305 arr.iter()
306 .filter_map(|l| l["name"].as_str().map(String::from))
307 .collect()
308 })
309 .unwrap_or_default(),
310 body: v["body"].as_str().map(String::from),
311 ..Default::default()
312 }
313}
314
315pub struct GitHubProvider {
320 config: Result<GitHubConfig, String>,
321}
322
323impl GitHubProvider {
324 pub fn new() -> Self {
325 Self {
326 config: GitHubConfig::from_env(),
327 }
328 }
329
330 #[must_use]
335 pub fn with_config(config: GitHubConfig) -> Self {
336 Self { config: Ok(config) }
337 }
338}
339
340impl Default for GitHubProvider {
341 fn default() -> Self {
342 Self::new()
343 }
344}
345
346impl ContextProvider for GitHubProvider {
347 fn id(&self) -> &'static str {
348 "github"
349 }
350
351 fn display_name(&self) -> &'static str {
352 "GitHub"
353 }
354
355 fn supported_actions(&self) -> &[&str] {
356 &["issues", "pull_requests", "actions"]
357 }
358
359 fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
360 let config = self.config.as_ref().map_err(std::clone::Clone::clone)?;
361 match action {
362 "issues" => list_issues(config, params.state.as_deref(), params.limit),
363 "pull_requests" => list_pull_requests(config, params.state.as_deref(), params.limit),
364 "actions" => list_actions(config, params.state.as_deref(), params.limit),
365 _ => Err(format!("Unknown GitHub action: {action}")),
366 }
367 }
368
369 fn cache_ttl_secs(&self) -> u64 {
370 CACHE_TTL_SECS
371 }
372
373 fn is_available(&self) -> bool {
374 self.config.is_ok()
375 }
376}
377
378#[cfg(test)]
383mod tests {
384 use super::{GitHubProvider, parse_github_remote};
385 use crate::core::providers::provider_trait::ContextProvider;
386
387 #[test]
388 fn parse_github_remote_ssh() {
389 let (owner, repo) = parse_github_remote("git@github.com:yvgude/lean-ctx.git");
390 assert_eq!(owner.as_deref(), Some("yvgude"));
391 assert_eq!(repo.as_deref(), Some("lean-ctx"));
392 }
393
394 #[test]
395 fn parse_github_remote_https() {
396 let (owner, repo) = parse_github_remote("https://github.com/yvgude/lean-ctx.git");
397 assert_eq!(owner.as_deref(), Some("yvgude"));
398 assert_eq!(repo.as_deref(), Some("lean-ctx"));
399 }
400
401 #[test]
402 fn parse_github_remote_no_match() {
403 let (owner, repo) = parse_github_remote("git@gitlab.com:foo/bar.git");
404 assert!(owner.is_none());
405 assert!(repo.is_none());
406 }
407
408 #[test]
409 fn provider_unavailable_without_token() {
410 let _env_lock = crate::core::data_dir::test_env_lock();
411 crate::test_env::remove_var("GITHUB_TOKEN");
412 crate::test_env::remove_var("GH_TOKEN");
413 crate::test_env::remove_var("LEAN_CTX_GITHUB_TOKEN");
414 let provider = GitHubProvider::new();
415 assert!(!provider.is_available());
416 }
417
418 #[test]
419 fn provider_reports_correct_id_and_actions() {
420 let provider = GitHubProvider::new();
421 assert_eq!(provider.id(), "github");
422 assert_eq!(provider.display_name(), "GitHub");
423 assert!(provider.supported_actions().contains(&"issues"));
424 assert!(provider.supported_actions().contains(&"pull_requests"));
425 assert!(provider.supported_actions().contains(&"actions"));
426 }
427}