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;