upstream_rs/providers/github/
github_adapter.rs1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use std::path::Path;
4
5use crate::models::common::Version;
6use crate::models::provider::{Asset, Release, RepositorySearchFilters, RepositorySearchResult};
7use crate::providers::release_provider::ReleaseProvider;
8
9use super::github_client::GithubClient;
10use super::github_dtos::{GithubAssetDto, GithubReleaseDto, GithubRepositorySearchItemDto};
11
12#[derive(Debug, Clone)]
13pub struct GithubAdapter {
14 client: GithubClient,
15}
16
17impl GithubAdapter {
18 pub fn new(client: GithubClient) -> Self {
19 Self { client }
20 }
21
22 pub async fn download_asset<F>(
23 &self,
24 asset: &Asset,
25 destination_path: &Path,
26 dl_callback: &mut Option<F>,
27 ) -> Result<()>
28 where
29 F: FnMut(u64, u64),
30 {
31 self.client
32 .download_file(&asset.download_url, destination_path, dl_callback)
33 .await
34 }
35
36 pub async fn get_release_by_tag(&self, slug: &str, tag: &str) -> Result<Release> {
37 let dto = self.client.get_release_by_tag(slug, tag).await?;
38 Ok(self.convert_release(dto))
39 }
40
41 pub async fn get_latest_release(&self, slug: &str) -> Result<Release> {
42 let dto = self.client.get_latest_release(slug).await?;
43 Ok(self.convert_release(dto))
44 }
45
46 pub async fn get_releases(
47 &self,
48 slug: &str,
49 per_page: Option<u32>,
50 max_total: Option<u32>,
51 ) -> Result<Vec<Release>> {
52 let dtos = self.client.get_releases(slug, per_page, max_total).await?;
53 Ok(dtos
54 .into_iter()
55 .map(|dto| self.convert_release(dto))
56 .collect())
57 }
58
59 pub async fn get_releases_newer_than(
60 &self,
61 slug: &str,
62 from_version: &Version,
63 per_page: Option<u32>,
64 ) -> Result<Vec<Release>> {
65 let per_page = per_page.unwrap_or(30);
66 let mut page = 1;
67 let mut releases = Vec::new();
68
69 loop {
70 let batch = self.client.get_releases_page(slug, per_page, page).await?;
71 if batch.is_empty() {
72 break;
73 }
74
75 let partial_page = batch.len() < per_page as usize;
76 let mut reached_from_version = false;
77 for dto in batch {
78 let parsed_version = Version::from_tag(&dto.tag_name).ok();
79 let release = self.convert_release(dto);
80 if parsed_version
81 .as_ref()
82 .is_some_and(|version| version <= from_version)
83 {
84 reached_from_version = true;
85 continue;
86 }
87 releases.push(release);
88 }
89
90 if reached_from_version || partial_page {
91 break;
92 }
93
94 page += 1;
95 }
96
97 Ok(releases)
98 }
99
100 pub async fn get_branch_head_sha(&self, slug: &str, branch: &str) -> Result<String> {
101 self.client.get_branch_head_sha(slug, branch).await
102 }
103
104 pub async fn search_repositories(
105 &self,
106 query: &str,
107 limit: Option<u32>,
108 filters: &RepositorySearchFilters,
109 ) -> Result<Vec<RepositorySearchResult>> {
110 let dto = self
111 .client
112 .search_repositories(query, limit, filters)
113 .await?;
114 Ok(dto
115 .items
116 .into_iter()
117 .map(Self::convert_search_result)
118 .collect())
119 }
120
121 fn convert_asset(dto: GithubAssetDto) -> Asset {
122 let created_at = Self::parse_timestamp(&dto.created_at);
123 Asset::new(
124 dto.browser_download_url,
125 dto.id as u64,
126 dto.name,
127 dto.size as u64,
128 created_at,
129 )
130 }
131
132 fn convert_release(&self, dto: GithubReleaseDto) -> Release {
133 let assets: Vec<Asset> = dto.assets.into_iter().map(Self::convert_asset).collect();
134 let version =
135 Version::from_tag(&dto.tag_name).unwrap_or_else(|_| Version::new(0, 0, 0, false));
136 Release {
137 id: dto.id as u64,
138 tag: dto.tag_name,
139 name: dto.name,
140 body: dto.body,
141 is_draft: dto.draft,
142 is_prerelease: dto.prerelease,
143 published_at: Self::parse_timestamp(&dto.published_at),
144 assets,
145 version,
146 }
147 }
148
149 fn convert_search_result(dto: GithubRepositorySearchItemDto) -> RepositorySearchResult {
150 RepositorySearchResult {
151 repo_slug: dto.full_name,
152 display_name: dto.name,
153 description: dto.description,
154 stars: dto.stargazers_count,
155 language: dto.language,
156 updated_at: Self::parse_timestamp(&dto.updated_at),
157 }
158 }
159
160 fn parse_timestamp(raw: &str) -> DateTime<Utc> {
161 if raw.trim().is_empty() {
162 return DateTime::<Utc>::MIN_UTC;
163 }
164 raw.parse::<DateTime<Utc>>()
165 .unwrap_or(DateTime::<Utc>::MIN_UTC)
166 }
167}
168
169#[async_trait::async_trait(?Send)]
170impl ReleaseProvider for GithubAdapter {
171 async fn get_latest_release(&self, slug: &str) -> Result<Release> {
172 GithubAdapter::get_latest_release(self, slug).await
173 }
174
175 async fn get_releases(
176 &self,
177 slug: &str,
178 per_page: Option<u32>,
179 max_total: Option<u32>,
180 ) -> Result<Vec<Release>> {
181 GithubAdapter::get_releases(self, slug, per_page, max_total).await
182 }
183
184 async fn get_releases_newer_than(
185 &self,
186 slug: &str,
187 from_version: &Version,
188 per_page: Option<u32>,
189 ) -> Result<Vec<Release>> {
190 GithubAdapter::get_releases_newer_than(self, slug, from_version, per_page).await
191 }
192
193 async fn get_release_by_tag(&self, slug: &str, tag: &str) -> Result<Release> {
194 GithubAdapter::get_release_by_tag(self, slug, tag).await
195 }
196
197 async fn get_branch_head_sha(&self, slug: &str, branch: &str) -> Result<String> {
198 GithubAdapter::get_branch_head_sha(self, slug, branch).await
199 }
200
201 async fn search_repositories(
202 &self,
203 query: &str,
204 limit: Option<u32>,
205 filters: &RepositorySearchFilters,
206 ) -> Result<Vec<RepositorySearchResult>> {
207 GithubAdapter::search_repositories(self, query, limit, filters).await
208 }
209
210 async fn download_asset(
211 &self,
212 asset: &Asset,
213 destination_path: &Path,
214 dl_callback: Option<&mut (dyn FnMut(u64, u64) + '_)>,
215 ) -> Result<()> {
216 let mut forwarded = dl_callback;
217 GithubAdapter::download_asset(self, asset, destination_path, &mut forwarded).await
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::GithubAdapter;
224 use crate::providers::github::github_client::GithubClient;
225 use crate::providers::github::github_dtos::{
226 GithubAssetDto, GithubReleaseDto, GithubRepositorySearchItemDto,
227 };
228
229 #[test]
230 fn parse_timestamp_returns_min_for_invalid_or_empty_values() {
231 assert_eq!(
232 GithubAdapter::parse_timestamp(""),
233 chrono::DateTime::<chrono::Utc>::MIN_UTC
234 );
235 assert_eq!(
236 GithubAdapter::parse_timestamp("not-a-date"),
237 chrono::DateTime::<chrono::Utc>::MIN_UTC
238 );
239 }
240
241 #[test]
242 fn convert_release_maps_assets_and_version() {
243 let adapter =
244 GithubAdapter::new(GithubClient::new(None, Default::default()).expect("github client"));
245 let dto = GithubReleaseDto {
246 id: 12,
247 tag_name: "v2.3.4".to_string(),
248 name: "Release 2.3.4".to_string(),
249 body: "notes".to_string(),
250 prerelease: true,
251 draft: false,
252 published_at: "2026-02-21T00:00:00Z".to_string(),
253 assets: vec![GithubAssetDto {
254 id: 9,
255 name: "tool-linux-x86_64.tar.gz".to_string(),
256 browser_download_url: "https://example.invalid/tool-linux-x86_64.tar.gz"
257 .to_string(),
258 size: 123,
259 content_type: "application/gzip".to_string(),
260 created_at: "2026-02-20T00:00:00Z".to_string(),
261 }],
262 };
263
264 let release = adapter.convert_release(dto);
265 assert_eq!(release.id, 12);
266 assert_eq!(release.version.to_string(), "2.3.4");
267 assert!(release.is_prerelease);
268 assert_eq!(release.assets.len(), 1);
269 assert_eq!(release.assets[0].id, 9);
270 }
271
272 #[test]
273 fn convert_search_result_maps_fields() {
274 let dto = GithubRepositorySearchItemDto {
275 full_name: "BurntSushi/ripgrep".to_string(),
276 name: "ripgrep".to_string(),
277 description: "fast grep".to_string(),
278 stargazers_count: 123,
279 language: "Rust".to_string(),
280 updated_at: "2026-05-09T00:00:00Z".to_string(),
281 archived: false,
282 fork: false,
283 };
284
285 let result = GithubAdapter::convert_search_result(dto);
286 assert_eq!(result.repo_slug, "BurntSushi/ripgrep");
287 assert_eq!(result.display_name, "ripgrep");
288 assert_eq!(result.description, "fast grep");
289 assert_eq!(result.stars, 123);
290 assert_eq!(result.language, "Rust");
291 }
292
293 #[test]
294 fn convert_search_result_invalid_timestamp_uses_min() {
295 let dto = GithubRepositorySearchItemDto {
296 full_name: "owner/repo".to_string(),
297 name: "repo".to_string(),
298 description: String::new(),
299 stargazers_count: 0,
300 language: String::new(),
301 updated_at: "nope".to_string(),
302 archived: false,
303 fork: false,
304 };
305
306 let result = GithubAdapter::convert_search_result(dto);
307 assert_eq!(result.updated_at, chrono::DateTime::<chrono::Utc>::MIN_UTC);
308 }
309}