Skip to main content

lean_ctx/core/providers/
github.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! GitHub context provider — issues, pull requests, actions.
4//!
5//! Follows the same pattern as `gitlab.rs` but targets the GitHub REST API v3.
6//! Implements `ContextProvider` for the registry.
7
8use super::provider_trait::{ContextProvider, ProviderParams};
9use super::{ProviderItem, ProviderResult};
10
11const DEFAULT_PER_PAGE: usize = 20;
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 = super::credentials::var("GITHUB_TOKEN")
28            .or_else(|_| super::credentials::var("GH_TOKEN"))
29            .or_else(|_| super::credentials::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 = super::credentials::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) = super::credentials::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        super::credentials::var("GITHUB_REPOSITORY_OWNER"),
67        super::credentials::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 endpoint = list_endpoint(&slug, "issues", per_page, state.unwrap_or("open"));
146
147    // No response cache: a list answer is the principal's current access, and
148    // a cached one would keep serving objects after access was revoked.
149    let body = api_get(config, &endpoint)?;
150    let items: Vec<serde_json::Value> =
151        serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
152
153    Ok(ProviderResult {
154        provider: "github".to_string(),
155        resource_type: "issues".to_string(),
156        total_count: None,
157        truncated: items.len() >= per_page,
158        items: items
159            .iter()
160            .filter(|v| v.get("pull_request").is_none_or(serde_json::Value::is_null))
161            .map(parse_issue)
162            .collect(),
163    })
164}
165
166/// A list endpoint whose filter value is one query value: it cannot add or
167/// replace a parameter, whatever the caller passes.
168fn list_endpoint(slug: &str, resource: &str, per_page: usize, state: &str) -> String {
169    format!(
170        "/repos/{slug}/{resource}?per_page={per_page}&state={}&sort=updated&direction=desc",
171        urlencoding::encode(state)
172    )
173}
174
175pub fn list_pull_requests(
176    config: &GitHubConfig,
177    state: Option<&str>,
178    limit: Option<usize>,
179) -> Result<ProviderResult, String> {
180    let slug = config.repo_slug().ok_or("No GitHub repo configured.")?;
181
182    let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(100);
183    let endpoint = list_endpoint(&slug, "pulls", per_page, state.unwrap_or("open"));
184
185    let body = api_get(config, &endpoint)?;
186    let items: Vec<serde_json::Value> =
187        serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
188
189    Ok(ProviderResult {
190        provider: "github".to_string(),
191        resource_type: "pull_requests".to_string(),
192        total_count: None,
193        truncated: items.len() >= per_page,
194        items: items.iter().map(parse_pr).collect(),
195    })
196}
197
198/// Fetch one issue or pull request now, with this config's credential. GitHub
199/// answers 404 for an object the credential may not read.
200fn show_item(config: &GitHubConfig, resource: &str, number: u64) -> Result<ProviderItem, String> {
201    let slug = config.repo_slug().ok_or("No GitHub repo configured.")?;
202    let (path, parse): (&str, fn(&serde_json::Value) -> ProviderItem) = match resource {
203        "issues" => ("issues", parse_issue),
204        "pull_requests" => ("pulls", parse_pr),
205        _ => return Err("unsupported stored GitHub resource".into()),
206    };
207    let body = api_get(config, &format!("/repos/{slug}/{path}/{number}"))?;
208    let value: serde_json::Value =
209        serde_json::from_str(&body).map_err(|_| "invalid GitHub item response")?;
210    // The issues endpoint also returns pull requests; an issue origin must not
211    // be satisfied by a pull request of the same number, or the reverse.
212    let is_pull =
213        value.get("pull_request").is_some_and(|v| !v.is_null()) || resource == "pull_requests";
214    if (resource == "issues") == is_pull {
215        return Err("stored GitHub resource changed kind".into());
216    }
217    Ok(parse(&value))
218}
219
220pub fn list_actions(
221    config: &GitHubConfig,
222    status: Option<&str>,
223    limit: Option<usize>,
224) -> Result<ProviderResult, String> {
225    let slug = config.repo_slug().ok_or("No GitHub repo configured.")?;
226
227    let per_page = limit.unwrap_or(DEFAULT_PER_PAGE).min(30);
228    let mut endpoint = format!("/repos/{slug}/actions/runs?per_page={per_page}");
229    if let Some(s) = status {
230        endpoint.push_str(&format!("&status={}", urlencoding::encode(s)));
231    }
232
233    let body = api_get(config, &endpoint)?;
234    let json: serde_json::Value =
235        serde_json::from_str(&body).map_err(|e| format!("JSON parse error: {e}"))?;
236
237    let runs = json["workflow_runs"]
238        .as_array()
239        .cloned()
240        .unwrap_or_default();
241
242    Ok(ProviderResult {
243        provider: "github".to_string(),
244        resource_type: "actions".to_string(),
245        total_count: json["total_count"].as_u64().map(|n| n as usize),
246        truncated: runs.len() >= per_page,
247        items: runs
248            .iter()
249            .map(|r| ProviderItem {
250                id: r["id"].as_u64().unwrap_or(0).to_string(),
251                title: r["name"].as_str().unwrap_or("").to_string(),
252                state: r["conclusion"]
253                    .as_str()
254                    .or_else(|| r["status"].as_str())
255                    .map(String::from),
256                author: r["actor"]["login"].as_str().map(String::from),
257                created_at: r["created_at"].as_str().map(String::from),
258                updated_at: r["updated_at"].as_str().map(String::from),
259                url: r["html_url"].as_str().map(String::from),
260                labels: Vec::new(),
261                body: None,
262                ..Default::default()
263            })
264            .collect(),
265    })
266}
267
268// ---------------------------------------------------------------------------
269// Parsers
270// ---------------------------------------------------------------------------
271
272fn parse_issue(v: &serde_json::Value) -> ProviderItem {
273    ProviderItem {
274        id: v["number"].as_u64().unwrap_or(0).to_string(),
275        title: v["title"].as_str().unwrap_or("").to_string(),
276        state: v["state"].as_str().map(String::from),
277        author: v["user"]["login"].as_str().map(String::from),
278        created_at: v["created_at"].as_str().map(String::from),
279        updated_at: v["updated_at"].as_str().map(String::from),
280        url: v["html_url"].as_str().map(String::from),
281        labels: v["labels"]
282            .as_array()
283            .map(|arr| {
284                arr.iter()
285                    .filter_map(|l| l["name"].as_str().map(String::from))
286                    .collect()
287            })
288            .unwrap_or_default(),
289        body: v["body"].as_str().map(String::from),
290        ..Default::default()
291    }
292}
293
294fn parse_pr(v: &serde_json::Value) -> ProviderItem {
295    ProviderItem {
296        id: v["number"].as_u64().unwrap_or(0).to_string(),
297        title: v["title"].as_str().unwrap_or("").to_string(),
298        state: v["state"].as_str().map(String::from),
299        author: v["user"]["login"].as_str().map(String::from),
300        created_at: v["created_at"].as_str().map(String::from),
301        updated_at: v["updated_at"].as_str().map(String::from),
302        url: v["html_url"].as_str().map(String::from),
303        labels: v["labels"]
304            .as_array()
305            .map(|arr| {
306                arr.iter()
307                    .filter_map(|l| l["name"].as_str().map(String::from))
308                    .collect()
309            })
310            .unwrap_or_default(),
311        body: v["body"].as_str().map(String::from),
312        ..Default::default()
313    }
314}
315
316// ---------------------------------------------------------------------------
317// ContextProvider trait impl
318// ---------------------------------------------------------------------------
319
320pub struct GitHubProvider {
321    config: Result<GitHubConfig, String>,
322}
323
324impl GitHubProvider {
325    pub fn new() -> Self {
326        Self {
327            config: GitHubConfig::from_env(),
328        }
329    }
330
331    /// Construct with an explicit config, bypassing env discovery. Used by the
332    /// hosted team server's managed connectors so each scheduled run carries its
333    /// own credential without mutating process-global env (which would race
334    /// across connectors).
335    #[must_use]
336    pub fn with_config(config: GitHubConfig) -> Self {
337        Self { config: Ok(config) }
338    }
339}
340
341impl Default for GitHubProvider {
342    fn default() -> Self {
343        Self::new()
344    }
345}
346
347impl ContextProvider for GitHubProvider {
348    fn id(&self) -> &'static str {
349        "github"
350    }
351
352    fn display_name(&self) -> &'static str {
353        "GitHub"
354    }
355
356    fn supported_actions(&self) -> &[&str] {
357        &["issues", "pull_requests", "actions"]
358    }
359
360    fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
361        let config = self.config.as_ref().map_err(std::clone::Clone::clone)?;
362        match action {
363            "issues" => list_issues(config, params.state.as_deref(), params.limit),
364            "pull_requests" => list_pull_requests(config, params.state.as_deref(), params.limit),
365            "actions" => list_actions(config, params.state.as_deref(), params.limit),
366            _ => Err(format!("Unknown GitHub action: {action}")),
367        }
368    }
369
370    fn cache_ttl_secs(&self) -> u64 {
371        0
372    }
373
374    fn descriptor(&self) -> super::provider_trait::ConnectorDescriptor {
375        descriptor()
376    }
377
378    /// The source a stored item came from: API origin, repository and the
379    /// credential that read it. Another token or repository is another source.
380    fn reuse_binding(&self, action: &str, _params: &ProviderParams) -> Option<String> {
381        if !matches!(action, "issues" | "pull_requests") {
382            return None;
383        }
384        let config = self.config.as_ref().ok()?;
385        super::provenance::digest(&serde_json::json!({
386            "version": 1, "provider": "github", "api_base": config.api_base,
387            "repository": config.repo_slug()?, "credential": config.token,
388        }))
389    }
390
391    fn reacquire_item(
392        &self,
393        action: &str,
394        params: &ProviderParams,
395        item_id: &str,
396    ) -> Result<ProviderResult, String> {
397        if self.reuse_binding(action, params).is_none() {
398            return Err("GitHub source is unavailable for item reauthorization".into());
399        }
400        let number: u64 = item_id
401            .parse()
402            .map_err(|_| "invalid stored GitHub item number")?;
403        if number == 0 || number.to_string() != item_id {
404            return Err("invalid stored GitHub item number".into());
405        }
406        let config = self.config.as_ref().map_err(Clone::clone)?;
407        let item = show_item(config, action, number)?;
408        Ok(ProviderResult {
409            provider: "github".into(),
410            resource_type: action.into(),
411            items: vec![item],
412            total_count: Some(1),
413            truncated: false,
414        })
415    }
416
417    fn is_available(&self) -> bool {
418        self.config.is_ok()
419    }
420}
421
422// ---------------------------------------------------------------------------
423// Tests
424// ---------------------------------------------------------------------------
425
426pub(crate) fn descriptor() -> super::provider_trait::ConnectorDescriptor {
427    use super::provider_trait::{CapabilityLevel as L, ConnectorDescriptor as C};
428    C {
429        principal_binding: "token (GITHUB_TOKEN / GH_TOKEN / LEAN_CTX_GITHUB_TOKEN), bound with API origin and repository",
430        search: L::Partial,
431        list: L::Supported,
432        get: L::Supported,
433        delta: L::Unsupported,
434        revalidate_acl: L::Supported,
435        revalidate_revision: L::Supported,
436        delete_event: L::Partial,
437        content_trust: C::UNTRUSTED,
438        write_surface: C::NO_WRITES,
439        failure_policy: C::FAIL_CLOSED,
440        notes: "search = state filter, no full text; issues and pull requests re-checked per item with the \
441                principal's token (404 = no longer accessible, kind checked); actions runs are list-only and \
442                never stored with an origin; no response cache",
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::{GitHubProvider, parse_github_remote};
449    use crate::core::providers::provider_trait::ContextProvider;
450
451    #[test]
452    fn parse_github_remote_ssh() {
453        let (owner, repo) = parse_github_remote("git@github.com:yvgude/lean-ctx.git");
454        assert_eq!(owner.as_deref(), Some("yvgude"));
455        assert_eq!(repo.as_deref(), Some("lean-ctx"));
456    }
457
458    #[test]
459    fn parse_github_remote_https() {
460        let (owner, repo) = parse_github_remote("https://github.com/yvgude/lean-ctx.git");
461        assert_eq!(owner.as_deref(), Some("yvgude"));
462        assert_eq!(repo.as_deref(), Some("lean-ctx"));
463    }
464
465    #[test]
466    fn parse_github_remote_no_match() {
467        let (owner, repo) = parse_github_remote("git@gitlab.com:foo/bar.git");
468        assert!(owner.is_none());
469        assert!(repo.is_none());
470    }
471
472    #[test]
473    fn provider_unavailable_without_token() {
474        let _env_lock = crate::core::data_dir::test_env_lock();
475        crate::test_env::remove_var("GITHUB_TOKEN");
476        crate::test_env::remove_var("GH_TOKEN");
477        crate::test_env::remove_var("LEAN_CTX_GITHUB_TOKEN");
478        let provider = GitHubProvider::new();
479        assert!(!provider.is_available());
480    }
481
482    #[test]
483    fn provider_reports_correct_id_and_actions() {
484        let provider = GitHubProvider::new();
485        assert_eq!(provider.id(), "github");
486        assert_eq!(provider.display_name(), "GitHub");
487        assert!(provider.supported_actions().contains(&"issues"));
488        assert!(provider.supported_actions().contains(&"pull_requests"));
489        assert!(provider.supported_actions().contains(&"actions"));
490    }
491
492    fn config(url: &str, token: &str, repo: &str) -> super::GitHubConfig {
493        super::GitHubConfig {
494            token: token.into(),
495            owner: Some("acme".into()),
496            repo: Some(repo.into()),
497            api_base: url.into(),
498        }
499    }
500
501    #[test]
502    fn a_revoked_list_is_asked_again_and_never_served_from_a_cache() {
503        use crate::core::providers::hardened_http::redirect_tests::Server;
504        let issue = r#"[{"number":3,"title":"canary","state":"open","updated_at":"t1"}]"#;
505        let server = Server::new(200, None, issue);
506        let provider = GitHubProvider::with_config(config(&server.url, "reader-token", "repo"));
507        let params = super::ProviderParams::default();
508        assert_eq!(
509            provider.execute("issues", &params).unwrap().items[0].title,
510            "canary"
511        );
512        server.respond(404, r#"{"message":"Not Found"}"#);
513        assert!(provider.execute("issues", &params).is_err());
514        assert_eq!(server.count(), 2);
515    }
516
517    #[test]
518    fn a_filter_value_stays_one_query_value() {
519        use crate::core::providers::hardened_http::redirect_tests::Server;
520        let server = Server::new(200, None, "[]");
521        let provider = GitHubProvider::with_config(config(&server.url, "reader-token", "repo"));
522        let params = super::ProviderParams {
523            state: Some("all&per_page=100&sort=created#x".into()),
524            ..super::ProviderParams::default()
525        };
526        provider.execute("pull_requests", &params).unwrap();
527        assert_eq!(
528            server.last_request_line().unwrap(),
529            "GET /repos/acme/repo/pulls?per_page=20&state=all%26per_page%3D100%26sort%3Dcreated%23x&sort=updated&direction=desc HTTP/1.1"
530        );
531    }
532
533    #[test]
534    fn an_item_is_reacquired_now_with_its_kind_and_credential() {
535        use crate::core::providers::hardened_http::redirect_tests::Server;
536        let server = Server::new(
537            200,
538            None,
539            r#"{"number":5,"title":"t","state":"open","updated_at":"t1"}"#,
540        );
541        let provider = GitHubProvider::with_config(config(&server.url, "reader-token", "repo"));
542        let params = super::ProviderParams::default();
543        let binding = provider.reuse_binding("issues", &params).unwrap();
544        assert!(provider.reuse_binding("actions", &params).is_none());
545
546        assert_eq!(
547            provider
548                .reacquire_item("issues", &params, "5")
549                .unwrap()
550                .items[0]
551                .id,
552            "5"
553        );
554        assert_eq!(
555            server.last_request_line().unwrap(),
556            "GET /repos/acme/repo/issues/5 HTTP/1.1"
557        );
558        // The issues endpoint answering with a pull request is not the stored issue.
559        server.respond(
560            200,
561            r#"{"number":5,"title":"t","pull_request":{"url":"u"}}"#,
562        );
563        assert!(provider.reacquire_item("issues", &params, "5").is_err());
564        // Lost access: GitHub answers 404.
565        server.respond(404, r#"{"message":"Not Found"}"#);
566        assert!(provider.reacquire_item("issues", &params, "5").is_err());
567        let before = server.count();
568        for bad in ["0", "05", "5/../6", "x"] {
569            assert!(
570                provider.reacquire_item("issues", &params, bad).is_err(),
571                "{bad}"
572            );
573        }
574        assert_eq!(server.count(), before);
575        // Another token or repository is another source.
576        for other in [
577            config(&server.url, "other-token", "repo"),
578            config(&server.url, "reader-token", "other"),
579        ] {
580            assert_ne!(
581                GitHubProvider::with_config(other).reuse_binding("issues", &params),
582                Some(binding.clone())
583            );
584        }
585    }
586}