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