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;