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