Skip to main content

anodizer_core/
github_client.rs

1//! GitHub client trait and mock implementation.
2//!
3//! Defines the [`GitHubClient`] trait that abstracts GitHub API operations
4//! needed by the release stage. The real octocrab-based implementation lives
5//! in `crates/stage-release`; this module provides only the trait definition
6//! and a [`MockGitHubClient`] for testing.
7//!
8//! # Usage
9//!
10//! The mock client records every call and returns configurable responses:
11//!
12//! ```rust,ignore
13//! use anodizer_core::github_client::{MockGitHubClient, ReleaseInfo, GitHubClient};
14//!
15//! let mock = MockGitHubClient::new();
16//! mock.set_create_release_response(Ok(ReleaseInfo {
17//!     id: 42,
18//!     html_url: "https://github.com/owner/repo/releases/42".to_string(),
19//!     tag_name: "v1.0.0".to_string(),
20//!     name: Some("Release v1.0.0".to_string()),
21//!     draft: false,
22//! }));
23//!
24//! let result = mock.create_release(&params)?;
25//! assert_eq!(mock.create_release_calls(), 1);
26//! # Ok::<(), anyhow::Error>(())
27//! ```
28
29use std::path::PathBuf;
30
31#[cfg(feature = "test-helpers")]
32use std::sync::Mutex;
33
34// ---------------------------------------------------------------------------
35// Data types
36// ---------------------------------------------------------------------------
37
38/// Minimal release metadata returned by GitHub API operations.
39#[derive(Debug, Clone)]
40pub struct ReleaseInfo {
41    pub id: u64,
42    pub html_url: String,
43    pub tag_name: String,
44    pub name: Option<String>,
45    pub draft: bool,
46}
47
48/// Parameters for creating a GitHub release.
49#[derive(Debug, Clone)]
50pub struct CreateReleaseParams {
51    pub owner: String,
52    pub repo: String,
53    pub tag_name: String,
54    pub name: String,
55    pub body: String,
56    pub draft: bool,
57    pub prerelease: bool,
58    pub generate_release_notes: bool,
59    pub make_latest: Option<String>,
60}
61
62/// Parameters for uploading a release asset.
63#[derive(Debug, Clone)]
64pub struct UploadAssetParams {
65    pub owner: String,
66    pub repo: String,
67    pub release_id: u64,
68    pub file_name: String,
69    pub file_path: PathBuf,
70}
71
72/// Minimal asset metadata returned by upload operations.
73#[derive(Debug, Clone)]
74pub struct AssetInfo {
75    pub id: u64,
76    pub name: String,
77    pub size: u64,
78}
79
80/// Parameters for listing releases.
81#[derive(Debug, Clone)]
82pub struct ListReleasesParams {
83    pub owner: String,
84    pub repo: String,
85}
86
87/// Parameters for deleting a release.
88#[derive(Debug, Clone)]
89pub struct DeleteReleaseParams {
90    pub owner: String,
91    pub repo: String,
92    pub release_id: u64,
93}
94
95/// Parameters for looking up a release by tag name.
96///
97/// Wraps `GET /repos/{owner}/{repo}/releases/tags/{tag}`. Returned by
98/// [`GitHubClient::get_release_by_tag`] as `Ok(Some(ReleaseInfo))` when
99/// the release exists, `Ok(None)` when the tag has no release, or an
100/// `Err` for transport / auth failures. Used by
101/// `GithubReleasePublisher` to capture release IDs into evidence
102/// without modifying `ReleaseStage::run`.
103#[derive(Debug, Clone)]
104pub struct GetReleaseByTagParams {
105    pub owner: String,
106    pub repo: String,
107    pub tag: String,
108}
109
110/// Parameters for deleting a git tag reference.
111///
112/// Wraps `DELETE /repos/{owner}/{repo}/git/refs/tags/{tag}`. GitHub's
113/// `DELETE /releases/{id}` only removes the release record; the tag
114/// itself survives. `GithubReleasePublisher::rollback` follows the
115/// release delete with a tag delete so the rollback fully reverses the
116/// publish — otherwise a re-run would still see the old tag.
117#[derive(Debug, Clone)]
118pub struct DeleteTagParams {
119    pub owner: String,
120    pub repo: String,
121    pub tag: String,
122}
123
124// ---------------------------------------------------------------------------
125// Trait
126// ---------------------------------------------------------------------------
127
128/// Abstraction over GitHub API operations used by the release stage.
129///
130/// Implementations:
131/// - Real: wraps octocrab (lives in `crates/stage-release`)
132/// - Mock: [`MockGitHubClient`] for tests (records calls, configurable responses)
133pub trait GitHubClient {
134    /// Create a new GitHub release.
135    fn create_release(&self, params: &CreateReleaseParams) -> anyhow::Result<ReleaseInfo>;
136
137    /// Upload an asset to an existing release.
138    fn upload_asset(&self, params: &UploadAssetParams) -> anyhow::Result<AssetInfo>;
139
140    /// List all releases for a repository.
141    fn list_releases(&self, params: &ListReleasesParams) -> anyhow::Result<Vec<ReleaseInfo>>;
142
143    /// Delete a release by ID.
144    fn delete_release(&self, params: &DeleteReleaseParams) -> anyhow::Result<()>;
145
146    /// Look up a release by tag name.
147    ///
148    /// Returns `Ok(Some(info))` when the release exists, `Ok(None)` when
149    /// the tag has no release (HTTP 404 for the `/releases/tags/{tag}`
150    /// endpoint), and `Err` for transport / auth failures.
151    fn get_release_by_tag(
152        &self,
153        params: &GetReleaseByTagParams,
154    ) -> anyhow::Result<Option<ReleaseInfo>>;
155
156    /// Delete a git tag reference.
157    ///
158    /// GitHub's `DELETE /repos/{owner}/{repo}/releases/{id}` leaves the
159    /// underlying tag alive; a complete rollback issues a follow-up
160    /// `DELETE /repos/{owner}/{repo}/git/refs/tags/{tag}` to remove it.
161    /// 404 here should be bucketed as already-absent by the caller —
162    /// the tag was either never created or was already deleted.
163    fn delete_tag(&self, params: &DeleteTagParams) -> anyhow::Result<()>;
164}
165
166// ---------------------------------------------------------------------------
167// MockGitHubClient (test-only)
168// ---------------------------------------------------------------------------
169
170/// A mock GitHub client that records calls and returns configurable responses.
171///
172/// All response setters use interior mutability (Mutex) so the mock can be
173/// shared across test code without requiring `&mut`.
174///
175/// Only available when the `test-helpers` feature is enabled.
176#[cfg(feature = "test-helpers")]
177pub struct MockGitHubClient {
178    create_release_calls: Mutex<Vec<CreateReleaseParams>>,
179    upload_asset_calls: Mutex<Vec<UploadAssetParams>>,
180    list_releases_calls: Mutex<Vec<ListReleasesParams>>,
181    delete_release_calls: Mutex<Vec<DeleteReleaseParams>>,
182    get_release_by_tag_calls: Mutex<Vec<GetReleaseByTagParams>>,
183    delete_tag_calls: Mutex<Vec<DeleteTagParams>>,
184
185    create_release_response: Mutex<Option<Result<ReleaseInfo, String>>>,
186    upload_asset_response: Mutex<Option<Result<AssetInfo, String>>>,
187    list_releases_response: Mutex<Option<Result<Vec<ReleaseInfo>, String>>>,
188    delete_release_response: Mutex<Option<Result<(), String>>>,
189    get_release_by_tag_response: Mutex<Option<Result<Option<ReleaseInfo>, String>>>,
190    delete_tag_response: Mutex<Option<Result<(), String>>>,
191}
192
193#[cfg(feature = "test-helpers")]
194impl MockGitHubClient {
195    /// Create a new mock with no pre-configured responses.
196    ///
197    /// By default, all operations will return an error saying
198    /// "no mock response configured". Use the `set_*_response` methods
199    /// to configure what each operation returns.
200    pub fn new() -> Self {
201        Self {
202            create_release_calls: Mutex::new(Vec::new()),
203            upload_asset_calls: Mutex::new(Vec::new()),
204            list_releases_calls: Mutex::new(Vec::new()),
205            delete_release_calls: Mutex::new(Vec::new()),
206            get_release_by_tag_calls: Mutex::new(Vec::new()),
207            delete_tag_calls: Mutex::new(Vec::new()),
208            create_release_response: Mutex::new(None),
209            upload_asset_response: Mutex::new(None),
210            list_releases_response: Mutex::new(None),
211            delete_release_response: Mutex::new(None),
212            get_release_by_tag_response: Mutex::new(None),
213            delete_tag_response: Mutex::new(None),
214        }
215    }
216
217    // -- Response setters --
218
219    /// Configure the response for `create_release` calls.
220    pub fn set_create_release_response(&self, response: Result<ReleaseInfo, String>) {
221        *self
222            .create_release_response
223            .lock()
224            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
225    }
226
227    /// Configure the response for `upload_asset` calls.
228    pub fn set_upload_asset_response(&self, response: Result<AssetInfo, String>) {
229        *self
230            .upload_asset_response
231            .lock()
232            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
233    }
234
235    /// Configure the response for `list_releases` calls.
236    pub fn set_list_releases_response(&self, response: Result<Vec<ReleaseInfo>, String>) {
237        *self
238            .list_releases_response
239            .lock()
240            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
241    }
242
243    /// Configure the response for `delete_release` calls.
244    pub fn set_delete_release_response(&self, response: Result<(), String>) {
245        *self
246            .delete_release_response
247            .lock()
248            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
249    }
250
251    /// Configure the response for `get_release_by_tag` calls.
252    ///
253    /// `Ok(Some(info))` mirrors a 200 OK response, `Ok(None)` mirrors a
254    /// 404 (no release for this tag), and `Err(msg)` mirrors any other
255    /// failure (transport, 5xx, auth).
256    pub fn set_get_release_by_tag_response(&self, response: Result<Option<ReleaseInfo>, String>) {
257        *self
258            .get_release_by_tag_response
259            .lock()
260            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
261    }
262
263    /// Configure the response for `delete_tag` calls.
264    pub fn set_delete_tag_response(&self, response: Result<(), String>) {
265        *self
266            .delete_tag_response
267            .lock()
268            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
269    }
270
271    // -- Call counters / accessors --
272
273    /// Number of times `create_release` was called.
274    pub fn create_release_call_count(&self) -> usize {
275        self.create_release_calls
276            .lock()
277            .unwrap_or_else(std::sync::PoisonError::into_inner)
278            .len()
279    }
280
281    /// Number of times `upload_asset` was called.
282    pub fn upload_asset_call_count(&self) -> usize {
283        self.upload_asset_calls
284            .lock()
285            .unwrap_or_else(std::sync::PoisonError::into_inner)
286            .len()
287    }
288
289    /// Number of times `list_releases` was called.
290    pub fn list_releases_call_count(&self) -> usize {
291        self.list_releases_calls
292            .lock()
293            .unwrap_or_else(std::sync::PoisonError::into_inner)
294            .len()
295    }
296
297    /// Number of times `delete_release` was called.
298    pub fn delete_release_call_count(&self) -> usize {
299        self.delete_release_calls
300            .lock()
301            .unwrap_or_else(std::sync::PoisonError::into_inner)
302            .len()
303    }
304
305    /// Get a clone of all recorded `create_release` call parameters.
306    pub fn create_release_calls(&self) -> Vec<CreateReleaseParams> {
307        self.create_release_calls
308            .lock()
309            .unwrap_or_else(std::sync::PoisonError::into_inner)
310            .clone()
311    }
312
313    /// Get a clone of all recorded `upload_asset` call parameters.
314    pub fn upload_asset_calls(&self) -> Vec<UploadAssetParams> {
315        self.upload_asset_calls
316            .lock()
317            .unwrap_or_else(std::sync::PoisonError::into_inner)
318            .clone()
319    }
320
321    /// Get a clone of all recorded `list_releases` call parameters.
322    pub fn list_releases_calls(&self) -> Vec<ListReleasesParams> {
323        self.list_releases_calls
324            .lock()
325            .unwrap_or_else(std::sync::PoisonError::into_inner)
326            .clone()
327    }
328
329    /// Get a clone of all recorded `delete_release` call parameters.
330    pub fn delete_release_calls(&self) -> Vec<DeleteReleaseParams> {
331        self.delete_release_calls
332            .lock()
333            .unwrap_or_else(std::sync::PoisonError::into_inner)
334            .clone()
335    }
336
337    /// Number of times `get_release_by_tag` was called.
338    pub fn get_release_by_tag_call_count(&self) -> usize {
339        self.get_release_by_tag_calls
340            .lock()
341            .unwrap_or_else(std::sync::PoisonError::into_inner)
342            .len()
343    }
344
345    /// Get a clone of all recorded `get_release_by_tag` call parameters.
346    pub fn get_release_by_tag_calls(&self) -> Vec<GetReleaseByTagParams> {
347        self.get_release_by_tag_calls
348            .lock()
349            .unwrap_or_else(std::sync::PoisonError::into_inner)
350            .clone()
351    }
352
353    /// Number of times `delete_tag` was called.
354    pub fn delete_tag_call_count(&self) -> usize {
355        self.delete_tag_calls
356            .lock()
357            .unwrap_or_else(std::sync::PoisonError::into_inner)
358            .len()
359    }
360
361    /// Get a clone of all recorded `delete_tag` call parameters.
362    pub fn delete_tag_calls(&self) -> Vec<DeleteTagParams> {
363        self.delete_tag_calls
364            .lock()
365            .unwrap_or_else(std::sync::PoisonError::into_inner)
366            .clone()
367    }
368}
369
370#[cfg(feature = "test-helpers")]
371impl Default for MockGitHubClient {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377#[cfg(feature = "test-helpers")]
378impl GitHubClient for MockGitHubClient {
379    fn create_release(&self, params: &CreateReleaseParams) -> anyhow::Result<ReleaseInfo> {
380        self.create_release_calls
381            .lock()
382            .unwrap_or_else(std::sync::PoisonError::into_inner)
383            .push(params.clone());
384
385        match self
386            .create_release_response
387            .lock()
388            .unwrap_or_else(std::sync::PoisonError::into_inner)
389            .as_ref()
390        {
391            Some(Ok(info)) => Ok(info.clone()),
392            Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
393            None => Err(anyhow::anyhow!(
394                "MockGitHubClient: no create_release response configured"
395            )),
396        }
397    }
398
399    fn upload_asset(&self, params: &UploadAssetParams) -> anyhow::Result<AssetInfo> {
400        self.upload_asset_calls
401            .lock()
402            .unwrap_or_else(std::sync::PoisonError::into_inner)
403            .push(params.clone());
404
405        match self
406            .upload_asset_response
407            .lock()
408            .unwrap_or_else(std::sync::PoisonError::into_inner)
409            .as_ref()
410        {
411            Some(Ok(info)) => Ok(info.clone()),
412            Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
413            None => Err(anyhow::anyhow!(
414                "MockGitHubClient: no upload_asset response configured"
415            )),
416        }
417    }
418
419    fn list_releases(&self, params: &ListReleasesParams) -> anyhow::Result<Vec<ReleaseInfo>> {
420        self.list_releases_calls
421            .lock()
422            .unwrap_or_else(std::sync::PoisonError::into_inner)
423            .push(params.clone());
424
425        match self
426            .list_releases_response
427            .lock()
428            .unwrap_or_else(std::sync::PoisonError::into_inner)
429            .as_ref()
430        {
431            Some(Ok(releases)) => Ok(releases.clone()),
432            Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
433            None => Err(anyhow::anyhow!(
434                "MockGitHubClient: no list_releases response configured"
435            )),
436        }
437    }
438
439    fn delete_release(&self, params: &DeleteReleaseParams) -> anyhow::Result<()> {
440        self.delete_release_calls
441            .lock()
442            .unwrap_or_else(std::sync::PoisonError::into_inner)
443            .push(params.clone());
444
445        match self
446            .delete_release_response
447            .lock()
448            .unwrap_or_else(std::sync::PoisonError::into_inner)
449            .as_ref()
450        {
451            Some(Ok(())) => Ok(()),
452            Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
453            None => Err(anyhow::anyhow!(
454                "MockGitHubClient: no delete_release response configured"
455            )),
456        }
457    }
458
459    fn get_release_by_tag(
460        &self,
461        params: &GetReleaseByTagParams,
462    ) -> anyhow::Result<Option<ReleaseInfo>> {
463        self.get_release_by_tag_calls
464            .lock()
465            .unwrap_or_else(std::sync::PoisonError::into_inner)
466            .push(params.clone());
467
468        match self
469            .get_release_by_tag_response
470            .lock()
471            .unwrap_or_else(std::sync::PoisonError::into_inner)
472            .as_ref()
473        {
474            Some(Ok(info)) => Ok(info.clone()),
475            Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
476            None => Err(anyhow::anyhow!(
477                "MockGitHubClient: no get_release_by_tag response configured"
478            )),
479        }
480    }
481
482    fn delete_tag(&self, params: &DeleteTagParams) -> anyhow::Result<()> {
483        self.delete_tag_calls
484            .lock()
485            .unwrap_or_else(std::sync::PoisonError::into_inner)
486            .push(params.clone());
487
488        match self
489            .delete_tag_response
490            .lock()
491            .unwrap_or_else(std::sync::PoisonError::into_inner)
492            .as_ref()
493        {
494            Some(Ok(())) => Ok(()),
495            Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
496            None => Err(anyhow::anyhow!(
497                "MockGitHubClient: no delete_tag response configured"
498            )),
499        }
500    }
501}
502
503// ---------------------------------------------------------------------------
504// Tests
505// ---------------------------------------------------------------------------
506
507#[cfg(all(test, feature = "test-helpers"))]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_mock_records_create_release_calls() {
513        let mock = MockGitHubClient::new();
514        mock.set_create_release_response(Ok(ReleaseInfo {
515            id: 42,
516            html_url: "https://github.com/owner/repo/releases/42".to_string(),
517            tag_name: "v1.0.0".to_string(),
518            name: Some("Release v1.0.0".to_string()),
519            draft: false,
520        }));
521
522        let params = CreateReleaseParams {
523            owner: "owner".to_string(),
524            repo: "repo".to_string(),
525            tag_name: "v1.0.0".to_string(),
526            name: "Release v1.0.0".to_string(),
527            body: "Changelog here".to_string(),
528            draft: false,
529            prerelease: false,
530            generate_release_notes: false,
531            make_latest: None,
532        };
533
534        let result = mock.create_release(&params).unwrap();
535        assert_eq!(result.id, 42);
536        assert_eq!(result.tag_name, "v1.0.0");
537        assert_eq!(mock.create_release_call_count(), 1);
538
539        let calls = mock.create_release_calls();
540        assert_eq!(calls[0].owner, "owner");
541        assert_eq!(calls[0].tag_name, "v1.0.0");
542    }
543
544    #[test]
545    fn test_mock_records_upload_asset_calls() {
546        let mock = MockGitHubClient::new();
547        mock.set_upload_asset_response(Ok(AssetInfo {
548            id: 100,
549            name: "myapp-linux-amd64.tar.gz".to_string(),
550            size: 4096,
551        }));
552
553        let params = UploadAssetParams {
554            owner: "owner".to_string(),
555            repo: "repo".to_string(),
556            release_id: 42,
557            file_name: "myapp-linux-amd64.tar.gz".to_string(),
558            file_path: PathBuf::from("/tmp/myapp-linux-amd64.tar.gz"),
559        };
560
561        let result = mock.upload_asset(&params).unwrap();
562        assert_eq!(result.name, "myapp-linux-amd64.tar.gz");
563        assert_eq!(mock.upload_asset_call_count(), 1);
564    }
565
566    #[test]
567    fn test_mock_records_list_releases_calls() {
568        let mock = MockGitHubClient::new();
569        mock.set_list_releases_response(Ok(vec![ReleaseInfo {
570            id: 1,
571            html_url: "https://github.com/owner/repo/releases/1".to_string(),
572            tag_name: "v0.9.0".to_string(),
573            name: Some("Release v0.9.0".to_string()),
574            draft: false,
575        }]));
576
577        let params = ListReleasesParams {
578            owner: "owner".to_string(),
579            repo: "repo".to_string(),
580        };
581
582        let result = mock.list_releases(&params).unwrap();
583        assert_eq!(result.len(), 1);
584        assert_eq!(mock.list_releases_call_count(), 1);
585    }
586
587    #[test]
588    fn test_mock_records_delete_release_calls() {
589        let mock = MockGitHubClient::new();
590        mock.set_delete_release_response(Ok(()));
591
592        let params = DeleteReleaseParams {
593            owner: "owner".to_string(),
594            repo: "repo".to_string(),
595            release_id: 42,
596        };
597
598        mock.delete_release(&params).unwrap();
599        assert_eq!(mock.delete_release_call_count(), 1);
600
601        let calls = mock.delete_release_calls();
602        assert_eq!(calls[0].release_id, 42);
603    }
604
605    #[test]
606    fn test_mock_returns_error_when_no_response_configured() {
607        let mock = MockGitHubClient::new();
608
609        let params = CreateReleaseParams {
610            owner: "owner".to_string(),
611            repo: "repo".to_string(),
612            tag_name: "v1.0.0".to_string(),
613            name: "Release".to_string(),
614            body: "".to_string(),
615            draft: false,
616            prerelease: false,
617            generate_release_notes: false,
618            make_latest: None,
619        };
620
621        let result = mock.create_release(&params);
622        assert!(result.is_err());
623        assert!(
624            result
625                .unwrap_err()
626                .to_string()
627                .contains("no create_release response configured")
628        );
629    }
630
631    #[test]
632    fn test_mock_returns_configured_error() {
633        let mock = MockGitHubClient::new();
634        mock.set_create_release_response(Err("API rate limit exceeded".to_string()));
635
636        let params = CreateReleaseParams {
637            owner: "owner".to_string(),
638            repo: "repo".to_string(),
639            tag_name: "v1.0.0".to_string(),
640            name: "Release".to_string(),
641            body: "".to_string(),
642            draft: false,
643            prerelease: false,
644            generate_release_notes: false,
645            make_latest: None,
646        };
647
648        let result = mock.create_release(&params);
649        assert!(result.is_err());
650        assert!(
651            result
652                .unwrap_err()
653                .to_string()
654                .contains("API rate limit exceeded")
655        );
656    }
657
658    #[test]
659    fn test_mock_multiple_calls_accumulate() {
660        let mock = MockGitHubClient::new();
661        mock.set_delete_release_response(Ok(()));
662
663        for i in 1..=3 {
664            let params = DeleteReleaseParams {
665                owner: "owner".to_string(),
666                repo: "repo".to_string(),
667                release_id: i,
668            };
669            mock.delete_release(&params).unwrap();
670        }
671
672        assert_eq!(mock.delete_release_call_count(), 3);
673        let calls = mock.delete_release_calls();
674        assert_eq!(calls[0].release_id, 1);
675        assert_eq!(calls[1].release_id, 2);
676        assert_eq!(calls[2].release_id, 3);
677    }
678
679    #[test]
680    fn test_mock_default_is_same_as_new() {
681        let mock = MockGitHubClient::default();
682        assert_eq!(mock.create_release_call_count(), 0);
683        assert_eq!(mock.upload_asset_call_count(), 0);
684        assert_eq!(mock.list_releases_call_count(), 0);
685        assert_eq!(mock.delete_release_call_count(), 0);
686    }
687}