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