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