crates/flotilla-core/src/providers/github_api.rs
Line | Count | Source |
1 | | use std::{ |
2 | | collections::HashMap, |
3 | | path::Path, |
4 | | sync::{Arc, Mutex}, |
5 | | }; |
6 | | |
7 | | use async_trait::async_trait; |
8 | | |
9 | | use crate::providers::{run_output, ChannelLabel, CommandRunner}; |
10 | | |
11 | | const MAX_PER_PAGE: usize = 100; |
12 | | |
13 | | /// Clamp a limit to GitHub's max per_page (100), warning if truncated. |
14 | 12 | pub fn clamp_per_page(limit: usize) -> usize { |
15 | 12 | if limit > MAX_PER_PAGE { |
16 | 0 | tracing::warn!(requested = %limit, max = MAX_PER_PAGE, "GitHub API per_page capped"); |
17 | 0 | MAX_PER_PAGE |
18 | | } else { |
19 | 12 | limit |
20 | | } |
21 | 12 | } |
22 | | |
23 | | /// Parsed response from `gh api --include`. |
24 | | pub struct GhApiResponse { |
25 | | pub status: u16, |
26 | | pub etag: Option<String>, |
27 | | pub body: String, |
28 | | pub has_next_page: bool, |
29 | | pub total_count: Option<u32>, |
30 | | } |
31 | | |
32 | | /// Parse the combined headers+body output from `gh api --include`. |
33 | 10 | pub fn parse_gh_api_response(raw: &str) -> GhApiResponse { |
34 | | // Split on first blank line (headers end with \r\n\r\n or \n\n) |
35 | 10 | let (header_section, body) = if let Some(pos) = raw.find("\r\n\r\n") { |
36 | 10 | (&raw[..pos], raw[pos + 4..].trim().to_string()) |
37 | 0 | } else if let Some(pos) = raw.find("\n\n") { |
38 | 0 | (&raw[..pos], raw[pos + 2..].trim().to_string()) |
39 | | } else { |
40 | 0 | (raw, String::new()) |
41 | | }; |
42 | | |
43 | 10 | let mut status = 0u16; |
44 | 10 | let mut etag = None; |
45 | 10 | let mut has_next_page = false; |
46 | | |
47 | 23 | for (i, line) in header_section10 .lines10 ().enumerate10 () { |
48 | 23 | if i == 0 { |
49 | | // "HTTP/2.0 200 OK" or "HTTP/1.1 304 Not Modified" |
50 | 10 | if let Some(code_str) = line.split_whitespace().nth(1) { |
51 | 10 | status = code_str.parse().unwrap_or(0); |
52 | 10 | }0 |
53 | 13 | } else if line.len() >= 6 && line[..5].eq_ignore_ascii_case("etag:") { |
54 | 9 | etag = Some(line[5..].trim().to_string()); |
55 | 9 | } else if line.len() >= 64 && line[..5]4 .eq_ignore_ascii_case4 ("link:"4 ) { |
56 | 2 | has_next_page = line.contains("rel=\"next\""); |
57 | 2 | } |
58 | | } |
59 | | |
60 | 10 | GhApiResponse { status, etag, body, has_next_page, total_count: None } |
61 | 10 | } |
62 | | |
63 | | #[async_trait] |
64 | | pub trait GhApi: Send + Sync { |
65 | | async fn get(&self, endpoint: &str, repo_root: &Path, label: &ChannelLabel) -> Result<String, String>; |
66 | | async fn get_with_headers(&self, endpoint: &str, repo_root: &Path, label: &ChannelLabel) -> Result<GhApiResponse, String>; |
67 | | } |
68 | | |
69 | | /// Cache entry: ETag + the JSON response body from last 200. |
70 | | struct CacheEntry { |
71 | | etag: String, |
72 | | body: String, |
73 | | has_next_page: bool, |
74 | | } |
75 | | |
76 | | /// Client that wraps `gh api` with ETag-based conditional request caching. |
77 | | pub struct GhApiClient { |
78 | | cache: Mutex<HashMap<String, CacheEntry>>, |
79 | | runner: Arc<dyn CommandRunner>, |
80 | | } |
81 | | |
82 | | impl GhApiClient { |
83 | 3 | pub fn new(runner: Arc<dyn CommandRunner>) -> Self { |
84 | 3 | Self { cache: Mutex::new(HashMap::new()), runner } |
85 | 3 | } |
86 | | } |
87 | | |
88 | | #[async_trait] |
89 | | impl GhApi for GhApiClient { |
90 | | /// Fetch a GitHub API endpoint, using cached ETag for conditional requests. |
91 | | /// Returns the JSON body (from cache on 304, fresh on 200). |
92 | 0 | async fn get(&self, endpoint: &str, repo_root: &Path, label: &ChannelLabel) -> Result<String, String> { |
93 | | self.get_with_headers(endpoint, repo_root, label).await.map(|r| r.body) |
94 | 0 | } |
95 | | |
96 | 0 | async fn get_with_headers(&self, endpoint: &str, repo_root: &Path, _label: &ChannelLabel) -> Result<GhApiResponse, String> { |
97 | | // Build args |
98 | | let cached_etag = { |
99 | 0 | let cache = self.cache.lock().unwrap_or_else(|p| p.into_inner()); |
100 | 0 | cache.get(endpoint).map(|e| e.etag.clone()) |
101 | | }; |
102 | | |
103 | | let mut args = vec!["api".to_string(), "--include".to_string(), endpoint.to_string()]; |
104 | | if let Some(ref etag) = cached_etag { |
105 | | args.push("-H".to_string()); |
106 | | args.push(format!("If-None-Match: {}", etag)); |
107 | | } |
108 | | |
109 | 0 | let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); |
110 | | |
111 | | let output = run_output!(self.runner, "gh", &args_refs, repo_root)?; |
112 | | |
113 | | // Always parse stdout — gh api --include writes headers even on 304 |
114 | | let parsed = parse_gh_api_response(&output.stdout); |
115 | | |
116 | | if parsed.status == 304 { |
117 | | // Serve from cache |
118 | 0 | let cache = self.cache.lock().unwrap_or_else(|p| p.into_inner()); |
119 | | if let Some(entry) = cache.get(endpoint) { |
120 | | return Ok(GhApiResponse { |
121 | | status: 304, |
122 | | etag: Some(entry.etag.clone()), |
123 | | body: entry.body.clone(), |
124 | | has_next_page: entry.has_next_page, |
125 | | total_count: None, |
126 | | }); |
127 | | } |
128 | | return Err("304 but no cached response".to_string()); |
129 | | } |
130 | | |
131 | | if !output.success { |
132 | | return Err(output.stderr); |
133 | | } |
134 | | |
135 | | if let Some(ref etag) = parsed.etag { |
136 | 0 | let mut cache = self.cache.lock().unwrap_or_else(|p| p.into_inner()); |
137 | | cache.insert(endpoint.to_string(), CacheEntry { |
138 | | etag: etag.clone(), |
139 | | body: parsed.body.clone(), |
140 | | has_next_page: parsed.has_next_page, |
141 | | }); |
142 | | } |
143 | | |
144 | | Ok(parsed) |
145 | 0 | } |
146 | | } |
147 | | |
148 | | #[cfg(test)] |
149 | | mod tests { |
150 | | use super::*; |
151 | | |
152 | | #[test] |
153 | 1 | fn parse_200_response_extracts_etag_and_body() { |
154 | 1 | let raw = "HTTP/2.0 200 OK\r\nEtag: W/\"abc123\"\r\nContent-Type: application/json\r\n\r\n[{\"number\":1}]"; |
155 | 1 | let result = parse_gh_api_response(raw); |
156 | 1 | assert_eq!(result.etag, Some("W/\"abc123\"".to_string())); |
157 | 1 | assert_eq!(result.body, "[{\"number\":1}]"); |
158 | 1 | assert_eq!(result.status, 200); |
159 | 1 | } |
160 | | |
161 | | #[test] |
162 | 1 | fn parse_304_response_has_no_body() { |
163 | 1 | let raw = "HTTP/2.0 304 Not Modified\r\nEtag: \"abc123\"\r\n\r\n"; |
164 | 1 | let result = parse_gh_api_response(raw); |
165 | 1 | assert_eq!(result.etag, Some("\"abc123\"".to_string())); |
166 | 1 | assert_eq!(result.body, ""); |
167 | 1 | assert_eq!(result.status, 304); |
168 | 1 | } |
169 | | |
170 | | #[test] |
171 | 1 | fn parse_etag_case_insensitive() { |
172 | | // GitHub sends "Etag:" but HTTP spec allows any casing |
173 | 4 | for header in ["Etag: \"x\"", 1 "etag: \"x\""1 , "ETag: \"x\"", "ETAG: \"x\""] { |
174 | 4 | let raw = format!("HTTP/2.0 200 OK\r\n{}\r\n\r\n{{}}", header); |
175 | 4 | let result = parse_gh_api_response(&raw); |
176 | 4 | assert_eq!(result.etag, Some("\"x\"".to_string()), "failed for: {}", header); |
177 | | } |
178 | 1 | } |
179 | | |
180 | | #[test] |
181 | 1 | fn parse_response_without_etag() { |
182 | 1 | let raw = "HTTP/2.0 200 OK\r\nContent-Type: text/plain\r\n\r\nhello"; |
183 | 1 | let result = parse_gh_api_response(raw); |
184 | 1 | assert_eq!(result.etag, None); |
185 | 1 | assert_eq!(result.body, "hello"); |
186 | 1 | } |
187 | | |
188 | | #[test] |
189 | 1 | fn parse_link_header_has_next() { |
190 | 1 | let raw = "HTTP/2.0 200 OK\r\nLink: <https://api.github.com/repos/foo/bar/issues?page=2>; rel=\"next\", <https://api.github.com/repos/foo/bar/issues?page=5>; rel=\"last\"\r\nEtag: \"abc\"\r\n\r\n[{\"number\":1}]"; |
191 | 1 | let result = parse_gh_api_response(raw); |
192 | 1 | assert!(result.has_next_page); |
193 | 1 | assert_eq!(result.total_count, None); |
194 | 1 | } |
195 | | |
196 | | #[test] |
197 | 1 | fn parse_link_header_no_next() { |
198 | 1 | let raw = "HTTP/2.0 200 OK\r\nLink: <https://api.github.com/repos/foo/bar/issues?page=3>; rel=\"prev\"\r\nEtag: \"abc\"\r\n\r\n[]"; |
199 | 1 | let result = parse_gh_api_response(raw); |
200 | 1 | assert!(!result.has_next_page); |
201 | 1 | } |
202 | | |
203 | | #[test] |
204 | 1 | fn parse_no_link_header() { |
205 | 1 | let raw = "HTTP/2.0 200 OK\r\nEtag: \"abc\"\r\n\r\n[]"; |
206 | 1 | let result = parse_gh_api_response(raw); |
207 | 1 | assert!(!result.has_next_page); |
208 | 1 | } |
209 | | } |