Skip to main content

github_bot_sdk/client/
release.rs

1// Spec: docs/specs/interfaces/additional-operations.md
2// Release operations for GitHub API
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::client::issue::IssueUser;
8use crate::client::InstallationClient;
9use crate::error::ApiError;
10
11/// GitHub release.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Release {
14    /// Unique release identifier
15    pub id: u64,
16
17    /// Node ID for GraphQL API
18    pub node_id: String,
19
20    /// Release tag name
21    pub tag_name: String,
22
23    /// Target commitish (branch or commit SHA)
24    pub target_commitish: String,
25
26    /// Release name
27    pub name: Option<String>,
28
29    /// Release body (Markdown)
30    pub body: Option<String>,
31
32    /// Whether this is a draft release
33    pub draft: bool,
34
35    /// Whether this is a prerelease
36    pub prerelease: bool,
37
38    /// User who created the release
39    pub author: IssueUser,
40
41    /// Creation timestamp
42    pub created_at: DateTime<Utc>,
43
44    /// Publication timestamp
45    pub published_at: Option<DateTime<Utc>>,
46
47    /// Release URL
48    pub url: String,
49
50    /// Release HTML URL
51    pub html_url: String,
52
53    /// Release assets
54    pub assets: Vec<ReleaseAsset>,
55}
56
57/// Asset attached to a release.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ReleaseAsset {
60    /// Unique asset identifier
61    pub id: u64,
62
63    /// Node ID for GraphQL API
64    pub node_id: String,
65
66    /// Asset filename
67    pub name: String,
68
69    /// Asset label
70    pub label: Option<String>,
71
72    /// Asset content type
73    pub content_type: String,
74
75    /// Asset state
76    pub state: String, // "uploaded", "open"
77
78    /// Asset size in bytes
79    pub size: u64,
80
81    /// Download count
82    pub download_count: u64,
83
84    /// User who uploaded the asset
85    pub uploader: IssueUser,
86
87    /// Creation timestamp
88    pub created_at: DateTime<Utc>,
89
90    /// Last update timestamp
91    pub updated_at: DateTime<Utc>,
92
93    /// Asset download URL
94    pub browser_download_url: String,
95}
96
97/// Request to create a release.
98#[derive(Debug, Clone, Serialize)]
99pub struct CreateReleaseRequest {
100    /// Tag name (required)
101    pub tag_name: String,
102
103    /// Target commitish (branch or commit SHA)
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub target_commitish: Option<String>,
106
107    /// Release name
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub name: Option<String>,
110
111    /// Release body (Markdown)
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub body: Option<String>,
114
115    /// Whether to create as draft
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub draft: Option<bool>,
118
119    /// Whether to mark as prerelease
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub prerelease: Option<bool>,
122
123    /// Whether to automatically generate the name and body for this release.
124    ///
125    /// When set to `true`, GitHub will auto-generate the release name (if `name`
126    /// is not provided) and the release notes body from merged pull requests and
127    /// other repository activity since the previous release. If `name` is provided
128    /// it is used as-is; if `body` is provided it is pre-pended to the generated
129    /// notes. Defaults to `false`.
130    ///
131    /// # Example
132    ///
133    /// ```
134    /// use github_bot_sdk::client::CreateReleaseRequest;
135    ///
136    /// let request = CreateReleaseRequest {
137    ///     tag_name: "v1.2.0".to_string(),
138    ///     target_commitish: None,
139    ///     name: None,
140    ///     body: None,
141    ///     draft: None,
142    ///     prerelease: None,
143    ///     generate_release_notes: Some(true),
144    /// };
145    /// ```
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub generate_release_notes: Option<bool>,
148}
149
150/// Request to update a release.
151///
152/// Note: `generate_release_notes` is intentionally absent from this type.
153/// The GitHub Update Release endpoint does not accept that parameter — it is
154/// only valid on the Create Release endpoint.
155#[derive(Debug, Clone, Serialize, Default)]
156pub struct UpdateReleaseRequest {
157    /// Tag name
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub tag_name: Option<String>,
160
161    /// Target commitish
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub target_commitish: Option<String>,
164
165    /// Release name
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub name: Option<String>,
168
169    /// Release body (Markdown)
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub body: Option<String>,
172
173    /// Whether this is a draft
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub draft: Option<bool>,
176
177    /// Whether this is a prerelease
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub prerelease: Option<bool>,
180}
181
182/// Domain client for release operations.
183///
184/// Obtained via [`InstallationClient::releases()`]. Cheap to clone (Arc-backed).
185///
186/// See docs/specs/interfaces/additional-operations.md
187#[derive(Debug, Clone)]
188pub struct ReleasesClient {
189    client: InstallationClient,
190}
191
192impl ReleasesClient {
193    pub(crate) fn new(client: InstallationClient) -> Self {
194        Self { client }
195    }
196
197    /// List releases in a repository.
198    ///
199    /// Retrieves all releases for a repository, including drafts and prereleases.
200    ///
201    /// # Arguments
202    ///
203    /// * `owner` - Repository owner
204    /// * `repo` - Repository name
205    ///
206    /// # Returns
207    ///
208    /// Returns vector of releases ordered by creation date (newest first).
209    ///
210    /// # Errors
211    ///
212    /// * `ApiError::NotFound` - Repository does not exist
213    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
214    ///
215    /// # Example
216    ///
217    /// ```no_run
218    /// # use github_bot_sdk::client::ReleasesClient;
219    /// # async fn example(client: &ReleasesClient) -> Result<(), Box<dyn std::error::Error>> {
220    /// let releases = client.list("owner", "repo").await?;
221    /// for release in releases {
222    ///     println!("Release: {} ({})", release.name.unwrap_or_default(), release.tag_name);
223    /// }
224    /// # Ok(())
225    /// # }
226    /// ```
227    pub async fn list(&self, owner: &str, repo: &str) -> Result<Vec<Release>, ApiError> {
228        let path = format!("/repos/{}/{}/releases", owner, repo);
229        let response = self.client.get(&path).await?;
230
231        let status = response.status();
232        if !status.is_success() {
233            return Err(super::map_http_error(status, response).await);
234        }
235
236        response.json().await.map_err(ApiError::from)
237    }
238
239    /// Get the latest published release.
240    ///
241    /// Returns the most recent non-draft, non-prerelease release.
242    ///
243    /// # Arguments
244    ///
245    /// * `owner` - Repository owner
246    /// * `repo` - Repository name
247    ///
248    /// # Returns
249    ///
250    /// Returns the latest published `Release`.
251    ///
252    /// # Errors
253    ///
254    /// * `ApiError::NotFound` - Repository or no published releases exist
255    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
256    ///
257    /// # Example
258    ///
259    /// ```no_run
260    /// # use github_bot_sdk::client::ReleasesClient;
261    /// # async fn example(client: &ReleasesClient) -> Result<(), Box<dyn std::error::Error>> {
262    /// let release = client.get_latest("owner", "repo").await?;
263    /// println!("Latest: {} ({})", release.name.unwrap_or_default(), release.tag_name);
264    /// # Ok(())
265    /// # }
266    /// ```
267    pub async fn get_latest(&self, owner: &str, repo: &str) -> Result<Release, ApiError> {
268        let path = format!("/repos/{}/{}/releases/latest", owner, repo);
269        let response = self.client.get(&path).await?;
270
271        let status = response.status();
272        if !status.is_success() {
273            return Err(super::map_http_error(status, response).await);
274        }
275
276        response.json().await.map_err(ApiError::from)
277    }
278
279    /// Get a release by tag name.
280    ///
281    /// Retrieves a release by its git tag name.
282    ///
283    /// # Arguments
284    ///
285    /// * `owner` - Repository owner
286    /// * `repo` - Repository name
287    /// * `tag` - Git tag name
288    ///
289    /// # Returns
290    ///
291    /// Returns the `Release` with the specified tag.
292    ///
293    /// # Errors
294    ///
295    /// * `ApiError::NotFound` - Release with tag does not exist
296    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
297    ///
298    /// # Example
299    ///
300    /// ```no_run
301    /// # use github_bot_sdk::client::ReleasesClient;
302    /// # async fn example(client: &ReleasesClient) -> Result<(), Box<dyn std::error::Error>> {
303    /// let release = client.get_by_tag("owner", "repo", "v1.0.0").await?;
304    /// println!("Release: {}", release.tag_name);
305    /// # Ok(())
306    /// # }
307    /// ```
308    pub async fn get_by_tag(
309        &self,
310        owner: &str,
311        repo: &str,
312        tag: &str,
313    ) -> Result<Release, ApiError> {
314        let encoded_tag = urlencoding::encode(tag);
315        let path = format!("/repos/{}/{}/releases/tags/{}", owner, repo, encoded_tag);
316        let response = self.client.get(&path).await?;
317
318        let status = response.status();
319        if !status.is_success() {
320            return Err(super::map_http_error(status, response).await);
321        }
322
323        response.json().await.map_err(ApiError::from)
324    }
325
326    /// Get a release by ID.
327    ///
328    /// Retrieves a release by its unique identifier.
329    ///
330    /// # Arguments
331    ///
332    /// * `owner` - Repository owner
333    /// * `repo` - Repository name
334    /// * `release_id` - Release ID
335    ///
336    /// # Returns
337    ///
338    /// Returns the `Release` with the specified ID.
339    ///
340    /// # Errors
341    ///
342    /// * `ApiError::NotFound` - Release does not exist
343    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
344    ///
345    /// # Example
346    ///
347    /// ```no_run
348    /// # use github_bot_sdk::client::ReleasesClient;
349    /// # async fn example(client: &ReleasesClient) -> Result<(), Box<dyn std::error::Error>> {
350    /// let release = client.get("owner", "repo", 12345).await?;
351    /// println!("Release: {}", release.tag_name);
352    /// # Ok(())
353    /// # }
354    /// ```
355    pub async fn get(&self, owner: &str, repo: &str, release_id: u64) -> Result<Release, ApiError> {
356        let path = format!("/repos/{}/{}/releases/{}", owner, repo, release_id);
357        let response = self.client.get(&path).await?;
358
359        let status = response.status();
360        if !status.is_success() {
361            return Err(super::map_http_error(status, response).await);
362        }
363
364        response.json().await.map_err(ApiError::from)
365    }
366
367    /// Create a new release.
368    ///
369    /// Creates a new release for a repository. Can create published releases,
370    /// drafts, or prereleases.
371    ///
372    /// # Arguments
373    ///
374    /// * `owner` - Repository owner
375    /// * `repo` - Repository name
376    /// * `request` - Release creation parameters
377    ///
378    /// # Returns
379    ///
380    /// Returns the created `Release`.
381    ///
382    /// # Errors
383    ///
384    /// * `ApiError::InvalidRequest` - Tag already exists or invalid parameters
385    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
386    ///
387    /// # Example
388    ///
389    /// ```no_run
390    /// # use github_bot_sdk::client::{ReleasesClient, CreateReleaseRequest};
391    /// # async fn example(client: &ReleasesClient) -> Result<(), Box<dyn std::error::Error>> {
392    /// let request = CreateReleaseRequest {
393    ///     tag_name: "v1.0.0".to_string(),
394    ///     name: Some("Version 1.0.0".to_string()),
395    ///     body: Some("Release notes".to_string()),
396    ///     draft: Some(false),
397    ///     prerelease: Some(false),
398    ///     target_commitish: None,
399    ///     generate_release_notes: None,
400    /// };
401    /// let release = client.create("owner", "repo", request).await?;
402    /// println!("Created release: {}", release.tag_name);
403    /// # Ok(())
404    /// # }
405    /// ```
406    pub async fn create(
407        &self,
408        owner: &str,
409        repo: &str,
410        request: CreateReleaseRequest,
411    ) -> Result<Release, ApiError> {
412        let path = format!("/repos/{}/{}/releases", owner, repo);
413        let response = self.client.post(&path, &request).await?;
414
415        let status = response.status();
416        if !status.is_success() {
417            return Err(super::map_http_error(status, response).await);
418        }
419
420        response.json().await.map_err(ApiError::from)
421    }
422
423    /// Update an existing release.
424    ///
425    /// Updates release properties. Only specified fields are modified.
426    ///
427    /// # Arguments
428    ///
429    /// * `owner` - Repository owner
430    /// * `repo` - Repository name
431    /// * `release_id` - Release ID
432    /// * `request` - Fields to update
433    ///
434    /// # Returns
435    ///
436    /// Returns the updated `Release`.
437    ///
438    /// # Errors
439    ///
440    /// * `ApiError::NotFound` - Release does not exist
441    /// * `ApiError::InvalidRequest` - Invalid parameters
442    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
443    ///
444    /// # Example
445    ///
446    /// ```no_run
447    /// # use github_bot_sdk::client::{ReleasesClient, UpdateReleaseRequest};
448    /// # async fn example(client: &ReleasesClient) -> Result<(), Box<dyn std::error::Error>> {
449    /// let request = UpdateReleaseRequest {
450    ///     name: Some("Updated name".to_string()),
451    ///     body: Some("Updated notes".to_string()),
452    ///     ..Default::default()
453    /// };
454    /// let release = client.update("owner", "repo", 12345, request).await?;
455    /// println!("Updated release: {}", release.tag_name);
456    /// # Ok(())
457    /// # }
458    /// ```
459    pub async fn update(
460        &self,
461        owner: &str,
462        repo: &str,
463        release_id: u64,
464        request: UpdateReleaseRequest,
465    ) -> Result<Release, ApiError> {
466        let path = format!("/repos/{}/{}/releases/{}", owner, repo, release_id);
467        let response = self.client.patch(&path, &request).await?;
468
469        let status = response.status();
470        if !status.is_success() {
471            return Err(super::map_http_error(status, response).await);
472        }
473
474        response.json().await.map_err(ApiError::from)
475    }
476
477    /// Delete a release.
478    ///
479    /// Deletes a release. Does not delete the associated git tag.
480    ///
481    /// # Arguments
482    ///
483    /// * `owner` - Repository owner
484    /// * `repo` - Repository name
485    /// * `release_id` - Release ID
486    ///
487    /// # Returns
488    ///
489    /// Returns `Ok(())` on successful deletion.
490    ///
491    /// # Errors
492    ///
493    /// * `ApiError::NotFound` - Release does not exist
494    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
495    ///
496    /// # Example
497    ///
498    /// ```no_run
499    /// # use github_bot_sdk::client::ReleasesClient;
500    /// # async fn example(client: &ReleasesClient) -> Result<(), Box<dyn std::error::Error>> {
501    /// client.delete("owner", "repo", 12345).await?;
502    /// println!("Release deleted");
503    /// # Ok(())
504    /// # }
505    /// ```
506    pub async fn delete(&self, owner: &str, repo: &str, release_id: u64) -> Result<(), ApiError> {
507        let path = format!("/repos/{}/{}/releases/{}", owner, repo, release_id);
508        let response = self.client.delete(&path).await?;
509
510        let status = response.status();
511        if !status.is_success() {
512            return Err(super::map_http_error(status, response).await);
513        }
514
515        Ok(())
516    }
517}
518
519#[cfg(test)]
520#[path = "release_tests.rs"]
521mod tests;