Coverage Report

Created: 2026-04-05 07:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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
}