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 get_project_readme(&self, slug: &str) -> Result<String> {
105 self.client.get_project_readme(slug).await
106 }
107
108 pub async fn search_repositories(
109 &self,
110 query: &str,
111 limit: Option<u32>,
112 filters: &RepositorySearchFilters,
113 ) -> Result<Vec<RepositorySearchResult>> {
114 let dto = self
115 .client
116 .search_repositories(query, limit, filters)
117 .await?;
118 Ok(dto
119 .items
120 .into_iter()
121 .map(Self::convert_search_result)
122 .collect())
123 }
124
125 fn convert_asset(dto: GithubAssetDto) -> Asset {
126 let created_at = Self::parse_timestamp(&dto.created_at);
127 Asset::new(
128 dto.browser_download_url,
129 dto.id as u64,
130 dto.name,
131 dto.size as u64,
132 created_at,
133 )
134 }
135
136 fn convert_release(&self, dto: GithubReleaseDto) -> Release {
137 let assets: Vec<Asset> = dto.assets.into_iter().map(Self::convert_asset).collect();
138 let version =
139 Version::from_tag(&dto.tag_name).unwrap_or_else(|_| Version::new(0, 0, 0, false));
140 Release {
141 id: dto.id as u64,
142 tag: dto.tag_name,
143 name: dto.name,
144 body: dto.body,
145 is_draft: dto.draft,
146 is_prerelease: dto.prerelease,
147 published_at: Self::parse_timestamp(&dto.published_at),
148 assets,
149 version,
150 }
151 }
152
153 fn convert_search_result(dto: GithubRepositorySearchItemDto) -> RepositorySearchResult {
154 RepositorySearchResult {
155 repo_slug: dto.full_name,
156 display_name: dto.name,
157 description: dto.description,
158 stars: dto.stargazers_count,
159 language: dto.language,
160 updated_at: Self::parse_timestamp(&dto.updated_at),
161 }
162 }
163
164 fn parse_timestamp(raw: &str) -> DateTime<Utc> {
165 if raw.trim().is_empty() {
166 return DateTime::<Utc>::MIN_UTC;
167 }
168 raw.parse::<DateTime<Utc>>()
169 .unwrap_or(DateTime::<Utc>::MIN_UTC)
170 }
171}
172
173#[async_trait::async_trait(?Send)]
174impl ReleaseProvider for GithubAdapter {
175 async fn get_latest_release(&self, slug: &str) -> Result<Release> {
176 GithubAdapter::get_latest_release(self, slug).await
177 }
178
179 async fn get_releases(
180 &self,
181 slug: &str,
182 per_page: Option<u32>,
183 max_total: Option<u32>,
184 ) -> Result<Vec<Release>> {
185 GithubAdapter::get_releases(self, slug, per_page, max_total).await
186 }
187
188 async fn get_releases_newer_than(
189 &self,
190 slug: &str,
191 from_version: &Version,
192 per_page: Option<u32>,
193 ) -> Result<Vec<Release>> {
194 GithubAdapter::get_releases_newer_than(self, slug, from_version, per_page).await
195 }
196
197 async fn get_release_by_tag(&self, slug: &str, tag: &str) -> Result<Release> {
198 GithubAdapter::get_release_by_tag(self, slug, tag).await
199 }
200
201 async fn get_branch_head_sha(&self, slug: &str, branch: &str) -> Result<String> {
202 GithubAdapter::get_branch_head_sha(self, slug, branch).await
203 }
204
205 async fn get_project_readme(&self, slug: &str) -> Result<String> {
206 GithubAdapter::get_project_readme(self, slug).await
207 }
208
209 async fn search_repositories(
210 &self,
211 query: &str,
212 limit: Option<u32>,
213 filters: &RepositorySearchFilters,
214 ) -> Result<Vec<RepositorySearchResult>> {
215 GithubAdapter::search_repositories(self, query, limit, filters).await
216 }
217
218 async fn download_asset(
219 &self,
220 asset: &Asset,
221 destination_path: &Path,
222 dl_callback: Option<&mut (dyn FnMut(u64, u64) + '_)>,
223 ) -> Result<()> {
224 let mut forwarded = dl_callback;
225 GithubAdapter::download_asset(self, asset, destination_path, &mut forwarded).await
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::GithubAdapter;
232 use crate::providers::github::github_client::GithubClient;
233 use crate::providers::github::github_dtos::{
234 GithubAssetDto, GithubReleaseDto, GithubRepositorySearchItemDto,
235 };
236
237 #[test]
238 fn parse_timestamp_returns_min_for_invalid_or_empty_values() {
239 assert_eq!(
240 GithubAdapter::parse_timestamp(""),
241 chrono::DateTime::<chrono::Utc>::MIN_UTC
242 );
243 assert_eq!(
244 GithubAdapter::parse_timestamp("not-a-date"),
245 chrono::DateTime::<chrono::Utc>::MIN_UTC
246 );
247 }
248
249 #[test]
250 fn convert_release_maps_assets_and_version() {
251 let adapter =
252 GithubAdapter::new(GithubClient::new(None, Default::default()).expect("github client"));
253 let dto = GithubReleaseDto {
254 id: 12,
255 tag_name: "v2.3.4".to_string(),
256 name: "Release 2.3.4".to_string(),
257 body: "notes".to_string(),
258 prerelease: true,
259 draft: false,
260 published_at: "2026-02-21T00:00:00Z".to_string(),
261 assets: vec![GithubAssetDto {
262 id: 9,
263 name: "tool-linux-x86_64.tar.gz".to_string(),
264 browser_download_url: "https://example.invalid/tool-linux-x86_64.tar.gz"
265 .to_string(),
266 size: 123,
267 content_type: "application/gzip".to_string(),
268 created_at: "2026-02-20T00:00:00Z".to_string(),
269 }],
270 };
271
272 let release = adapter.convert_release(dto);
273 assert_eq!(release.id, 12);
274 assert_eq!(release.version.to_string(), "2.3.4");
275 assert!(release.is_prerelease);
276 assert_eq!(release.assets.len(), 1);
277 assert_eq!(release.assets[0].id, 9);
278 }
279
280 #[test]
281 fn convert_search_result_maps_fields() {
282 let dto = GithubRepositorySearchItemDto {
283 full_name: "BurntSushi/ripgrep".to_string(),
284 name: "ripgrep".to_string(),
285 description: "fast grep".to_string(),
286 stargazers_count: 123,
287 language: "Rust".to_string(),
288 updated_at: "2026-05-09T00:00:00Z".to_string(),
289 archived: false,
290 fork: false,
291 };
292
293 let result = GithubAdapter::convert_search_result(dto);
294 assert_eq!(result.repo_slug, "BurntSushi/ripgrep");
295 assert_eq!(result.display_name, "ripgrep");
296 assert_eq!(result.description, "fast grep");
297 assert_eq!(result.stars, 123);
298 assert_eq!(result.language, "Rust");
299 }
300
301 #[test]
302 fn convert_search_result_invalid_timestamp_uses_min() {
303 let dto = GithubRepositorySearchItemDto {
304 full_name: "owner/repo".to_string(),
305 name: "repo".to_string(),
306 description: String::new(),
307 stargazers_count: 0,
308 language: String::new(),
309 updated_at: "nope".to_string(),
310 archived: false,
311 fork: false,
312 };
313
314 let result = GithubAdapter::convert_search_result(dto);
315 assert_eq!(result.updated_at, chrono::DateTime::<chrono::Utc>::MIN_UTC);
316 }
317}