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] 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] 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] 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] 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}