Skip to main content

ephemeris_forge_github/api/http/
release.rs

1use 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    /// Read the body of the unique open pull request for `head` and `base`.
44    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    /// Create the proposal pull request, or update its title and body.
87    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    /// Read a release by its tag.
108    ///
109    /// The tag route serves only published releases and a draft has no tag
110    /// yet, so this lists the releases and matches on `tag_name`.
111    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    /// Create a draft release and return its stable ID.
127    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    /// List all release assets, including size and server-reported digest.
140    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    /// Download an existing asset for digest verification when GitHub omitted
151    /// its server-side digest.
152    ///
153    /// Only the asset route with an octet-stream `Accept` header serves the
154    /// assets of draft releases and private repositories. It redirects to
155    /// storage.
156    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    /// Upload one asset to an existing draft release.
167    ///
168    /// # Errors
169    /// Returns a rejection for an invalid asset filename and an unsupported
170    /// error if the release is already published.
171    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    /// Delete one release asset.
213    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    /// Publish a fully staged draft.
224    ///
225    /// # Errors
226    /// Returns an unsupported error if the release is already published.
227    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}