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
10pub 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 },
389 {
390 "browser_download_url": "https://example.com/other"
391 }
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 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}