Skip to main content

github_actions_maintainer/
github.rs

1use std::{thread, time::Duration};
2
3use anyhow::{Context, Result, anyhow, bail};
4use reqwest::{
5    StatusCode,
6    blocking::{Client, RequestBuilder, Response},
7    header::{AUTHORIZATION, HeaderMap, HeaderValue, RETRY_AFTER, USER_AGENT},
8};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone)]
12pub struct GitHubClient {
13    base_url: String,
14    token: Option<String>,
15    client: Client,
16    max_retries: u32,
17    retry_delay: Duration,
18    max_retry_delay: Duration,
19}
20
21#[derive(Debug, Deserialize)]
22struct CommitResponse {
23    sha: String,
24}
25
26#[derive(Debug, Clone, Eq, PartialEq)]
27pub struct LatestReference {
28    pub version: String,
29    pub sha: String,
30}
31
32#[derive(Debug, Deserialize)]
33struct LatestReleaseResponse {
34    tag_name: String,
35}
36
37#[derive(Debug, Deserialize)]
38struct TagResponse {
39    name: String,
40    commit: Option<CommitResponse>,
41}
42
43#[derive(Debug, Clone)]
44pub struct GitHubClientOptions {
45    pub base_url: String,
46    pub token: Option<String>,
47    pub timeout: Duration,
48    pub max_retries: u32,
49    pub retry_delay: Duration,
50    pub max_retry_delay: Duration,
51}
52
53impl Default for GitHubClientOptions {
54    fn default() -> Self {
55        Self {
56            base_url: String::from("https://api.github.com"),
57            token: None,
58            timeout: Duration::from_secs(30),
59            max_retries: 3,
60            retry_delay: Duration::from_secs(1),
61            max_retry_delay: Duration::from_mins(1),
62        }
63    }
64}
65
66#[derive(Debug, Clone, Eq, PartialEq)]
67pub struct TreeEntry {
68    pub path: String,
69    pub sha: String,
70}
71
72#[derive(Debug, Clone, Eq, PartialEq)]
73pub struct PullRequestInfo {
74    pub number: u64,
75    pub url: String,
76}
77
78#[derive(Debug, Clone, Eq, PartialEq)]
79pub struct TagInfo {
80    pub name: String,
81    pub sha: Option<String>,
82}
83
84#[derive(Debug, Clone, Eq, PartialEq)]
85pub struct CommitInfo {
86    pub sha: String,
87    pub message: String,
88    pub is_merge: bool,
89}
90
91#[derive(Debug, Clone, Eq, PartialEq)]
92pub struct CommitRange {
93    pub commits: Vec<CommitInfo>,
94    pub truncated: bool,
95}
96
97#[derive(Debug, Clone, Eq, PartialEq)]
98pub struct ReleaseInfo {
99    pub url: String,
100}
101
102#[derive(Debug, Deserialize)]
103struct RepositoryResponse {
104    default_branch: String,
105}
106
107#[derive(Debug, Deserialize)]
108struct ReferenceResponse {
109    object: ReferenceObject,
110}
111
112#[derive(Debug, Deserialize)]
113struct ReferenceObject {
114    sha: String,
115}
116
117#[derive(Debug, Deserialize)]
118struct CommitTreeResponse {
119    tree: CommitTreeObject,
120}
121
122#[derive(Debug, Deserialize)]
123struct CommitTreeObject {
124    sha: String,
125}
126
127#[derive(Debug, Deserialize)]
128struct BlobResponse {
129    sha: String,
130}
131
132#[derive(Debug, Deserialize)]
133struct TreeResponse {
134    sha: String,
135}
136
137#[derive(Debug, Deserialize)]
138struct CreatedCommitResponse {
139    sha: String,
140}
141
142#[derive(Debug, Deserialize)]
143struct PullRequestResponse {
144    number: u64,
145    html_url: String,
146}
147
148#[derive(Debug, Deserialize)]
149struct UserResponse {
150    login: Option<String>,
151}
152
153#[derive(Debug, Deserialize)]
154struct CompareResponse {
155    total_commits: u64,
156    commits: Vec<RepoCommitResponse>,
157}
158
159#[derive(Debug, Deserialize)]
160struct RepoCommitResponse {
161    sha: String,
162    commit: RepoCommitDetail,
163    parents: Vec<CommitParent>,
164}
165
166#[derive(Debug, Deserialize)]
167struct RepoCommitDetail {
168    message: String,
169}
170
171#[derive(Debug, Deserialize)]
172struct CommitParent {}
173
174#[derive(Debug, Deserialize)]
175struct CreatedReleaseResponse {
176    html_url: String,
177}
178
179#[derive(Debug, Serialize)]
180struct CreateReferenceRequest<'a> {
181    #[serde(rename = "ref")]
182    reference: &'a str,
183    sha: &'a str,
184}
185
186#[derive(Debug, Serialize)]
187struct CreateBlobRequest<'a> {
188    content: &'a str,
189    encoding: &'a str,
190}
191
192#[derive(Debug, Serialize)]
193struct CreateTreeRequest<'a> {
194    base_tree: &'a str,
195    tree: Vec<CreateTreeEntry<'a>>,
196}
197
198#[derive(Debug, Serialize)]
199struct CreateTreeEntry<'a> {
200    path: &'a str,
201    mode: &'a str,
202    #[serde(rename = "type")]
203    object_type: &'a str,
204    sha: &'a str,
205}
206
207#[derive(Debug, Serialize)]
208struct CreateCommitRequest<'a> {
209    message: &'a str,
210    tree: &'a str,
211    parents: Vec<&'a str>,
212}
213
214#[derive(Debug, Serialize)]
215struct UpdateReferenceRequest<'a> {
216    sha: &'a str,
217    force: bool,
218}
219
220#[derive(Debug, Serialize)]
221struct CreatePullRequestRequest<'a> {
222    title: &'a str,
223    body: &'a str,
224    head: &'a str,
225    base: &'a str,
226}
227
228#[derive(Debug, Serialize)]
229struct UpdatePullRequestRequest<'a> {
230    title: &'a str,
231    body: &'a str,
232}
233
234#[derive(Debug, Serialize)]
235struct AddLabelsRequest<'a> {
236    labels: &'a [String],
237}
238
239#[derive(Debug, Serialize)]
240struct CreateReleaseRequest<'a> {
241    tag_name: &'a str,
242    target_commitish: &'a str,
243    name: &'a str,
244    body: &'a str,
245}
246
247#[derive(Debug, Serialize)]
248struct CreateTagObjectRequest<'a> {
249    tag: &'a str,
250    message: &'a str,
251    object: &'a str,
252    #[serde(rename = "type")]
253    object_type: &'a str,
254}
255
256#[derive(Debug, Deserialize)]
257struct CreatedTagObjectResponse {
258    sha: String,
259}
260
261impl GitHubClient {
262    pub fn new(base_url: impl Into<String>, token: Option<String>) -> Result<Self> {
263        Self::with_options(GitHubClientOptions {
264            base_url: base_url.into(),
265            token,
266            ..GitHubClientOptions::default()
267        })
268    }
269
270    pub fn with_options(options: GitHubClientOptions) -> Result<Self> {
271        let GitHubClientOptions {
272            base_url,
273            token,
274            timeout,
275            max_retries,
276            retry_delay,
277            max_retry_delay,
278        } = options;
279        let mut headers = HeaderMap::new();
280        headers.insert(USER_AGENT, HeaderValue::from_static("github-actions-maintainer"));
281
282        let client = Client::builder()
283            .default_headers(headers)
284            .timeout(timeout)
285            .build()
286            .context("failed to build GitHub HTTP client")?;
287
288        Ok(Self {
289            base_url: base_url.trim_end_matches('/').to_owned(),
290            token: token.as_deref().and_then(normalize_token),
291            client,
292            max_retries,
293            retry_delay,
294            max_retry_delay,
295        })
296    }
297
298    pub fn latest_reference(&self, owner: &str, repository: &str) -> Result<LatestReference> {
299        if let Some(version) = self.latest_release_tag(owner, repository)? {
300            let sha = self.resolve_reference(owner, repository, &version)?;
301            return Ok(LatestReference { version, sha });
302        }
303
304        let tags = self
305            .get_with_retry(&format!("/repos/{owner}/{repository}/tags?per_page=1"), || {
306                format!("fetch tags for {owner}/{repository}")
307            })?;
308        let mut tags = tags
309            .json::<Vec<TagResponse>>()
310            .with_context(|| format!("failed to decode tags response for {owner}/{repository}"))?;
311
312        let tag = tags.pop().ok_or_else(|| {
313            anyhow::anyhow!("GitHub did not return any tags for {owner}/{repository}")
314        })?;
315        let sha = if let Some(commit) = tag.commit {
316            commit.sha
317        } else {
318            self.resolve_reference(owner, repository, &tag.name)?
319        };
320
321        Ok(LatestReference { version: tag.name, sha })
322    }
323
324    pub fn resolve_reference(
325        &self,
326        owner: &str,
327        repository: &str,
328        reference: &str,
329    ) -> Result<String> {
330        let encoded_reference = urlencoding::encode(reference);
331        let response = self.get_with_retry(
332            &format!("/repos/{owner}/{repository}/commits/{encoded_reference}"),
333            || format!("resolve {owner}/{repository}@{reference}"),
334        )?;
335        let commit = response.json::<CommitResponse>().with_context(|| {
336            format!("failed to decode commit response for {owner}/{repository}@{reference}")
337        })?;
338
339        Ok(commit.sha)
340    }
341
342    fn latest_release_tag(&self, owner: &str, repository: &str) -> Result<Option<String>> {
343        let response = self.get_with_retry_allowing_not_found(
344            &format!("/repos/{owner}/{repository}/releases/latest"),
345            || format!("fetch latest release for {owner}/{repository}"),
346        )?;
347        let Some(response) = response else {
348            return Ok(None);
349        };
350        let release = response.json::<LatestReleaseResponse>().with_context(|| {
351            format!("failed to decode release response for {owner}/{repository}")
352        })?;
353
354        Ok(Some(release.tag_name))
355    }
356
357    pub fn validate_token_scopes(&self) -> Result<()> {
358        let token = self
359            .token
360            .as_deref()
361            .ok_or_else(|| anyhow!("a GitHub token is required for remote PR creation"))?;
362
363        let response = self.send_with_retry(
364            || self.get("/user").header(AUTHORIZATION, format!("Bearer {token}")),
365            || String::from("validate GitHub token scopes"),
366        )?;
367        let headers = response.headers().clone();
368        let user =
369            response.json::<UserResponse>().context("failed to decode GitHub user response")?;
370
371        if user.login.is_none() {
372            bail!("failed to validate GitHub token: authenticated user is missing");
373        }
374
375        let Some(scopes) = headers.get("x-oauth-scopes").and_then(|value| value.to_str().ok())
376        else {
377            return Ok(());
378        };
379
380        let has_repo_scope = scopes.contains("repo") || scopes.contains("public_repo");
381        if !has_repo_scope {
382            bail!("GitHub token is missing the repo or public_repo scope");
383        }
384        if !scopes.contains("workflow") {
385            bail!("GitHub token is missing the workflow scope");
386        }
387
388        Ok(())
389    }
390
391    pub fn default_branch(&self, owner: &str, repository: &str) -> Result<String> {
392        let response = self.get_with_retry(&format!("/repos/{owner}/{repository}"), || {
393            format!("fetch repository metadata for {owner}/{repository}")
394        })?;
395        let repository = response.json::<RepositoryResponse>().with_context(|| {
396            format!("failed to decode repository response for {owner}/{repository}")
397        })?;
398        Ok(repository.default_branch)
399    }
400
401    pub fn branch_head_sha(&self, owner: &str, repository: &str, branch: &str) -> Result<String> {
402        let response = self.get_with_retry(
403            &format!("/repos/{owner}/{repository}/git/ref/heads/{branch}"),
404            || format!("fetch branch ref for {owner}/{repository}:{branch}"),
405        )?;
406        let reference = response.json::<ReferenceResponse>().with_context(|| {
407            format!("failed to decode branch ref for {owner}/{repository}:{branch}")
408        })?;
409        Ok(reference.object.sha)
410    }
411
412    pub fn commit_tree_sha(
413        &self,
414        owner: &str,
415        repository: &str,
416        commit_sha: &str,
417    ) -> Result<String> {
418        let response = self.get_with_retry(
419            &format!("/repos/{owner}/{repository}/git/commits/{commit_sha}"),
420            || format!("fetch commit tree for {owner}/{repository}@{commit_sha}"),
421        )?;
422        let commit = response.json::<CommitTreeResponse>().with_context(|| {
423            format!("failed to decode commit tree for {owner}/{repository}@{commit_sha}")
424        })?;
425        Ok(commit.tree.sha)
426    }
427
428    pub fn create_branch(
429        &self,
430        owner: &str,
431        repository: &str,
432        branch: &str,
433        base_sha: &str,
434    ) -> Result<()> {
435        self.create_ref(owner, repository, &format!("heads/{branch}"), base_sha)
436    }
437
438    pub fn create_ref(
439        &self,
440        owner: &str,
441        repository: &str,
442        ref_path: &str,
443        sha: &str,
444    ) -> Result<()> {
445        let reference = format!("refs/{ref_path}");
446        let payload = CreateReferenceRequest { reference: &reference, sha };
447        self.post_json(&format!("/repos/{owner}/{repository}/git/refs"), &payload, || {
448            format!("create ref {ref_path} for {owner}/{repository}")
449        })?;
450        Ok(())
451    }
452
453    /// Create an annotated tag object pointing at `commit_sha` and return the
454    /// tag object's SHA. The tagger identity derives from the token. A ref
455    /// must still be created separately to make the tag reachable.
456    pub fn create_annotated_tag(
457        &self,
458        owner: &str,
459        repository: &str,
460        tag: &str,
461        message: &str,
462        commit_sha: &str,
463    ) -> Result<String> {
464        let payload =
465            CreateTagObjectRequest { tag, message, object: commit_sha, object_type: "commit" };
466        let response =
467            self.post_json(&format!("/repos/{owner}/{repository}/git/tags"), &payload, || {
468                format!("create annotated tag {tag} for {owner}/{repository}")
469            })?;
470        let created = response.json::<CreatedTagObjectResponse>().with_context(|| {
471            format!("failed to decode tag object response for {owner}/{repository}")
472        })?;
473        Ok(created.sha)
474    }
475
476    pub fn reference_sha(
477        &self,
478        owner: &str,
479        repository: &str,
480        ref_path: &str,
481    ) -> Result<Option<String>> {
482        let response = self.get_with_retry_allowing_not_found(
483            &format!("/repos/{owner}/{repository}/git/ref/{ref_path}"),
484            || format!("fetch ref {ref_path} for {owner}/{repository}"),
485        )?;
486        let Some(response) = response else {
487            return Ok(None);
488        };
489        let reference = response
490            .json::<ReferenceResponse>()
491            .with_context(|| format!("failed to decode ref {ref_path} for {owner}/{repository}"))?;
492        Ok(Some(reference.object.sha))
493    }
494
495    pub fn create_blob(&self, owner: &str, repository: &str, content: &str) -> Result<String> {
496        let payload = CreateBlobRequest { content, encoding: "utf-8" };
497        let response =
498            self.post_json(&format!("/repos/{owner}/{repository}/git/blobs"), &payload, || {
499                format!("create blob for {owner}/{repository}")
500            })?;
501        let blob = response
502            .json::<BlobResponse>()
503            .with_context(|| format!("failed to decode blob response for {owner}/{repository}"))?;
504        Ok(blob.sha)
505    }
506
507    pub fn create_tree(
508        &self,
509        owner: &str,
510        repository: &str,
511        base_tree_sha: &str,
512        entries: &[TreeEntry],
513    ) -> Result<String> {
514        let payload = CreateTreeRequest {
515            base_tree: base_tree_sha,
516            tree: entries
517                .iter()
518                .map(|entry| CreateTreeEntry {
519                    path: &entry.path,
520                    mode: "100644",
521                    object_type: "blob",
522                    sha: &entry.sha,
523                })
524                .collect(),
525        };
526        let response =
527            self.post_json(&format!("/repos/{owner}/{repository}/git/trees"), &payload, || {
528                format!("create tree for {owner}/{repository}")
529            })?;
530        let tree = response
531            .json::<TreeResponse>()
532            .with_context(|| format!("failed to decode tree response for {owner}/{repository}"))?;
533        Ok(tree.sha)
534    }
535
536    pub fn create_commit(
537        &self,
538        owner: &str,
539        repository: &str,
540        message: &str,
541        tree_sha: &str,
542        parent_sha: &str,
543    ) -> Result<String> {
544        let payload = CreateCommitRequest { message, tree: tree_sha, parents: vec![parent_sha] };
545        let response =
546            self.post_json(&format!("/repos/{owner}/{repository}/git/commits"), &payload, || {
547                format!("create commit for {owner}/{repository}")
548            })?;
549        let commit = response.json::<CreatedCommitResponse>().with_context(|| {
550            format!("failed to decode commit response for {owner}/{repository}")
551        })?;
552        Ok(commit.sha)
553    }
554
555    pub fn update_branch(
556        &self,
557        owner: &str,
558        repository: &str,
559        branch: &str,
560        commit_sha: &str,
561    ) -> Result<()> {
562        self.update_ref(owner, repository, &format!("heads/{branch}"), commit_sha, false)
563    }
564
565    pub fn update_ref(
566        &self,
567        owner: &str,
568        repository: &str,
569        ref_path: &str,
570        sha: &str,
571        force: bool,
572    ) -> Result<()> {
573        let payload = UpdateReferenceRequest { sha, force };
574        self.patch_json(
575            &format!("/repos/{owner}/{repository}/git/refs/{ref_path}"),
576            &payload,
577            || format!("update ref {ref_path} for {owner}/{repository}"),
578        )?;
579        Ok(())
580    }
581
582    /// Fast-forward `ref_path` to `sha`; returns `Ok(false)` when GitHub rejects
583    /// the update because it is not a fast forward (HTTP 422).
584    pub fn update_ref_fast_forward(
585        &self,
586        owner: &str,
587        repository: &str,
588        ref_path: &str,
589        sha: &str,
590    ) -> Result<bool> {
591        let payload = UpdateReferenceRequest { sha, force: false };
592        let response = self.send_with_retry_allowing_non_fast_forward(
593            || {
594                self.client
595                    .patch(format!(
596                        "{}/repos/{owner}/{repository}/git/refs/{ref_path}",
597                        self.base_url
598                    ))
599                    .with_auth(self)
600                    .json(&payload)
601            },
602            || format!("fast-forward ref {ref_path} for {owner}/{repository}"),
603        )?;
604        Ok(response.is_some())
605    }
606
607    pub fn list_tags(&self, owner: &str, repository: &str, max_pages: u32) -> Result<Vec<TagInfo>> {
608        let mut tags = Vec::new();
609        for page in 1..=max_pages {
610            let response = self.get_with_retry(
611                &format!("/repos/{owner}/{repository}/tags?per_page=100&page={page}"),
612                || format!("list tags for {owner}/{repository}"),
613            )?;
614            let page_tags = response.json::<Vec<TagResponse>>().with_context(|| {
615                format!("failed to decode tags response for {owner}/{repository}")
616            })?;
617            let page_len = page_tags.len();
618            tags.extend(
619                page_tags.into_iter().map(|tag| TagInfo {
620                    name: tag.name,
621                    sha: tag.commit.map(|commit| commit.sha),
622                }),
623            );
624            if page_len < 100 {
625                break;
626            }
627        }
628        Ok(tags)
629    }
630
631    /// Latest tag whose name is `prefix` followed by a semver version,
632    /// scanning up to 1000 tags.
633    pub fn latest_semver_tag(
634        &self,
635        owner: &str,
636        repository: &str,
637        prefix: &str,
638    ) -> Result<Option<TagInfo>> {
639        const MAX_TAG_PAGES: u32 = 10;
640        let tags = self.list_tags(owner, repository, MAX_TAG_PAGES)?;
641        Ok(tags
642            .into_iter()
643            .filter_map(|tag| {
644                let version = tag.name.strip_prefix(prefix)?;
645                let parsed = semver::Version::parse(version).ok()?;
646                Some((parsed, tag))
647            })
648            .max_by(|(left, _), (right, _)| left.cmp(right))
649            .map(|(_, tag)| tag))
650    }
651
652    pub fn compare_commits(
653        &self,
654        owner: &str,
655        repository: &str,
656        base: &str,
657        head: &str,
658        max_pages: u32,
659    ) -> Result<CommitRange> {
660        let encoded_base = urlencoding::encode(base);
661        let encoded_head = urlencoding::encode(head);
662        let mut commits = Vec::new();
663        let mut total_commits = 0usize;
664        for page in 1..=max_pages {
665            let response = self.get_with_retry(
666                &format!(
667                    "/repos/{owner}/{repository}/compare/{encoded_base}...{encoded_head}?per_page=100&page={page}"
668                ),
669                || format!("compare {base}...{head} for {owner}/{repository}"),
670            )?;
671            let compare = response.json::<CompareResponse>().with_context(|| {
672                format!("failed to decode compare response for {owner}/{repository}")
673            })?;
674            total_commits = usize::try_from(compare.total_commits).unwrap_or(usize::MAX);
675            // The compare endpoint caps the commit list; once pages come back
676            // empty, further requests cannot make progress.
677            if compare.commits.is_empty() {
678                break;
679            }
680            commits.extend(compare.commits.into_iter().map(commit_info_from_response));
681            if commits.len() >= total_commits {
682                break;
683            }
684        }
685        let truncated = commits.len() < total_commits;
686        Ok(CommitRange { commits, truncated })
687    }
688
689    pub fn list_commits(
690        &self,
691        owner: &str,
692        repository: &str,
693        head_sha: &str,
694        max_pages: u32,
695    ) -> Result<CommitRange> {
696        let encoded_head = urlencoding::encode(head_sha);
697        let mut commits = Vec::new();
698        let mut last_page_full = false;
699        for page in 1..=max_pages {
700            let response = self.get_with_retry(
701                &format!(
702                    "/repos/{owner}/{repository}/commits?sha={encoded_head}&per_page=100&page={page}"
703                ),
704                || format!("list commits for {owner}/{repository}"),
705            )?;
706            let page_commits = response.json::<Vec<RepoCommitResponse>>().with_context(|| {
707                format!("failed to decode commits response for {owner}/{repository}")
708            })?;
709            last_page_full = page_commits.len() == 100;
710            commits.extend(page_commits.into_iter().map(commit_info_from_response));
711            if !last_page_full {
712                break;
713            }
714        }
715        Ok(CommitRange { commits, truncated: last_page_full })
716    }
717
718    pub fn create_release(
719        &self,
720        owner: &str,
721        repository: &str,
722        tag_name: &str,
723        name: &str,
724        body: &str,
725        target_commitish: &str,
726    ) -> Result<ReleaseInfo> {
727        let payload = CreateReleaseRequest { tag_name, target_commitish, name, body };
728        let response =
729            self.post_json(&format!("/repos/{owner}/{repository}/releases"), &payload, || {
730                format!("create release {tag_name} for {owner}/{repository}")
731            })?;
732        let release = response.json::<CreatedReleaseResponse>().with_context(|| {
733            format!("failed to decode release response for {owner}/{repository}")
734        })?;
735        Ok(ReleaseInfo { url: release.html_url })
736    }
737
738    pub fn ensure_token(&self) -> Result<()> {
739        if self.token.is_none() {
740            bail!("a GitHub token is required to create releases; provide --token or GITHUB_TOKEN");
741        }
742        Ok(())
743    }
744
745    pub fn create_pull_request(
746        &self,
747        owner: &str,
748        repository: &str,
749        title: &str,
750        body: &str,
751        head: &str,
752        base: &str,
753    ) -> Result<PullRequestInfo> {
754        let payload = CreatePullRequestRequest { title, body, head, base };
755        let response =
756            self.post_json(&format!("/repos/{owner}/{repository}/pulls"), &payload, || {
757                format!("create pull request for {owner}/{repository}")
758            })?;
759        let pull_request = response.json::<PullRequestResponse>().with_context(|| {
760            format!("failed to decode pull request response for {owner}/{repository}")
761        })?;
762        Ok(PullRequestInfo { number: pull_request.number, url: pull_request.html_url })
763    }
764
765    pub fn find_open_pull_request(
766        &self,
767        owner: &str,
768        repository: &str,
769        head: &str,
770        base: &str,
771    ) -> Result<Option<PullRequestInfo>> {
772        let encoded_head = urlencoding::encode(&format!("{owner}:{head}")).into_owned();
773        let encoded_base = urlencoding::encode(base).into_owned();
774        let response = self.send_with_retry(
775            || self.get(&format!(
776                "/repos/{owner}/{repository}/pulls?state=open&head={encoded_head}&base={encoded_base}&per_page=100"
777            )),
778            || format!("find open release pull request for {owner}/{repository}"),
779        )?;
780        let pull_requests = response.json::<Vec<PullRequestResponse>>().with_context(|| {
781            format!("failed to decode pull request list for {owner}/{repository}")
782        })?;
783        Ok(pull_requests.into_iter().next().map(|pull_request| PullRequestInfo {
784            number: pull_request.number,
785            url: pull_request.html_url,
786        }))
787    }
788
789    pub fn update_pull_request(
790        &self,
791        owner: &str,
792        repository: &str,
793        number: u64,
794        title: &str,
795        body: &str,
796    ) -> Result<PullRequestInfo> {
797        let payload = UpdatePullRequestRequest { title, body };
798        let response = self.patch_json(
799            &format!("/repos/{owner}/{repository}/pulls/{number}"),
800            &payload,
801            || format!("update pull request {number} for {owner}/{repository}"),
802        )?;
803        let pull_request = response.json::<PullRequestResponse>().with_context(|| {
804            format!("failed to decode updated pull request response for {owner}/{repository}")
805        })?;
806        Ok(PullRequestInfo { number: pull_request.number, url: pull_request.html_url })
807    }
808
809    pub fn add_labels(
810        &self,
811        owner: &str,
812        repository: &str,
813        issue_number: u64,
814        labels: &[String],
815    ) -> Result<()> {
816        let payload = AddLabelsRequest { labels };
817        self.post_json(
818            &format!("/repos/{owner}/{repository}/issues/{issue_number}/labels"),
819            &payload,
820            || format!("add labels to issue {issue_number} for {owner}/{repository}"),
821        )?;
822        Ok(())
823    }
824
825    fn get(&self, path: &str) -> RequestBuilder {
826        self.get_anonymous(path).with_auth(self)
827    }
828
829    fn get_anonymous(&self, path: &str) -> RequestBuilder {
830        self.client.get(format!("{}{}", self.base_url, path))
831    }
832
833    fn post_json<T: Serialize, F>(&self, path: &str, payload: &T, describe: F) -> Result<Response>
834    where
835        F: Fn() -> String,
836    {
837        self.send_with_retry(
838            || self.client.post(format!("{}{}", self.base_url, path)).with_auth(self).json(payload),
839            describe,
840        )
841    }
842
843    fn patch_json<T: Serialize, F>(&self, path: &str, payload: &T, describe: F) -> Result<Response>
844    where
845        F: Fn() -> String,
846    {
847        self.send_with_retry(
848            || {
849                self.client
850                    .patch(format!("{}{}", self.base_url, path))
851                    .with_auth(self)
852                    .json(payload)
853            },
854            describe,
855        )
856    }
857
858    /// Send with retry/backoff and return the first non-retryable response,
859    /// whatever its status. Status-specific handling lives in the wrappers.
860    fn send_raw_with_retry<F, D>(&self, mut build_request: F, describe: &D) -> Result<Response>
861    where
862        F: FnMut() -> RequestBuilder,
863        D: Fn() -> String,
864    {
865        let mut attempt = 0u32;
866
867        loop {
868            match build_request().send() {
869                Ok(response) => {
870                    if Self::should_retry_response(&response) && attempt < self.max_retries {
871                        self.sleep_for_retry(response.headers(), attempt);
872                        attempt += 1;
873                        continue;
874                    }
875                    return Ok(response);
876                }
877                Err(error) => {
878                    if (error.is_timeout() || error.is_connect()) && attempt < self.max_retries {
879                        thread::sleep(self.calculate_backoff(attempt));
880                        attempt += 1;
881                        continue;
882                    }
883                    return Err(error).with_context(describe);
884                }
885            }
886        }
887    }
888
889    fn send_with_retry<F, D>(&self, build_request: F, describe: D) -> Result<Response>
890    where
891        F: FnMut() -> RequestBuilder,
892        D: Fn() -> String,
893    {
894        let response = self.send_raw_with_retry(build_request, &describe)?;
895        if response.status().is_success() {
896            return Ok(response);
897        }
898        self.error_from_response(response, &describe())
899    }
900
901    /// GET public metadata, returning the response whatever its status.
902    ///
903    /// GitHub rejects *authenticated* requests with 403 when the owning
904    /// organization enables an IP allow list that does not cover the caller,
905    /// even though the same data is readable without a token. Retry such a
906    /// request once with no `Authorization` header and use the anonymous
907    /// response when it succeeds; otherwise report the original 403.
908    ///
909    /// Only 403 falls back: 401 means the token itself is bad and must stay
910    /// loud, and 404 is left to the caller. Rate-limit 403s keep the existing
911    /// backoff-then-report path instead of burning an anonymous request that
912    /// would face a lower limit.
913    fn send_read_with_retry<D>(&self, path: &str, describe: &D) -> Result<Response>
914    where
915        D: Fn() -> String,
916    {
917        let response = self.send_raw_with_retry(|| self.get(path), describe)?;
918        if self.token.is_none()
919            || response.status() != StatusCode::FORBIDDEN
920            || Self::should_retry_response(&response)
921        {
922            return Ok(response);
923        }
924
925        let body = response.text().unwrap_or_else(|_| String::from("<response body unavailable>"));
926        if body.to_ascii_lowercase().contains("rate limit") {
927            bail!("{}: GitHub API returned {} ({body})", describe(), StatusCode::FORBIDDEN);
928        }
929
930        let anonymous_outcome =
931            match self.send_raw_with_retry(|| self.get_anonymous(path), describe) {
932                Ok(anonymous) if anonymous.status().is_success() => return Ok(anonymous),
933                Ok(anonymous) => format!("returned {}", anonymous.status()),
934                Err(error) => format!("failed: {error}"),
935            };
936        bail!(
937            "{}: GitHub API returned {} ({body}); the anonymous retry without the token also {anonymous_outcome}",
938            describe(),
939            StatusCode::FORBIDDEN,
940        )
941    }
942
943    fn get_with_retry<D>(&self, path: &str, describe: D) -> Result<Response>
944    where
945        D: Fn() -> String,
946    {
947        let response = self.send_read_with_retry(path, &describe)?;
948        if response.status().is_success() {
949            return Ok(response);
950        }
951        self.error_from_response(response, &describe())
952    }
953
954    fn get_with_retry_allowing_not_found<D>(
955        &self,
956        path: &str,
957        describe: D,
958    ) -> Result<Option<Response>>
959    where
960        D: Fn() -> String,
961    {
962        let response = self.send_read_with_retry(path, &describe)?;
963        if response.status() == StatusCode::NOT_FOUND {
964            return Ok(None);
965        }
966        if response.status().is_success() {
967            return Ok(Some(response));
968        }
969        self.error_from_response(response, &describe()).map(Some)
970    }
971
972    /// Like `send_with_retry`, but a 422 whose body reports a non-fast-forward
973    /// ref update returns `Ok(None)`. Any other 422 is still an error so
974    /// validation failures (bad SHA, invalid ref name) surface loudly.
975    fn send_with_retry_allowing_non_fast_forward<D>(
976        &self,
977        build_request: impl FnMut() -> RequestBuilder,
978        describe: D,
979    ) -> Result<Option<Response>>
980    where
981        D: Fn() -> String,
982    {
983        let response = self.send_raw_with_retry(build_request, &describe)?;
984        if response.status() == StatusCode::UNPROCESSABLE_ENTITY {
985            let body =
986                response.text().unwrap_or_else(|_| String::from("<response body unavailable>"));
987            if body.to_ascii_lowercase().contains("fast forward") {
988                return Ok(None);
989            }
990            bail!("{}: GitHub API returned 422 Unprocessable Entity ({body})", describe());
991        }
992        if response.status().is_success() {
993            return Ok(Some(response));
994        }
995        self.error_from_response(response, &describe()).map(Some)
996    }
997
998    fn should_retry_response(response: &Response) -> bool {
999        if response.status() == StatusCode::TOO_MANY_REQUESTS || response.status().is_server_error()
1000        {
1001            return true;
1002        }
1003
1004        response.status() == StatusCode::FORBIDDEN
1005            && (response
1006                .headers()
1007                .get("x-ratelimit-remaining")
1008                .and_then(|value| value.to_str().ok())
1009                == Some("0")
1010                || response.headers().contains_key(RETRY_AFTER))
1011    }
1012
1013    fn sleep_for_retry(&self, headers: &HeaderMap, attempt: u32) {
1014        let delay = retry_delay_from_headers(headers)
1015            .filter(|delay| *delay > Duration::ZERO && *delay <= self.max_retry_delay * 10)
1016            .unwrap_or_else(|| self.calculate_backoff(attempt));
1017        thread::sleep(delay);
1018    }
1019
1020    fn calculate_backoff(&self, attempt: u32) -> Duration {
1021        let shift = attempt.min(10);
1022        let candidate = self.retry_delay.saturating_mul(1u32 << shift);
1023        candidate.min(self.max_retry_delay)
1024    }
1025
1026    fn error_from_response(&self, response: Response, context: &str) -> Result<Response> {
1027        let status = response.status();
1028        let body = response.text().unwrap_or_else(|_| String::from("<response body unavailable>"));
1029
1030        if status == StatusCode::FORBIDDEN
1031            && body.to_ascii_lowercase().contains("rate limit")
1032            && self.token.is_none()
1033        {
1034            bail!(
1035                "{context}: GitHub API rate limit exceeded. Provide --token or GITHUB_TOKEN for higher limits."
1036            )
1037        }
1038        if status == StatusCode::NOT_FOUND {
1039            bail!("{context}: resource not found ({body})");
1040        }
1041
1042        bail!("{context}: GitHub API returned {status} ({body})")
1043    }
1044}
1045
1046fn commit_info_from_response(commit: RepoCommitResponse) -> CommitInfo {
1047    CommitInfo {
1048        sha: commit.sha,
1049        message: commit.commit.message,
1050        is_merge: commit.parents.len() > 1,
1051    }
1052}
1053
1054fn normalize_token(token: &str) -> Option<String> {
1055    let trimmed = token.trim();
1056    if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) }
1057}
1058
1059fn retry_delay_from_headers(headers: &HeaderMap) -> Option<Duration> {
1060    if let Some(retry_after) = headers.get(RETRY_AFTER).and_then(|value| value.to_str().ok())
1061        && let Ok(seconds) = retry_after.parse::<u64>()
1062    {
1063        return Some(Duration::from_secs(seconds));
1064    }
1065
1066    let remaining = headers
1067        .get("x-ratelimit-remaining")
1068        .and_then(|value| value.to_str().ok())
1069        .and_then(|value| value.parse::<u64>().ok());
1070    let reset = headers
1071        .get("x-ratelimit-reset")
1072        .and_then(|value| value.to_str().ok())
1073        .and_then(|value| value.parse::<u64>().ok());
1074
1075    if remaining == Some(0)
1076        && let Some(reset) = reset
1077    {
1078        let now =
1079            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
1080        if reset > now {
1081            return Some(Duration::from_secs(reset - now) + Duration::from_millis(100));
1082        }
1083    }
1084
1085    None
1086}
1087
1088trait RequestBuilderAuthExt {
1089    fn with_auth(self, client: &GitHubClient) -> Self;
1090}
1091
1092impl RequestBuilderAuthExt for RequestBuilder {
1093    fn with_auth(self, client: &GitHubClient) -> Self {
1094        if let Some(token) = client.token.as_deref() {
1095            self.header(AUTHORIZATION, format!("Bearer {token}"))
1096        } else {
1097            self
1098        }
1099    }
1100}
1101
1102#[cfg(test)]
1103#[allow(clippy::significant_drop_tightening)]
1104mod tests {
1105    use mockito::{Matcher, Server};
1106    use std::time::Duration;
1107
1108    use super::{GitHubClient, GitHubClientOptions};
1109
1110    #[test]
1111    fn resolve_reference_returns_commit_sha() {
1112        let mut server = Server::new();
1113        let _mock = server
1114            .mock("GET", "/repos/actions/checkout/commits/v4")
1115            .match_header("user-agent", "github-actions-maintainer")
1116            .with_status(200)
1117            .with_body(r#"{"sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"}"#)
1118            .create();
1119
1120        let client = GitHubClient::new(server.url(), None).expect("github client");
1121        let sha = client.resolve_reference("actions", "checkout", "v4").expect("resolve reference");
1122
1123        assert_eq!(sha, "de0fac2e4500dabe0009e67214ff5f5447ce83dd");
1124    }
1125
1126    #[test]
1127    fn resolve_reference_sends_authorization_when_token_is_present() {
1128        let mut server = Server::new();
1129        let _mock = server
1130            .mock("GET", "/repos/actions/cache/commits/v4")
1131            .match_header("authorization", Matcher::Regex(r"^Bearer\s+ghp_testtoken$".into()))
1132            .with_status(200)
1133            .with_body(r#"{"sha":"668228422ae6a00e4ad889ee87cd7109ec5666a7"}"#)
1134            .create();
1135
1136        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1137            .expect("github client");
1138        let sha = client.resolve_reference("actions", "cache", "v4").expect("resolve reference");
1139
1140        assert_eq!(sha, "668228422ae6a00e4ad889ee87cd7109ec5666a7");
1141    }
1142
1143    #[test]
1144    fn resolve_reference_retries_after_rate_limit() {
1145        let mut server = Server::new();
1146        let now = std::time::SystemTime::now()
1147            .duration_since(std::time::UNIX_EPOCH)
1148            .expect("system time")
1149            .as_secs();
1150
1151        let _rate_limited = server
1152            .mock("GET", "/repos/actions/checkout/commits/v4")
1153            .expect(1)
1154            .with_status(403)
1155            .with_header("x-ratelimit-remaining", "0")
1156            .with_header("x-ratelimit-reset", &now.to_string())
1157            .with_body(r#"{"message":"API rate limit exceeded"}"#)
1158            .create();
1159        let _success = server
1160            .mock("GET", "/repos/actions/checkout/commits/v4")
1161            .expect(1)
1162            .with_status(200)
1163            .with_body(r#"{"sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"}"#)
1164            .create();
1165
1166        let client = GitHubClient::with_options(GitHubClientOptions {
1167            base_url: server.url(),
1168            token: None,
1169            timeout: Duration::from_secs(5),
1170            max_retries: 1,
1171            retry_delay: Duration::from_millis(1),
1172            max_retry_delay: Duration::from_millis(5),
1173        })
1174        .expect("github client");
1175
1176        let sha = client.resolve_reference("actions", "checkout", "v4").expect("resolve reference");
1177
1178        assert_eq!(sha, "de0fac2e4500dabe0009e67214ff5f5447ce83dd");
1179    }
1180
1181    #[test]
1182    fn resolve_reference_retries_when_retry_after_is_present() {
1183        let mut server = Server::new();
1184
1185        let _rate_limited = server
1186            .mock("GET", "/repos/actions/cache/commits/v4")
1187            .expect(1)
1188            .with_status(403)
1189            .with_header("retry-after", "0")
1190            .with_body(r#"{"message":"You have exceeded a secondary rate limit"}"#)
1191            .create();
1192        let _success = server
1193            .mock("GET", "/repos/actions/cache/commits/v4")
1194            .expect(1)
1195            .with_status(200)
1196            .with_body(r#"{"sha":"668228422ae6a00e4ad889ee87cd7109ec5666a7"}"#)
1197            .create();
1198
1199        let client = GitHubClient::with_options(GitHubClientOptions {
1200            base_url: server.url(),
1201            token: Some(String::from("ghp_testtoken")),
1202            timeout: Duration::from_secs(5),
1203            max_retries: 1,
1204            retry_delay: Duration::from_millis(1),
1205            max_retry_delay: Duration::from_millis(5),
1206        })
1207        .expect("github client");
1208
1209        let sha = client.resolve_reference("actions", "cache", "v4").expect("resolve reference");
1210
1211        assert_eq!(sha, "668228422ae6a00e4ad889ee87cd7109ec5666a7");
1212    }
1213
1214    const IP_ALLOW_LIST_BODY: &str = r#"{"message":"Although you appear to have the correct authorization credentials, the `aquasecurity` organization has an IP allow list enabled, and your IP address is not permitted to access this resource."}"#;
1215
1216    #[test]
1217    fn resolve_reference_falls_back_to_an_anonymous_request_on_403() {
1218        let mut server = Server::new();
1219        let forbidden = server
1220            .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1221            .match_header("authorization", Matcher::Regex(r"^Bearer\s+ghp_testtoken$".into()))
1222            .expect(1)
1223            .with_status(403)
1224            .with_body(IP_ALLOW_LIST_BODY)
1225            .create();
1226        let anonymous = server
1227            .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1228            .match_header("authorization", Matcher::Missing)
1229            .expect(1)
1230            .with_status(200)
1231            .with_body(r#"{"sha":"6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5"}"#)
1232            .create();
1233
1234        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1235            .expect("github client");
1236        let sha = client
1237            .resolve_reference("aquasecurity", "trivy-action", "0.33.1")
1238            .expect("resolve reference");
1239
1240        assert_eq!(sha, "6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5");
1241        forbidden.assert();
1242        anonymous.assert();
1243    }
1244
1245    #[test]
1246    fn latest_reference_falls_back_to_an_anonymous_request_on_403() {
1247        let mut server = Server::new();
1248        let forbidden = server
1249            .mock("GET", "/repos/aquasecurity/trivy-action/releases/latest")
1250            .match_header("authorization", Matcher::Regex(r"^Bearer\s+ghp_testtoken$".into()))
1251            .expect(1)
1252            .with_status(403)
1253            .with_body(IP_ALLOW_LIST_BODY)
1254            .create();
1255        let anonymous = server
1256            .mock("GET", "/repos/aquasecurity/trivy-action/releases/latest")
1257            .match_header("authorization", Matcher::Missing)
1258            .expect(1)
1259            .with_status(200)
1260            .with_body(r#"{"tag_name":"0.33.1"}"#)
1261            .create();
1262        let _commit = server
1263            .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1264            .with_status(200)
1265            .with_body(r#"{"sha":"6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5"}"#)
1266            .create();
1267
1268        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1269            .expect("github client");
1270        let latest =
1271            client.latest_reference("aquasecurity", "trivy-action").expect("latest reference");
1272
1273        assert_eq!(latest.version, "0.33.1");
1274        assert_eq!(latest.sha, "6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5");
1275        forbidden.assert();
1276        anonymous.assert();
1277    }
1278
1279    #[test]
1280    fn resolve_reference_reports_the_original_403_when_the_anonymous_retry_fails() {
1281        let mut server = Server::new();
1282        let _forbidden = server
1283            .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1284            .match_header("authorization", Matcher::Regex(r"^Bearer\s+ghp_testtoken$".into()))
1285            .expect(1)
1286            .with_status(403)
1287            .with_body(IP_ALLOW_LIST_BODY)
1288            .create();
1289        let anonymous = server
1290            .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1291            .match_header("authorization", Matcher::Missing)
1292            .expect(1)
1293            .with_status(404)
1294            .with_body(r#"{"message":"Not Found"}"#)
1295            .create();
1296
1297        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1298            .expect("github client");
1299        let error = client
1300            .resolve_reference("aquasecurity", "trivy-action", "0.33.1")
1301            .expect_err("forbidden");
1302        let message = error.to_string();
1303
1304        assert!(message.contains("403 Forbidden"), "{message}");
1305        assert!(message.contains("IP allow list"), "{message}");
1306        assert!(message.contains("anonymous retry"), "{message}");
1307        assert!(message.contains("404"), "{message}");
1308        anonymous.assert();
1309    }
1310
1311    #[test]
1312    fn resolve_reference_does_not_fall_back_to_anonymous_on_401() {
1313        let mut server = Server::new();
1314        let unauthorized = server
1315            .mock("GET", "/repos/actions/checkout/commits/v4")
1316            .expect(1)
1317            .with_status(401)
1318            .with_body(r#"{"message":"Bad credentials"}"#)
1319            .create();
1320
1321        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1322            .expect("github client");
1323        let error =
1324            client.resolve_reference("actions", "checkout", "v4").expect_err("bad credentials");
1325        let message = error.to_string();
1326
1327        assert!(message.contains("401 Unauthorized"), "{message}");
1328        assert!(!message.contains("anonymous retry"), "{message}");
1329        unauthorized.assert();
1330    }
1331
1332    #[test]
1333    fn resolve_reference_without_a_token_sends_a_single_request_on_403() {
1334        let mut server = Server::new();
1335        let forbidden = server
1336            .mock("GET", "/repos/actions/checkout/commits/v4")
1337            .match_header("authorization", Matcher::Missing)
1338            .expect(1)
1339            .with_status(403)
1340            .with_body(r#"{"message":"Resource not accessible"}"#)
1341            .create();
1342
1343        let client = GitHubClient::new(server.url(), None).expect("github client");
1344        let error = client.resolve_reference("actions", "checkout", "v4").expect_err("forbidden");
1345        let message = error.to_string();
1346
1347        assert!(message.contains("403 Forbidden"), "{message}");
1348        assert!(!message.contains("anonymous retry"), "{message}");
1349        forbidden.assert();
1350    }
1351
1352    #[test]
1353    fn create_ref_does_not_fall_back_to_anonymous_on_403() {
1354        let mut server = Server::new();
1355        let forbidden = server
1356            .mock("POST", "/repos/acme/demo/git/refs")
1357            .expect(1)
1358            .with_status(403)
1359            .with_body(r#"{"message":"Resource not accessible by integration"}"#)
1360            .create();
1361
1362        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1363            .expect("github client");
1364        let error =
1365            client.create_ref("acme", "demo", "tags/v1.2.3", "commitsha").expect_err("forbidden");
1366
1367        assert!(error.to_string().contains("403 Forbidden"), "{error}");
1368        forbidden.assert();
1369    }
1370
1371    #[test]
1372    fn reference_sha_returns_none_when_ref_is_missing() {
1373        let mut server = Server::new();
1374        let _mock = server
1375            .mock("GET", "/repos/acme/demo/git/ref/tags/v1.2.3")
1376            .with_status(404)
1377            .with_body(r#"{"message":"Not Found"}"#)
1378            .create();
1379
1380        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1381            .expect("github client");
1382        let sha = client.reference_sha("acme", "demo", "tags/v1.2.3").expect("reference sha");
1383
1384        assert_eq!(sha, None);
1385    }
1386
1387    #[test]
1388    fn reference_sha_returns_object_sha() {
1389        let mut server = Server::new();
1390        let _mock = server
1391            .mock("GET", "/repos/acme/demo/git/ref/tags/v1.2.3")
1392            .with_status(200)
1393            .with_body(r#"{"ref":"refs/tags/v1.2.3","object":{"sha":"tagsha"}}"#)
1394            .create();
1395
1396        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1397            .expect("github client");
1398        let sha = client.reference_sha("acme", "demo", "tags/v1.2.3").expect("reference sha");
1399
1400        assert_eq!(sha.as_deref(), Some("tagsha"));
1401    }
1402
1403    #[test]
1404    fn create_ref_posts_fully_qualified_tag_reference() {
1405        let mut server = Server::new();
1406        let _mock = server
1407            .mock("POST", "/repos/acme/demo/git/refs")
1408            .match_body(Matcher::Regex(r#""ref":"refs/tags/v1\.2\.3""#.into()))
1409            .with_status(201)
1410            .with_body(r#"{"ref":"refs/tags/v1.2.3"}"#)
1411            .create();
1412
1413        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1414            .expect("github client");
1415        client.create_ref("acme", "demo", "tags/v1.2.3", "commitsha").expect("create ref");
1416    }
1417
1418    #[test]
1419    fn update_ref_serializes_force_flag() {
1420        let mut server = Server::new();
1421        let _mock = server
1422            .mock("PATCH", "/repos/acme/demo/git/refs/tags/v1")
1423            .match_body(Matcher::Regex(r#""force":true"#.into()))
1424            .with_status(200)
1425            .with_body(r#"{"ref":"refs/tags/v1"}"#)
1426            .create();
1427
1428        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1429            .expect("github client");
1430        client.update_ref("acme", "demo", "tags/v1", "commitsha", true).expect("update ref");
1431    }
1432
1433    #[test]
1434    fn update_ref_fast_forward_reports_non_fast_forward_updates() {
1435        let mut server = Server::new();
1436        let _mock = server
1437            .mock("PATCH", "/repos/acme/demo/git/refs/heads/main")
1438            .match_body(Matcher::Regex(r#""force":false"#.into()))
1439            .with_status(422)
1440            .with_body(r#"{"message":"Update is not a fast forward"}"#)
1441            .create();
1442
1443        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1444            .expect("github client");
1445        let advanced = client
1446            .update_ref_fast_forward("acme", "demo", "heads/main", "commitsha")
1447            .expect("fast-forward ref");
1448
1449        assert!(!advanced);
1450    }
1451
1452    #[test]
1453    fn update_ref_fast_forward_errors_on_unrelated_validation_failures() {
1454        let mut server = Server::new();
1455        let _mock = server
1456            .mock("PATCH", "/repos/acme/demo/git/refs/heads/main")
1457            .with_status(422)
1458            .with_body(r#"{"message":"Object does not exist"}"#)
1459            .create();
1460
1461        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1462            .expect("github client");
1463        let error = client
1464            .update_ref_fast_forward("acme", "demo", "heads/main", "commitsha")
1465            .expect_err("validation failure");
1466
1467        assert!(error.to_string().contains("Object does not exist"), "{error}");
1468    }
1469
1470    #[test]
1471    fn update_ref_fast_forward_succeeds_when_ref_is_current() {
1472        let mut server = Server::new();
1473        let _mock = server
1474            .mock("PATCH", "/repos/acme/demo/git/refs/heads/main")
1475            .with_status(200)
1476            .with_body(r#"{"ref":"refs/heads/main"}"#)
1477            .create();
1478
1479        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1480            .expect("github client");
1481        let advanced = client
1482            .update_ref_fast_forward("acme", "demo", "heads/main", "commitsha")
1483            .expect("fast-forward ref");
1484
1485        assert!(advanced);
1486    }
1487
1488    #[test]
1489    fn list_tags_paginates_until_a_short_page() {
1490        let mut server = Server::new();
1491        let full_page: Vec<String> = (0..100)
1492            .map(|index| format!(r#"{{"name":"v0.0.{index}","commit":{{"sha":"{index:040}"}}}}"#))
1493            .collect();
1494        let _first = server
1495            .mock("GET", "/repos/acme/demo/tags?per_page=100&page=1")
1496            .expect(1)
1497            .with_status(200)
1498            .with_body(format!("[{}]", full_page.join(",")))
1499            .create();
1500        let _second = server
1501            .mock("GET", "/repos/acme/demo/tags?per_page=100&page=2")
1502            .expect(1)
1503            .with_status(200)
1504            .with_body(r#"[{"name":"v1.0.0","commit":{"sha":"lasttagsha"}}]"#)
1505            .create();
1506
1507        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1508            .expect("github client");
1509        let tags = client.list_tags("acme", "demo", 5).expect("list tags");
1510
1511        assert_eq!(tags.len(), 101);
1512        assert_eq!(tags[100].name, "v1.0.0");
1513        assert_eq!(tags[100].sha.as_deref(), Some("lasttagsha"));
1514    }
1515
1516    #[test]
1517    fn compare_commits_paginates_and_flags_merge_commits() {
1518        let mut server = Server::new();
1519        let first_page: Vec<String> = (0..100)
1520            .map(|index| {
1521                format!(
1522                    r#"{{"sha":"{index:040}","commit":{{"message":"feat: change {index}"}},"parents":[{{}}]}}"#
1523                )
1524            })
1525            .collect();
1526        let _first = server
1527            .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=1")
1528            .expect(1)
1529            .with_status(200)
1530            .with_body(format!(r#"{{"total_commits":101,"commits":[{}]}}"#, first_page.join(",")))
1531            .create();
1532        let _second = server
1533            .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=2")
1534            .expect(1)
1535            .with_status(200)
1536            .with_body(
1537                r#"{"total_commits":101,"commits":[{"sha":"mergesha","commit":{"message":"Merge pull request #1"},"parents":[{},{}]}]}"#,
1538            )
1539            .create();
1540
1541        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1542            .expect("github client");
1543        let range = client
1544            .compare_commits("acme", "demo", "v0.1.0", "headsha", 5)
1545            .expect("compare commits");
1546
1547        assert_eq!(range.commits.len(), 101);
1548        assert!(!range.truncated);
1549        assert!(!range.commits[0].is_merge);
1550        assert!(range.commits[100].is_merge);
1551        assert_eq!(range.commits[100].sha, "mergesha");
1552    }
1553
1554    #[test]
1555    fn compare_commits_marks_truncation_at_the_page_cap() {
1556        let mut server = Server::new();
1557        let first_page: Vec<String> = (0..100)
1558            .map(|index| {
1559                format!(r#"{{"sha":"{index:040}","commit":{{"message":"fix: {index}"}},"parents":[{{}}]}}"#)
1560            })
1561            .collect();
1562        let _first = server
1563            .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=1")
1564            .expect(1)
1565            .with_status(200)
1566            .with_body(format!(r#"{{"total_commits":150,"commits":[{}]}}"#, first_page.join(",")))
1567            .create();
1568
1569        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1570            .expect("github client");
1571        let range = client
1572            .compare_commits("acme", "demo", "v0.1.0", "headsha", 1)
1573            .expect("compare commits");
1574
1575        assert_eq!(range.commits.len(), 100);
1576        assert!(range.truncated);
1577    }
1578
1579    #[test]
1580    fn compare_commits_stops_when_pages_run_dry() {
1581        let mut server = Server::new();
1582        let first_page: Vec<String> = (0..100)
1583            .map(|index| {
1584                format!(
1585                    r#"{{"sha":"{index:040}","commit":{{"message":"fix: {index}"}},"parents":[{{}}]}}"#
1586                )
1587            })
1588            .collect();
1589        let _first = server
1590            .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=1")
1591            .expect(1)
1592            .with_status(200)
1593            .with_body(format!(r#"{{"total_commits":300,"commits":[{}]}}"#, first_page.join(",")))
1594            .create();
1595        let _second = server
1596            .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=2")
1597            .expect(1)
1598            .with_status(200)
1599            .with_body(r#"{"total_commits":300,"commits":[]}"#)
1600            .create();
1601        let _third = server
1602            .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=3")
1603            .expect(0)
1604            .create();
1605
1606        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1607            .expect("github client");
1608        let range =
1609            client.compare_commits("acme", "demo", "v0.1.0", "headsha", 10).expect("compare");
1610
1611        assert_eq!(range.commits.len(), 100);
1612        assert!(range.truncated);
1613    }
1614
1615    #[test]
1616    fn list_commits_stops_on_a_short_page() {
1617        let mut server = Server::new();
1618        let _first = server
1619            .mock("GET", "/repos/acme/demo/commits?sha=headsha&per_page=100&page=1")
1620            .expect(1)
1621            .with_status(200)
1622            .with_body(r#"[{"sha":"onlysha","commit":{"message":"feat: initial"},"parents":[]}]"#)
1623            .create();
1624
1625        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1626            .expect("github client");
1627        let range = client.list_commits("acme", "demo", "headsha", 3).expect("list commits");
1628
1629        assert_eq!(range.commits.len(), 1);
1630        assert!(!range.truncated);
1631        assert_eq!(range.commits[0].message, "feat: initial");
1632    }
1633
1634    #[test]
1635    fn create_release_posts_tag_and_returns_url() {
1636        let mut server = Server::new();
1637        let _mock = server
1638            .mock("POST", "/repos/acme/demo/releases")
1639            .match_body(Matcher::AllOf(vec![
1640                Matcher::Regex(r#""tag_name":"v1\.2\.3""#.into()),
1641                Matcher::Regex(r#""target_commitish":"commitsha""#.into()),
1642                Matcher::Regex(r#""name":"Release v1\.2\.3""#.into()),
1643            ]))
1644            .with_status(201)
1645            .with_body(r#"{"html_url":"https://github.com/acme/demo/releases/tag/v1.2.3"}"#)
1646            .create();
1647
1648        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1649            .expect("github client");
1650        let release = client
1651            .create_release("acme", "demo", "v1.2.3", "Release v1.2.3", "notes", "commitsha")
1652            .expect("create release");
1653
1654        assert_eq!(release.url, "https://github.com/acme/demo/releases/tag/v1.2.3");
1655    }
1656
1657    #[test]
1658    fn ensure_token_requires_a_token() {
1659        let client = GitHubClient::new("https://api.github.com", None).expect("github client");
1660        let error = client.ensure_token().expect_err("missing token");
1661
1662        assert!(error.to_string().contains("GitHub token"));
1663    }
1664
1665    #[test]
1666    fn validate_token_scopes_requires_workflow_scope() {
1667        let mut server = Server::new();
1668        let _user = server
1669            .mock("GET", "/user")
1670            .match_header("authorization", Matcher::Regex("^Bearer\\s+ghp_testtoken$".into()))
1671            .with_status(200)
1672            .with_header("x-oauth-scopes", "repo")
1673            .with_body(r#"{"login":"octocat"}"#)
1674            .create();
1675
1676        let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1677            .expect("github client");
1678        let error = client.validate_token_scopes().expect_err("missing workflow scope");
1679
1680        assert!(error.to_string().contains("workflow scope"));
1681    }
1682}