Skip to main content

facto_core/forges/
github.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use crate::forges::{Forge, ForgeError, ForgeResult};
5use crate::models::{ReleaseAsset, ReleaseInfo, RepoSort, RepositorySearchResult, TagPin};
6
7pub struct GitHub {
8    client: reqwest::Client,
9}
10
11impl GitHub {
12    pub fn new(client: reqwest::Client) -> Self {
13        Self { client }
14    }
15}
16
17#[derive(serde::Deserialize)]
18struct GitHubRelease {
19    tag_name: String,
20    name: Option<String>,
21    published_at: Option<String>,
22    prerelease: bool,
23    draft: bool,
24    html_url: String,
25    assets: Vec<GitHubAsset>,
26}
27
28#[derive(serde::Deserialize)]
29struct GitHubAsset {
30    name: String,
31    size: u64,
32    browser_download_url: String,
33    download_count: u64,
34    content_type: String,
35}
36
37#[derive(serde::Deserialize)]
38struct GitHubSearchResponse {
39    items: Vec<GitHubRepo>,
40}
41
42#[derive(serde::Deserialize)]
43struct GitHubRepo {
44    full_name: String,
45    name: String,
46    owner: GitHubRepoOwner,
47    description: Option<String>,
48    stargazers_count: u64,
49    language: Option<String>,
50    #[serde(default)]
51    topics: Vec<String>,
52    html_url: String,
53    updated_at: Option<String>,
54}
55
56#[derive(serde::Deserialize)]
57struct GitHubRepoOwner {
58    login: String,
59}
60
61impl Forge for GitHub {
62    fn id(&self) -> &str {
63        "github"
64    }
65
66    fn display_name(&self) -> &str {
67        "GitHub"
68    }
69
70    fn supports_releases(&self) -> bool {
71        true
72    }
73
74    fn list_releases<'a>(
75        &'a self,
76        owner: &'a str,
77        repo: &'a str,
78        limit: usize,
79    ) -> Pin<Box<dyn Future<Output = ForgeResult<Vec<ReleaseInfo>>> + Send + 'a>> {
80        Box::pin(async move {
81            let url = format!(
82                "https://api.github.com/repos/{}/{}/releases?per_page={}",
83                urlencoding::encode(owner),
84                urlencoding::encode(repo),
85                limit
86            );
87            let resp = self.client.get(&url).send().await?;
88
89            match resp.status().as_u16() {
90                200 => {
91                    let releases: Vec<GitHubRelease> = crate::forges::bounded_json(resp).await?;
92                    Ok(releases
93                        .into_iter()
94                        .map(|r| ReleaseInfo {
95                            tag: r.tag_name,
96                            name: r.name,
97                            published_at: r.published_at.as_deref().and_then(|ts| ts.parse().ok()),
98                            prerelease: r.prerelease,
99                            draft: r.draft,
100                            html_url: r.html_url,
101                            assets: r
102                                .assets
103                                .into_iter()
104                                .map(|a| ReleaseAsset {
105                                    name: a.name,
106                                    size: a.size,
107                                    download_url: a.browser_download_url,
108                                    download_count: a.download_count,
109                                    content_type: a.content_type,
110                                })
111                                .collect(),
112                        })
113                        .collect())
114                }
115                404 => Ok(Vec::new()),
116                429 => Err(ForgeError::RateLimited),
117                status => Err(ForgeError::Parse(format!("unexpected status {}", status))),
118            }
119        })
120    }
121
122    fn supports_search(&self) -> bool {
123        true
124    }
125
126    fn search_repositories<'a>(
127        &'a self,
128        query: &'a str,
129        sort: RepoSort,
130        language: Option<&'a str>,
131        limit: usize,
132    ) -> Pin<Box<dyn Future<Output = ForgeResult<Vec<RepositorySearchResult>>> + Send + 'a>> {
133        Box::pin(async move {
134            let mut q = query.to_string();
135            if let Some(lang) = language {
136                q.push_str(&format!(" language:{lang}"));
137            }
138            let mut url = format!(
139                "https://api.github.com/search/repositories?q={}&per_page={}",
140                urlencoding::encode(&q),
141                limit.min(100),
142            );
143            match sort {
144                RepoSort::Stars => url.push_str("&sort=stars&order=desc"),
145                RepoSort::Updated => url.push_str("&sort=updated&order=desc"),
146                RepoSort::Relevance => {}
147            }
148            let resp = self.client.get(&url).send().await?;
149            match resp.status().as_u16() {
150                200 => {}
151                403 | 429 => return Err(ForgeError::RateLimited),
152                status => return Err(ForgeError::Parse(format!("unexpected status {status}"))),
153            }
154            let data: GitHubSearchResponse = crate::forges::bounded_json(resp).await?;
155            Ok(data
156                .items
157                .into_iter()
158                .map(|r| RepositorySearchResult {
159                    forge: "github".into(),
160                    full_name: r.full_name,
161                    owner: r.owner.login,
162                    name: r.name,
163                    description: r.description,
164                    stars: r.stargazers_count,
165                    language: r.language,
166                    topics: r.topics,
167                    url: r.html_url,
168                    updated_at: r.updated_at.as_deref().and_then(|ts| ts.parse().ok()),
169                })
170                .collect())
171        })
172    }
173
174    fn resolve_tag<'a>(
175        &'a self,
176        owner: &'a str,
177        repo: &'a str,
178        tag: &'a str,
179    ) -> Pin<Box<dyn Future<Output = ForgeResult<TagPin>> + Send + 'a>> {
180        Box::pin(async move {
181            let url = format!(
182                "https://api.github.com/repos/{}/{}/commits/{}",
183                urlencoding::encode(owner),
184                urlencoding::encode(repo),
185                urlencoding::encode(tag),
186            );
187            let resp = self.client.get(&url).send().await?;
188
189            match resp.status().as_u16() {
190                200 => {
191                    let data: serde_json::Value = crate::forges::bounded_json(resp).await?;
192                    let sha = data["sha"]
193                        .as_str()
194                        .ok_or_else(|| ForgeError::Parse("missing sha in response".into()))?
195                        .to_string();
196                    Ok(TagPin {
197                        owner: owner.to_string(),
198                        repository: repo.to_string(),
199                        forge_id: "github".to_string(),
200                        tag: tag.to_string(),
201                        commit_sha: sha.clone(),
202                        url: Some(format!(
203                            "https://github.com/{}/{}/commit/{}",
204                            owner,
205                            repo,
206                            sha.get(..7).unwrap_or(&sha)
207                        )),
208                    })
209                }
210                404 => Err(ForgeError::Parse(format!("tag '{}' not found", tag))),
211                422 => Err(ForgeError::Parse(format!("invalid ref '{}'", tag))),
212                429 => Err(ForgeError::RateLimited),
213                status => Err(ForgeError::Parse(format!("unexpected status {}", status))),
214            }
215        })
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[tokio::test]
224    #[ignore] // requires network
225    async fn test_list_releases() {
226        let gh = GitHub::new(crate::http::default_client().unwrap());
227        let releases = gh.list_releases("jdx", "mise", 5).await.unwrap();
228        assert!(!releases.is_empty());
229        assert!(releases.len() <= 5);
230        assert!(!releases[0].tag.is_empty());
231    }
232
233    #[tokio::test]
234    #[ignore] // requires network
235    async fn test_resolve_tag() {
236        let gh = GitHub::new(crate::http::default_client().unwrap());
237        let pin = gh.resolve_tag("actions", "checkout", "v4").await.unwrap();
238        assert_eq!(pin.owner, "actions");
239        assert_eq!(pin.repository, "checkout");
240        assert_eq!(pin.tag, "v4");
241        assert_eq!(pin.commit_sha.len(), 40);
242        assert!(pin.url.is_some());
243    }
244
245    #[tokio::test]
246    #[ignore] // requires network
247    async fn test_resolve_tag_not_found() {
248        let gh = GitHub::new(crate::http::default_client().unwrap());
249        let result = gh
250            .resolve_tag("actions", "checkout", "v99999-nonexistent")
251            .await;
252        assert!(result.is_err());
253    }
254
255    #[tokio::test]
256    #[ignore] // requires network
257    async fn test_search_repositories() {
258        let gh = GitHub::new(crate::http::default_client().unwrap());
259        let results = gh
260            .search_repositories("tokio", RepoSort::Stars, None, 5)
261            .await
262            .unwrap();
263        assert!(!results.is_empty());
264        assert!(results.len() <= 5);
265        assert!(results[0].stars > 0);
266        assert_eq!(results[0].forge, "github");
267    }
268}