Skip to main content

update_kit/checker/sources/
github.rs

1use crate::errors::UpdateKitError;
2use crate::types::AssetInfo;
3use crate::utils::http::{fetch_with_timeout, FetchOptions as HttpFetchOptions};
4
5use super::{
6    FetchOptions, FetchVersionsOptions, VersionInfo, VersionListResult, VersionSource,
7    VersionSourceResult,
8};
9
10/// A version source backed by GitHub Releases.
11pub struct GitHubReleasesSource {
12    owner: String,
13    repo: String,
14    token: Option<String>,
15    api_base_url: String,
16}
17
18impl GitHubReleasesSource {
19    pub fn new(
20        owner: String,
21        repo: String,
22        token: Option<String>,
23        api_base_url: Option<String>,
24    ) -> Self {
25        Self {
26            owner,
27            repo,
28            token,
29            api_base_url: api_base_url
30                .unwrap_or_else(|| "https://api.github.com".to_string()),
31        }
32    }
33
34    fn build_headers(&self, etag: Option<&str>) -> Vec<(String, String)> {
35        let mut headers = vec![
36            (
37                "Accept".to_string(),
38                "application/vnd.github+json".to_string(),
39            ),
40            ("User-Agent".to_string(), "update-kit".to_string()),
41        ];
42        if let Some(token) = &self.token {
43            headers.push(("Authorization".to_string(), format!("Bearer {}", token)));
44        }
45        if let Some(etag) = etag {
46            headers.push(("If-None-Match".to_string(), etag.to_string()));
47        }
48        headers
49    }
50
51    fn parse_release(&self, json: &serde_json::Value) -> Option<VersionInfo> {
52        let tag = json.get("tag_name")?.as_str()?;
53        let version = tag.strip_prefix('v').unwrap_or(tag).to_string();
54
55        let release_url = json
56            .get("html_url")
57            .and_then(|v| v.as_str())
58            .map(String::from);
59
60        let release_notes = json.get("body").and_then(|v| v.as_str()).map(String::from);
61
62        let published_at = json
63            .get("published_at")
64            .and_then(|v| v.as_str())
65            .map(String::from);
66
67        let assets = json.get("assets").and_then(|v| v.as_array()).map(|arr| {
68            arr.iter()
69                .filter_map(|a| {
70                    let name = a.get("name")?.as_str()?.to_string();
71                    let url = a
72                        .get("browser_download_url")?
73                        .as_str()?
74                        .to_string();
75                    let size = a.get("size").and_then(|v| v.as_u64());
76                    Some(AssetInfo {
77                        name,
78                        url,
79                        size,
80                        checksum_url: None,
81                    })
82                })
83                .collect()
84        });
85
86        Some(VersionInfo {
87            version,
88            release_url,
89            release_notes,
90            assets,
91            published_at,
92        })
93    }
94}
95
96#[async_trait::async_trait]
97impl VersionSource for GitHubReleasesSource {
98    fn name(&self) -> &str {
99        "github"
100    }
101
102    async fn fetch_latest(&self, options: FetchOptions) -> VersionSourceResult {
103        let url = format!(
104            "{}/repos/{}/{}/releases/latest",
105            self.api_base_url, self.owner, self.repo
106        );
107
108        let headers = self.build_headers(options.etag.as_deref());
109
110        let response = match fetch_with_timeout(
111            &url,
112            Some(HttpFetchOptions {
113                timeout_ms: None,
114                headers: Some(headers),
115            }),
116        )
117        .await
118        {
119            Ok(r) => r,
120            Err(e) => {
121                return VersionSourceResult::Error {
122                    reason: e.to_string(),
123                    status: None,
124                }
125            }
126        };
127
128        let status = response.status().as_u16();
129
130        if status == 304 {
131            if let Some(etag) = options.etag {
132                return VersionSourceResult::NotModified { etag };
133            }
134        }
135
136        if !response.status().is_success() {
137            return VersionSourceResult::Error {
138                reason: format!("GitHub API returned status {}", status),
139                status: Some(status),
140            };
141        }
142
143        let etag = response
144            .headers()
145            .get("etag")
146            .and_then(|v| v.to_str().ok())
147            .map(String::from);
148
149        let json: serde_json::Value = match response.json().await {
150            Ok(j) => j,
151            Err(e) => {
152                return VersionSourceResult::Error {
153                    reason: format!("Failed to parse response: {}", e),
154                    status: Some(status),
155                }
156            }
157        };
158
159        match self.parse_release(&json) {
160            Some(info) => VersionSourceResult::Found { info, etag },
161            None => VersionSourceResult::Error {
162                reason: "Failed to parse release data".into(),
163                status: Some(status),
164            },
165        }
166    }
167
168    async fn fetch_versions(
169        &self,
170        options: FetchVersionsOptions,
171    ) -> Result<VersionListResult, UpdateKitError> {
172        let per_page = options.limit.unwrap_or(30).min(100);
173        let page = options
174            .cursor
175            .as_deref()
176            .and_then(|c| c.parse::<u32>().ok())
177            .unwrap_or(1);
178
179        let url = format!(
180            "{}/repos/{}/{}/releases?per_page={}&page={}",
181            self.api_base_url, self.owner, self.repo, per_page, page
182        );
183
184        let headers = self.build_headers(None);
185
186        let response = fetch_with_timeout(
187            &url,
188            Some(HttpFetchOptions {
189                timeout_ms: None,
190                headers: Some(headers),
191            }),
192        )
193        .await?;
194
195        if !response.status().is_success() {
196            return Ok(VersionListResult::Error {
197                reason: format!("GitHub API returned status {}", response.status().as_u16()),
198            });
199        }
200
201        let json: serde_json::Value = response.json().await?;
202
203        let versions = json
204            .as_array()
205            .map(|arr| {
206                arr.iter()
207                    .filter_map(|release| self.parse_release(release))
208                    .collect::<Vec<_>>()
209            })
210            .unwrap_or_default();
211
212        let has_more = versions.len() == per_page;
213        let next_cursor = if has_more {
214            Some((page + 1).to_string())
215        } else {
216            None
217        };
218
219        Ok(VersionListResult::Success {
220            versions,
221            next_cursor,
222            total_count: None,
223        })
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_source_name() {
233        let source = GitHubReleasesSource::new(
234            "owner".into(),
235            "repo".into(),
236            None,
237            None,
238        );
239        assert_eq!(source.name(), "github");
240    }
241
242    #[test]
243    fn test_custom_api_base_url() {
244        let source = GitHubReleasesSource::new(
245            "owner".into(),
246            "repo".into(),
247            None,
248            Some("https://github.example.com/api/v3".into()),
249        );
250        assert_eq!(source.api_base_url, "https://github.example.com/api/v3");
251    }
252
253    #[test]
254    fn test_parse_release() {
255        let source = GitHubReleasesSource::new(
256            "owner".into(),
257            "repo".into(),
258            None,
259            None,
260        );
261        let json = serde_json::json!({
262            "tag_name": "v1.2.3",
263            "html_url": "https://github.com/owner/repo/releases/tag/v1.2.3",
264            "body": "Release notes",
265            "published_at": "2024-01-01T00:00:00Z",
266            "assets": [
267                {
268                    "name": "app-linux-x64.tar.gz",
269                    "browser_download_url": "https://github.com/owner/repo/releases/download/v1.2.3/app-linux-x64.tar.gz",
270                    "size": 1024
271                }
272            ]
273        });
274
275        let info = source.parse_release(&json).unwrap();
276        assert_eq!(info.version, "1.2.3");
277        assert_eq!(
278            info.release_url,
279            Some("https://github.com/owner/repo/releases/tag/v1.2.3".into())
280        );
281        assert_eq!(info.release_notes, Some("Release notes".into()));
282        assert_eq!(info.assets.as_ref().unwrap().len(), 1);
283        assert_eq!(info.assets.as_ref().unwrap()[0].name, "app-linux-x64.tar.gz");
284    }
285
286    #[test]
287    fn test_parse_release_without_v_prefix() {
288        let source = GitHubReleasesSource::new(
289            "owner".into(),
290            "repo".into(),
291            None,
292            None,
293        );
294        let json = serde_json::json!({
295            "tag_name": "1.0.0",
296            "html_url": "https://github.com/owner/repo/releases/tag/1.0.0"
297        });
298
299        let info = source.parse_release(&json).unwrap();
300        assert_eq!(info.version, "1.0.0");
301    }
302
303    #[test]
304    fn parse_release_no_assets() {
305        let source = GitHubReleasesSource::new("owner".into(), "repo".into(), None, None);
306        let json = serde_json::json!({
307            "tag_name": "v1.0.0",
308            "html_url": "https://github.com/owner/repo/releases/tag/v1.0.0",
309            "body": "Notes",
310            "published_at": "2024-01-01T00:00:00Z"
311        });
312
313        let info = source.parse_release(&json).unwrap();
314        assert_eq!(info.version, "1.0.0");
315        assert!(info.assets.is_none());
316    }
317
318    #[test]
319    fn parse_release_no_body() {
320        let source = GitHubReleasesSource::new("owner".into(), "repo".into(), None, None);
321        let json = serde_json::json!({
322            "tag_name": "v1.0.0",
323            "html_url": "https://github.com/owner/repo/releases/tag/v1.0.0"
324        });
325
326        let info = source.parse_release(&json).unwrap();
327        assert_eq!(info.version, "1.0.0");
328        assert!(info.release_notes.is_none());
329    }
330
331    #[test]
332    fn parse_release_no_html_url() {
333        let source = GitHubReleasesSource::new("owner".into(), "repo".into(), None, None);
334        let json = serde_json::json!({
335            "tag_name": "v2.0.0",
336            "body": "Some notes"
337        });
338
339        let info = source.parse_release(&json).unwrap();
340        assert_eq!(info.version, "2.0.0");
341        assert!(info.release_url.is_none());
342        assert_eq!(info.release_notes, Some("Some notes".into()));
343    }
344
345    #[test]
346    fn parse_release_minimal() {
347        let source = GitHubReleasesSource::new("owner".into(), "repo".into(), None, None);
348        let json = serde_json::json!({
349            "tag_name": "v0.1.0"
350        });
351
352        let info = source.parse_release(&json).unwrap();
353        assert_eq!(info.version, "0.1.0");
354        assert!(info.release_url.is_none());
355        assert!(info.release_notes.is_none());
356        assert!(info.assets.is_none());
357        assert!(info.published_at.is_none());
358    }
359
360    #[test]
361    fn parse_release_empty_assets() {
362        let source = GitHubReleasesSource::new("owner".into(), "repo".into(), None, None);
363        let json = serde_json::json!({
364            "tag_name": "v1.0.0",
365            "assets": []
366        });
367
368        let info = source.parse_release(&json).unwrap();
369        assert_eq!(info.version, "1.0.0");
370        let assets = info.assets.unwrap();
371        assert!(assets.is_empty());
372    }
373
374    #[test]
375    fn parse_release_asset_missing_fields() {
376        let source = GitHubReleasesSource::new("owner".into(), "repo".into(), None, None);
377        let json = serde_json::json!({
378            "tag_name": "v1.0.0",
379            "assets": [
380                {
381                    "name": "app.tar.gz",
382                    "browser_download_url": "https://example.com/download",
383                    "size": 2048
384                },
385                {
386                    "name": "incomplete-asset"
387                    // missing browser_download_url — should be filtered out
388                },
389                {
390                    "browser_download_url": "https://example.com/other"
391                    // missing name — should be filtered out
392                }
393            ]
394        });
395
396        let info = source.parse_release(&json).unwrap();
397        let assets = info.assets.unwrap();
398        assert_eq!(assets.len(), 1);
399        assert_eq!(assets[0].name, "app.tar.gz");
400        assert_eq!(assets[0].url, "https://example.com/download");
401        assert_eq!(assets[0].size, Some(2048));
402    }
403
404    #[test]
405    fn parse_release_missing_tag_name_returns_none() {
406        let source = GitHubReleasesSource::new("owner".into(), "repo".into(), None, None);
407        let json = serde_json::json!({
408            "html_url": "https://github.com/owner/repo/releases/tag/v1.0.0",
409            "body": "Notes"
410        });
411
412        assert!(source.parse_release(&json).is_none());
413    }
414
415    #[test]
416    fn build_headers_without_token() {
417        let source = GitHubReleasesSource::new("owner".into(), "repo".into(), None, None);
418        let headers = source.build_headers(None);
419
420        assert_eq!(headers.len(), 2);
421        assert!(headers.iter().any(|(k, v)| k == "Accept" && v == "application/vnd.github+json"));
422        assert!(headers.iter().any(|(k, v)| k == "User-Agent" && v == "update-kit"));
423        assert!(!headers.iter().any(|(k, _)| k == "Authorization"));
424        assert!(!headers.iter().any(|(k, _)| k == "If-None-Match"));
425    }
426
427    #[test]
428    fn build_headers_with_token() {
429        let source = GitHubReleasesSource::new(
430            "owner".into(),
431            "repo".into(),
432            Some("test-token".into()),
433            None,
434        );
435        let headers = source.build_headers(None);
436
437        assert_eq!(headers.len(), 3);
438        assert!(headers.iter().any(|(k, v)| k == "Authorization" && v == "Bearer test-token"));
439    }
440
441    #[test]
442    fn build_headers_with_etag() {
443        let source = GitHubReleasesSource::new("owner".into(), "repo".into(), None, None);
444        let headers = source.build_headers(Some("\"etag-value\""));
445
446        assert_eq!(headers.len(), 3);
447        assert!(headers.iter().any(|(k, v)| k == "If-None-Match" && v == "\"etag-value\""));
448    }
449
450    #[test]
451    fn build_headers_with_token_and_etag() {
452        let source = GitHubReleasesSource::new(
453            "owner".into(),
454            "repo".into(),
455            Some("my-token".into()),
456            None,
457        );
458        let headers = source.build_headers(Some("\"abc\""));
459
460        assert_eq!(headers.len(), 4);
461        assert!(headers.iter().any(|(k, v)| k == "Authorization" && v == "Bearer my-token"));
462        assert!(headers.iter().any(|(k, v)| k == "If-None-Match" && v == "\"abc\""));
463    }
464
465    #[tokio::test]
466    async fn fetch_latest_unreachable_returns_error() {
467        // Uses an unreachable HTTPS URL to test the error path without needing mockito.
468        // fetch_with_timeout enforces HTTPS, so we use an HTTPS URL that won't connect.
469        let source = GitHubReleasesSource::new(
470            "owner".into(),
471            "repo".into(),
472            None,
473            Some("https://localhost:1".into()),
474        );
475        let result = source.fetch_latest(FetchOptions::default()).await;
476
477        match result {
478            VersionSourceResult::Error { reason, status } => {
479                assert!(!reason.is_empty());
480                assert!(status.is_none());
481            }
482            other => panic!("Expected Error, got: {other:?}"),
483        }
484    }
485
486    #[tokio::test]
487    async fn fetch_versions_unreachable_returns_error() {
488        let source = GitHubReleasesSource::new(
489            "owner".into(),
490            "repo".into(),
491            None,
492            Some("https://localhost:1".into()),
493        );
494        let result = source
495            .fetch_versions(FetchVersionsOptions {
496                limit: None,
497                cursor: None,
498            })
499            .await;
500
501        assert!(result.is_err());
502    }
503}