Skip to main content

github_bot_sdk/client/
release.rs

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