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