1use serde::{Deserialize, Serialize};
12
13use crate::http::{self, HttpError};
14use crate::pin::{pin_url, PinError, Pinned};
15
16pub const GITHUB_API_BASE: &str = "https://api.github.com";
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct GitHubAsset {
24 pub name: String,
25 pub size: u64,
26 pub browser_download_url: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub digest: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct GitHubRelease {
36 pub tag_name: String,
37 #[serde(default)]
38 pub prerelease: bool,
39 #[serde(default)]
40 pub draft: bool,
41 #[serde(default)]
42 pub published_at: String,
43 #[serde(default)]
44 pub assets: Vec<GitHubAsset>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ReleaseSelector<'a> {
50 Latest,
52 Prerelease,
54 Tag(&'a str),
56}
57
58#[derive(Debug, thiserror::Error)]
59pub enum GitHubError {
60 #[error(transparent)]
61 Http(#[from] HttpError),
62 #[error("GitHub API {status} listing {repo} releases")]
63 Api { status: u16, repo: String },
64 #[error("GitHub returned an unreadable release listing for {repo}: {source}")]
65 Malformed {
66 repo: String,
67 #[source]
68 source: serde_json::Error,
69 },
70 #[error("No {stability}GitHub release in {repo}{filtered}")]
71 NoRelease {
72 stability: &'static str,
75 repo: String,
76 filtered: &'static str,
78 },
79 #[error("GitHub release '{tag}' not found in {repo}. Available: {available}")]
80 NoTag {
81 tag: String,
82 repo: String,
83 available: String,
84 },
85}
86
87pub fn list_github_releases(
90 api_base: &str,
91 repo: &str,
92 token: Option<&str>,
93) -> Result<Vec<GitHubRelease>, GitHubError> {
94 let url = format!("{api_base}/repos/{repo}/releases?per_page=100");
95 let auth = token.map(|t| format!("Bearer {t}"));
96 let mut headers: Vec<(&str, &str)> = vec![
97 ("Accept", "application/vnd.github+json"),
98 ("X-GitHub-Api-Version", "2022-11-28"),
99 ];
100 if let Some(auth) = auth.as_deref() {
101 headers.push(("Authorization", auth));
102 }
103
104 let res = http::get(&url, &headers)?;
105 if !res.ok() {
106 return Err(GitHubError::Api {
107 status: res.status,
108 repo: repo.to_owned(),
109 });
110 }
111 serde_json::from_str(&res.body).map_err(|source| GitHubError::Malformed {
112 repo: repo.to_owned(),
113 source,
114 })
115}
116
117pub fn fetch_github_release(
123 api_base: &str,
124 repo: &str,
125 tag: &str,
126 token: Option<&str>,
127) -> Result<GitHubRelease, GitHubError> {
128 let url = format!("{api_base}/repos/{repo}/releases/tags/{tag}");
129 let auth = token.map(|t| format!("Bearer {t}"));
130 let mut headers: Vec<(&str, &str)> = vec![
131 ("Accept", "application/vnd.github+json"),
132 ("X-GitHub-Api-Version", "2022-11-28"),
133 ];
134 if let Some(auth) = auth.as_deref() {
135 headers.push(("Authorization", auth));
136 }
137
138 let res = http::get(&url, &headers)?;
139 if res.status == 404 {
140 return Err(GitHubError::NoTag {
141 tag: tag.to_owned(),
142 repo: repo.to_owned(),
143 available: "not listed".to_owned(),
144 });
145 }
146 if !res.ok() {
147 return Err(GitHubError::Api {
148 status: res.status,
149 repo: repo.to_owned(),
150 });
151 }
152 serde_json::from_str(&res.body).map_err(|source| GitHubError::Malformed {
153 repo: repo.to_owned(),
154 source,
155 })
156}
157
158pub fn github_asset_sha256(asset: &GitHubAsset) -> Option<&str> {
160 asset.digest.as_deref()?.strip_prefix("sha256:")
161}
162
163pub fn pin_github_asset(asset: &GitHubAsset) -> Result<Pinned, PinError> {
170 match github_asset_sha256(asset) {
171 Some(digest) => Ok(Pinned {
172 size: asset.size,
173 sha256: digest.to_owned(),
174 }),
175 None => pin_url(&asset.browser_download_url, &[]),
176 }
177}
178
179pub fn select_github_release<'r>(
182 releases: &'r [GitHubRelease],
183 selector: &ReleaseSelector<'_>,
184) -> Option<&'r GitHubRelease> {
185 match selector {
186 ReleaseSelector::Latest => releases.iter().find(|r| !r.prerelease),
187 ReleaseSelector::Prerelease => releases.first(),
188 ReleaseSelector::Tag(tag) => releases.iter().find(|r| r.tag_name == *tag),
189 }
190}
191
192pub fn pick_github_release(
197 api_base: &str,
198 repo: &str,
199 selector: &ReleaseSelector<'_>,
200 token: Option<&str>,
201 filter: Option<&dyn Fn(&GitHubRelease) -> bool>,
202) -> Result<GitHubRelease, GitHubError> {
203 let candidates: Vec<GitHubRelease> = list_github_releases(api_base, repo, token)?
204 .into_iter()
205 .filter(|r| !r.draft)
206 .filter(|r| match filter {
207 Some(keep) => keep(r),
208 None => true,
209 })
210 .collect();
211
212 if let Some(picked) = select_github_release(&candidates, selector) {
213 return Ok(picked.clone());
214 }
215
216 let filtered = if filter.is_some() {
217 " matching the loader filter"
218 } else {
219 ""
220 };
221 match selector {
222 ReleaseSelector::Latest => Err(GitHubError::NoRelease {
223 stability: "stable ",
224 repo: repo.to_owned(),
225 filtered,
226 }),
227 ReleaseSelector::Prerelease => Err(GitHubError::NoRelease {
228 stability: "",
229 repo: repo.to_owned(),
230 filtered,
231 }),
232 ReleaseSelector::Tag(tag) => {
233 let tags: Vec<&str> = candidates
234 .iter()
235 .take(5)
236 .map(|r| r.tag_name.as_str())
237 .collect();
238 let mut available = tags.join(", ");
239 if candidates.len() > 5 {
240 available.push_str(", …");
241 }
242 Err(GitHubError::NoTag {
243 tag: (*tag).to_owned(),
244 repo: repo.to_owned(),
245 available,
246 })
247 }
248 }
249}