Skip to main content

github_actions_maintainer/
github.rs

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