Skip to main content

lean_ctx/core/providers/
github.rs

1//! GitHub context provider — issues, pull requests, actions.
2//!
3//! Follows the same pattern as `gitlab.rs` but targets the GitHub REST API v3.
4//! Implements `ContextProvider` for the registry.
5
6use 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// ---------------------------------------------------------------------------
14// Config
15// ---------------------------------------------------------------------------
16
17#[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    // SSH: git@github.com:owner/repo.git
90    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    // HTTPS: https://github.com/owner/repo.git
98    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
111// ---------------------------------------------------------------------------
112// API calls
113// ---------------------------------------------------------------------------
114
115fn 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
131// ---------------------------------------------------------------------------
132// Resource handlers
133// ---------------------------------------------------------------------------
134
135pub 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
267// ---------------------------------------------------------------------------
268// Parsers
269// ---------------------------------------------------------------------------
270
271fn 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
315// ---------------------------------------------------------------------------
316// ContextProvider trait impl
317// ---------------------------------------------------------------------------
318
319pub 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    /// Construct with an explicit config, bypassing env discovery. Used by the
331    /// hosted team server's managed connectors so each scheduled run carries its
332    /// own credential without mutating process-global env (which would race
333    /// across connectors).
334    #[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// ---------------------------------------------------------------------------
379// Tests
380// ---------------------------------------------------------------------------
381
382#[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}