1use super::provider_trait::{ContextProvider, ProviderParams};
9use super::{ProviderItem, ProviderResult};
10
11const DEFAULT_PER_PAGE: usize = 20;
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 = 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 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 endpoint = list_endpoint(&slug, "issues", per_page, state.unwrap_or("open"));
146
147 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
166fn 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
198fn 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 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
268fn 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
316pub 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 #[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 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
422pub(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", ¶ms).unwrap().items[0].title,
510 "canary"
511 );
512 server.respond(404, r#"{"message":"Not Found"}"#);
513 assert!(provider.execute("issues", ¶ms).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", ¶ms).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", ¶ms).unwrap();
544 assert!(provider.reuse_binding("actions", ¶ms).is_none());
545
546 assert_eq!(
547 provider
548 .reacquire_item("issues", ¶ms, "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 server.respond(
560 200,
561 r#"{"number":5,"title":"t","pull_request":{"url":"u"}}"#,
562 );
563 assert!(provider.reacquire_item("issues", ¶ms, "5").is_err());
564 server.respond(404, r#"{"message":"Not Found"}"#);
566 assert!(provider.reacquire_item("issues", ¶ms, "5").is_err());
567 let before = server.count();
568 for bad in ["0", "05", "5/../6", "x"] {
569 assert!(
570 provider.reacquire_item("issues", ¶ms, bad).is_err(),
571 "{bad}"
572 );
573 }
574 assert_eq!(server.count(), before);
575 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", ¶ms),
582 Some(binding.clone())
583 );
584 }
585 }
586}