ephemeris_forge_github/api/http/
release.rs1use ephemeris_forge::{
2 AssetWire, DraftRelease, ObjectWire, PublishRelease, PullRequestCreate, ReleaseWire, RepoRef,
3 encode, path_segment, query_value,
4};
5use ephemeris_release::{RemoteAsset, RemoteRelease};
6use facet::Facet;
7use reqwest::header::HeaderValue;
8use reqwest::{Method, Url};
9
10use crate::api::{ForgeError, HttpApi};
11
12#[derive(Facet)]
13struct PullWire {
14 number: u64,
15 #[facet(default)]
16 body: Option<String>,
17}
18
19#[derive(Facet)]
20struct ListedReleaseWire {
21 id: u64,
22 draft: bool,
23 tag_name: String,
24}
25
26impl HttpApi {
27 pub(crate) async fn tag_commit(
28 &self,
29 repository: &RepoRef,
30 tag: &str,
31 ) -> Result<Option<String>, ForgeError> {
32 let path = repository.path(&format!("git/ref/tags/{}", path_segment(tag)));
33 let Some(reference) = self
34 .http
35 .json_optional::<ObjectWire>(Method::GET, &path, None)
36 .await?
37 else {
38 return Ok(None);
39 };
40 Ok(Some(reference.object.peel(&self.http, repository).await?))
41 }
42
43 pub(crate) async fn open_pull_request_body(
45 &self,
46 repository: &RepoRef,
47 head: &str,
48 base: &str,
49 ) -> Result<Option<String>, ForgeError> {
50 let pull = self.open_pull_request(repository, head, base).await?;
51 Ok(pull.map(|pull| pull.body.unwrap_or_default()))
52 }
53
54 async fn open_pull_request(
55 &self,
56 repository: &RepoRef,
57 head: &str,
58 base: &str,
59 ) -> Result<Option<PullWire>, ForgeError> {
60 let mut pulls = self.pulls(repository, "open", head, base).await?;
61 if pulls.len() > 1 {
62 return Err(ForgeError::AmbiguousPullRequest {
63 head: head.to_owned(),
64 base: base.to_owned(),
65 });
66 }
67 Ok(pulls.pop())
68 }
69
70 async fn pulls(
71 &self,
72 repository: &RepoRef,
73 state: &str,
74 head: &str,
75 base: &str,
76 ) -> Result<Vec<PullWire>, ForgeError> {
77 let path = repository.path(&format!(
78 "pulls?state={state}&head={}:{}&base={}",
79 query_value(repository.owner()),
80 query_value(head),
81 query_value(base)
82 ));
83 self.paged(&path).await
84 }
85
86 pub(crate) async fn ensure_pull_request(
88 &self,
89 repository: &RepoRef,
90 head: &str,
91 base: &str,
92 title: &str,
93 body: &str,
94 ) -> Result<(), ForgeError> {
95 let current = self.open_pull_request(repository, head, base).await?;
96 PullRequestCreate {
97 title,
98 body,
99 head,
100 base,
101 }
102 .ensure(&self.http, repository, current.map(|pull| pull.number))
103 .await?;
104 Ok(())
105 }
106
107 pub(crate) async fn release_by_tag(
112 &self,
113 repository: &RepoRef,
114 tag: &str,
115 ) -> Result<Option<RemoteRelease>, ForgeError> {
116 let releases: Vec<ListedReleaseWire> = self.paged(&repository.path("releases")).await?;
117 Ok(releases
118 .into_iter()
119 .find(|release| release.tag_name == tag)
120 .map(|release| RemoteRelease {
121 id: release.id,
122 draft: release.draft,
123 }))
124 }
125
126 pub(crate) async fn create_draft_release(
128 &self,
129 repository: &RepoRef,
130 tag: &str,
131 target_commit: &str,
132 title: &str,
133 notes: &str,
134 ) -> Result<u64, ForgeError> {
135 let draft = DraftRelease::new(tag, target_commit, title, notes);
136 Ok(draft.create(&self.http, repository).await?)
137 }
138
139 pub(crate) async fn assets(
141 &self,
142 repository: &RepoRef,
143 release_id: u64,
144 ) -> Result<Vec<RemoteAsset>, ForgeError> {
145 let path = repository.path(&format!("releases/{release_id}/assets"));
146 let values: Vec<AssetWire> = self.paged(&path).await?;
147 Ok(values.into_iter().map(remote_asset).collect())
148 }
149
150 pub(crate) async fn download_asset(
157 &self,
158 repository: &RepoRef,
159 asset: &RemoteAsset,
160 ) -> Result<Vec<u8>, ForgeError> {
161 let path = repository.path(&format!("releases/assets/{}", asset.id));
162 let accept = HeaderValue::from_static("application/octet-stream");
163 Ok(self.http.bytes(Method::GET, &path, Some(accept)).await?)
164 }
165
166 pub(crate) async fn upload_asset(
172 &self,
173 repository: &RepoRef,
174 release_id: u64,
175 filename: &str,
176 bytes: Vec<u8>,
177 ) -> Result<(), ForgeError> {
178 if filename.is_empty()
179 || filename.contains(['/', '\\'])
180 || filename == "."
181 || filename == ".."
182 {
183 return Err(ForgeError::InvalidAssetFilename {
184 filename: filename.to_owned(),
185 });
186 }
187 let path = repository.path(&format!("releases/{release_id}"));
188 let release: ReleaseWire = self.http.json(Method::GET, &path, None).await?;
189 if !release.draft {
190 return Err(ForgeError::ReleaseImmutable { id: release.id });
191 }
192 let template = release
193 .upload_url
194 .split_once('{')
195 .map_or(release.upload_url.as_str(), |(base, _)| base);
196 let mut url = Url::parse(template).map_err(|_error| ForgeError::InvalidUploadUrl {
197 id: release.id,
198 template: template.to_owned(),
199 })?;
200 url.query_pairs_mut().append_pair("name", filename);
201 self.http
202 .send_bytes(
203 Method::POST,
204 url.as_str(),
205 "application/octet-stream",
206 bytes,
207 )
208 .await?;
209 Ok(())
210 }
211
212 pub(crate) async fn delete_asset(
214 &self,
215 repository: &RepoRef,
216 asset_id: u64,
217 ) -> Result<(), ForgeError> {
218 let path = repository.path(&format!("releases/assets/{asset_id}"));
219 self.http.send(Method::DELETE, &path, None).await?;
220 Ok(())
221 }
222
223 pub(crate) async fn publish_release(
228 &self,
229 repository: &RepoRef,
230 release_id: u64,
231 ) -> Result<(), ForgeError> {
232 let path = repository.path(&format!("releases/{release_id}"));
233 let release: ReleaseWire = self.http.json(Method::GET, &path, None).await?;
234 if !release.draft {
235 return Err(ForgeError::ReleaseImmutable { id: release.id });
236 }
237 let raw = encode(&PublishRelease { draft: false });
238 self.http.send(Method::PATCH, &path, Some(&raw)).await?;
239 Ok(())
240 }
241}
242
243fn remote_asset(asset: AssetWire) -> RemoteAsset {
244 RemoteAsset {
245 id: asset.id,
246 name: asset.name,
247 size: asset.size,
248 sha256: asset.digest,
249 download_url: asset.browser_download_url,
250 }
251}