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 pub fn validate_token_scopes(&self) -> Result<()> {
358 let token = self
359 .token
360 .as_deref()
361 .ok_or_else(|| anyhow!("a GitHub token is required for remote PR creation"))?;
362
363 let response = self.send_with_retry(
364 || self.get("/user").header(AUTHORIZATION, format!("Bearer {token}")),
365 || String::from("validate GitHub token scopes"),
366 )?;
367 let headers = response.headers().clone();
368 let user =
369 response.json::<UserResponse>().context("failed to decode GitHub user response")?;
370
371 if user.login.is_none() {
372 bail!("failed to validate GitHub token: authenticated user is missing");
373 }
374
375 let Some(scopes) = headers.get("x-oauth-scopes").and_then(|value| value.to_str().ok())
376 else {
377 return Ok(());
378 };
379
380 let has_repo_scope = scopes.contains("repo") || scopes.contains("public_repo");
381 if !has_repo_scope {
382 bail!("GitHub token is missing the repo or public_repo scope");
383 }
384 if !scopes.contains("workflow") {
385 bail!("GitHub token is missing the workflow scope");
386 }
387
388 Ok(())
389 }
390
391 pub fn default_branch(&self, owner: &str, repository: &str) -> Result<String> {
392 let response = self.get_with_retry(&format!("/repos/{owner}/{repository}"), || {
393 format!("fetch repository metadata for {owner}/{repository}")
394 })?;
395 let repository = response.json::<RepositoryResponse>().with_context(|| {
396 format!("failed to decode repository response for {owner}/{repository}")
397 })?;
398 Ok(repository.default_branch)
399 }
400
401 pub fn branch_head_sha(&self, owner: &str, repository: &str, branch: &str) -> Result<String> {
402 let response = self.get_with_retry(
403 &format!("/repos/{owner}/{repository}/git/ref/heads/{branch}"),
404 || format!("fetch branch ref for {owner}/{repository}:{branch}"),
405 )?;
406 let reference = response.json::<ReferenceResponse>().with_context(|| {
407 format!("failed to decode branch ref for {owner}/{repository}:{branch}")
408 })?;
409 Ok(reference.object.sha)
410 }
411
412 pub fn commit_tree_sha(
413 &self,
414 owner: &str,
415 repository: &str,
416 commit_sha: &str,
417 ) -> Result<String> {
418 let response = self.get_with_retry(
419 &format!("/repos/{owner}/{repository}/git/commits/{commit_sha}"),
420 || format!("fetch commit tree for {owner}/{repository}@{commit_sha}"),
421 )?;
422 let commit = response.json::<CommitTreeResponse>().with_context(|| {
423 format!("failed to decode commit tree for {owner}/{repository}@{commit_sha}")
424 })?;
425 Ok(commit.tree.sha)
426 }
427
428 pub fn create_branch(
429 &self,
430 owner: &str,
431 repository: &str,
432 branch: &str,
433 base_sha: &str,
434 ) -> Result<()> {
435 self.create_ref(owner, repository, &format!("heads/{branch}"), base_sha)
436 }
437
438 pub fn create_ref(
439 &self,
440 owner: &str,
441 repository: &str,
442 ref_path: &str,
443 sha: &str,
444 ) -> Result<()> {
445 let reference = format!("refs/{ref_path}");
446 let payload = CreateReferenceRequest { reference: &reference, sha };
447 self.post_json(&format!("/repos/{owner}/{repository}/git/refs"), &payload, || {
448 format!("create ref {ref_path} for {owner}/{repository}")
449 })?;
450 Ok(())
451 }
452
453 pub fn create_annotated_tag(
457 &self,
458 owner: &str,
459 repository: &str,
460 tag: &str,
461 message: &str,
462 commit_sha: &str,
463 ) -> Result<String> {
464 let payload =
465 CreateTagObjectRequest { tag, message, object: commit_sha, object_type: "commit" };
466 let response =
467 self.post_json(&format!("/repos/{owner}/{repository}/git/tags"), &payload, || {
468 format!("create annotated tag {tag} for {owner}/{repository}")
469 })?;
470 let created = response.json::<CreatedTagObjectResponse>().with_context(|| {
471 format!("failed to decode tag object response for {owner}/{repository}")
472 })?;
473 Ok(created.sha)
474 }
475
476 pub fn reference_sha(
477 &self,
478 owner: &str,
479 repository: &str,
480 ref_path: &str,
481 ) -> Result<Option<String>> {
482 let response = self.get_with_retry_allowing_not_found(
483 &format!("/repos/{owner}/{repository}/git/ref/{ref_path}"),
484 || format!("fetch ref {ref_path} for {owner}/{repository}"),
485 )?;
486 let Some(response) = response else {
487 return Ok(None);
488 };
489 let reference = response
490 .json::<ReferenceResponse>()
491 .with_context(|| format!("failed to decode ref {ref_path} for {owner}/{repository}"))?;
492 Ok(Some(reference.object.sha))
493 }
494
495 pub fn create_blob(&self, owner: &str, repository: &str, content: &str) -> Result<String> {
496 let payload = CreateBlobRequest { content, encoding: "utf-8" };
497 let response =
498 self.post_json(&format!("/repos/{owner}/{repository}/git/blobs"), &payload, || {
499 format!("create blob for {owner}/{repository}")
500 })?;
501 let blob = response
502 .json::<BlobResponse>()
503 .with_context(|| format!("failed to decode blob response for {owner}/{repository}"))?;
504 Ok(blob.sha)
505 }
506
507 pub fn create_tree(
508 &self,
509 owner: &str,
510 repository: &str,
511 base_tree_sha: &str,
512 entries: &[TreeEntry],
513 ) -> Result<String> {
514 let payload = CreateTreeRequest {
515 base_tree: base_tree_sha,
516 tree: entries
517 .iter()
518 .map(|entry| CreateTreeEntry {
519 path: &entry.path,
520 mode: "100644",
521 object_type: "blob",
522 sha: &entry.sha,
523 })
524 .collect(),
525 };
526 let response =
527 self.post_json(&format!("/repos/{owner}/{repository}/git/trees"), &payload, || {
528 format!("create tree for {owner}/{repository}")
529 })?;
530 let tree = response
531 .json::<TreeResponse>()
532 .with_context(|| format!("failed to decode tree response for {owner}/{repository}"))?;
533 Ok(tree.sha)
534 }
535
536 pub fn create_commit(
537 &self,
538 owner: &str,
539 repository: &str,
540 message: &str,
541 tree_sha: &str,
542 parent_sha: &str,
543 ) -> Result<String> {
544 let payload = CreateCommitRequest { message, tree: tree_sha, parents: vec![parent_sha] };
545 let response =
546 self.post_json(&format!("/repos/{owner}/{repository}/git/commits"), &payload, || {
547 format!("create commit for {owner}/{repository}")
548 })?;
549 let commit = response.json::<CreatedCommitResponse>().with_context(|| {
550 format!("failed to decode commit response for {owner}/{repository}")
551 })?;
552 Ok(commit.sha)
553 }
554
555 pub fn update_branch(
556 &self,
557 owner: &str,
558 repository: &str,
559 branch: &str,
560 commit_sha: &str,
561 ) -> Result<()> {
562 self.update_ref(owner, repository, &format!("heads/{branch}"), commit_sha, false)
563 }
564
565 pub fn update_ref(
566 &self,
567 owner: &str,
568 repository: &str,
569 ref_path: &str,
570 sha: &str,
571 force: bool,
572 ) -> Result<()> {
573 let payload = UpdateReferenceRequest { sha, force };
574 self.patch_json(
575 &format!("/repos/{owner}/{repository}/git/refs/{ref_path}"),
576 &payload,
577 || format!("update ref {ref_path} for {owner}/{repository}"),
578 )?;
579 Ok(())
580 }
581
582 pub fn update_ref_fast_forward(
585 &self,
586 owner: &str,
587 repository: &str,
588 ref_path: &str,
589 sha: &str,
590 ) -> Result<bool> {
591 let payload = UpdateReferenceRequest { sha, force: false };
592 let response = self.send_with_retry_allowing_non_fast_forward(
593 || {
594 self.client
595 .patch(format!(
596 "{}/repos/{owner}/{repository}/git/refs/{ref_path}",
597 self.base_url
598 ))
599 .with_auth(self)
600 .json(&payload)
601 },
602 || format!("fast-forward ref {ref_path} for {owner}/{repository}"),
603 )?;
604 Ok(response.is_some())
605 }
606
607 pub fn list_tags(&self, owner: &str, repository: &str, max_pages: u32) -> Result<Vec<TagInfo>> {
608 let mut tags = Vec::new();
609 for page in 1..=max_pages {
610 let response = self.get_with_retry(
611 &format!("/repos/{owner}/{repository}/tags?per_page=100&page={page}"),
612 || format!("list tags for {owner}/{repository}"),
613 )?;
614 let page_tags = response.json::<Vec<TagResponse>>().with_context(|| {
615 format!("failed to decode tags response for {owner}/{repository}")
616 })?;
617 let page_len = page_tags.len();
618 tags.extend(
619 page_tags.into_iter().map(|tag| TagInfo {
620 name: tag.name,
621 sha: tag.commit.map(|commit| commit.sha),
622 }),
623 );
624 if page_len < 100 {
625 break;
626 }
627 }
628 Ok(tags)
629 }
630
631 pub fn latest_semver_tag(
634 &self,
635 owner: &str,
636 repository: &str,
637 prefix: &str,
638 ) -> Result<Option<TagInfo>> {
639 const MAX_TAG_PAGES: u32 = 10;
640 let tags = self.list_tags(owner, repository, MAX_TAG_PAGES)?;
641 Ok(tags
642 .into_iter()
643 .filter_map(|tag| {
644 let version = tag.name.strip_prefix(prefix)?;
645 let parsed = semver::Version::parse(version).ok()?;
646 Some((parsed, tag))
647 })
648 .max_by(|(left, _), (right, _)| left.cmp(right))
649 .map(|(_, tag)| tag))
650 }
651
652 pub fn compare_commits(
653 &self,
654 owner: &str,
655 repository: &str,
656 base: &str,
657 head: &str,
658 max_pages: u32,
659 ) -> Result<CommitRange> {
660 let encoded_base = urlencoding::encode(base);
661 let encoded_head = urlencoding::encode(head);
662 let mut commits = Vec::new();
663 let mut total_commits = 0usize;
664 for page in 1..=max_pages {
665 let response = self.get_with_retry(
666 &format!(
667 "/repos/{owner}/{repository}/compare/{encoded_base}...{encoded_head}?per_page=100&page={page}"
668 ),
669 || format!("compare {base}...{head} for {owner}/{repository}"),
670 )?;
671 let compare = response.json::<CompareResponse>().with_context(|| {
672 format!("failed to decode compare response for {owner}/{repository}")
673 })?;
674 total_commits = usize::try_from(compare.total_commits).unwrap_or(usize::MAX);
675 if compare.commits.is_empty() {
678 break;
679 }
680 commits.extend(compare.commits.into_iter().map(commit_info_from_response));
681 if commits.len() >= total_commits {
682 break;
683 }
684 }
685 let truncated = commits.len() < total_commits;
686 Ok(CommitRange { commits, truncated })
687 }
688
689 pub fn list_commits(
690 &self,
691 owner: &str,
692 repository: &str,
693 head_sha: &str,
694 max_pages: u32,
695 ) -> Result<CommitRange> {
696 let encoded_head = urlencoding::encode(head_sha);
697 let mut commits = Vec::new();
698 let mut last_page_full = false;
699 for page in 1..=max_pages {
700 let response = self.get_with_retry(
701 &format!(
702 "/repos/{owner}/{repository}/commits?sha={encoded_head}&per_page=100&page={page}"
703 ),
704 || format!("list commits for {owner}/{repository}"),
705 )?;
706 let page_commits = response.json::<Vec<RepoCommitResponse>>().with_context(|| {
707 format!("failed to decode commits response for {owner}/{repository}")
708 })?;
709 last_page_full = page_commits.len() == 100;
710 commits.extend(page_commits.into_iter().map(commit_info_from_response));
711 if !last_page_full {
712 break;
713 }
714 }
715 Ok(CommitRange { commits, truncated: last_page_full })
716 }
717
718 pub fn create_release(
719 &self,
720 owner: &str,
721 repository: &str,
722 tag_name: &str,
723 name: &str,
724 body: &str,
725 target_commitish: &str,
726 ) -> Result<ReleaseInfo> {
727 let payload = CreateReleaseRequest { tag_name, target_commitish, name, body };
728 let response =
729 self.post_json(&format!("/repos/{owner}/{repository}/releases"), &payload, || {
730 format!("create release {tag_name} for {owner}/{repository}")
731 })?;
732 let release = response.json::<CreatedReleaseResponse>().with_context(|| {
733 format!("failed to decode release response for {owner}/{repository}")
734 })?;
735 Ok(ReleaseInfo { url: release.html_url })
736 }
737
738 pub fn ensure_token(&self) -> Result<()> {
739 if self.token.is_none() {
740 bail!("a GitHub token is required to create releases; provide --token or GITHUB_TOKEN");
741 }
742 Ok(())
743 }
744
745 pub fn create_pull_request(
746 &self,
747 owner: &str,
748 repository: &str,
749 title: &str,
750 body: &str,
751 head: &str,
752 base: &str,
753 ) -> Result<PullRequestInfo> {
754 let payload = CreatePullRequestRequest { title, body, head, base };
755 let response =
756 self.post_json(&format!("/repos/{owner}/{repository}/pulls"), &payload, || {
757 format!("create pull request for {owner}/{repository}")
758 })?;
759 let pull_request = response.json::<PullRequestResponse>().with_context(|| {
760 format!("failed to decode pull request response for {owner}/{repository}")
761 })?;
762 Ok(PullRequestInfo { number: pull_request.number, url: pull_request.html_url })
763 }
764
765 pub fn find_open_pull_request(
766 &self,
767 owner: &str,
768 repository: &str,
769 head: &str,
770 base: &str,
771 ) -> Result<Option<PullRequestInfo>> {
772 let encoded_head = urlencoding::encode(&format!("{owner}:{head}")).into_owned();
773 let encoded_base = urlencoding::encode(base).into_owned();
774 let response = self.send_with_retry(
775 || self.get(&format!(
776 "/repos/{owner}/{repository}/pulls?state=open&head={encoded_head}&base={encoded_base}&per_page=100"
777 )),
778 || format!("find open release pull request for {owner}/{repository}"),
779 )?;
780 let pull_requests = response.json::<Vec<PullRequestResponse>>().with_context(|| {
781 format!("failed to decode pull request list for {owner}/{repository}")
782 })?;
783 Ok(pull_requests.into_iter().next().map(|pull_request| PullRequestInfo {
784 number: pull_request.number,
785 url: pull_request.html_url,
786 }))
787 }
788
789 pub fn update_pull_request(
790 &self,
791 owner: &str,
792 repository: &str,
793 number: u64,
794 title: &str,
795 body: &str,
796 ) -> Result<PullRequestInfo> {
797 let payload = UpdatePullRequestRequest { title, body };
798 let response = self.patch_json(
799 &format!("/repos/{owner}/{repository}/pulls/{number}"),
800 &payload,
801 || format!("update pull request {number} for {owner}/{repository}"),
802 )?;
803 let pull_request = response.json::<PullRequestResponse>().with_context(|| {
804 format!("failed to decode updated pull request response for {owner}/{repository}")
805 })?;
806 Ok(PullRequestInfo { number: pull_request.number, url: pull_request.html_url })
807 }
808
809 pub fn add_labels(
810 &self,
811 owner: &str,
812 repository: &str,
813 issue_number: u64,
814 labels: &[String],
815 ) -> Result<()> {
816 let payload = AddLabelsRequest { labels };
817 self.post_json(
818 &format!("/repos/{owner}/{repository}/issues/{issue_number}/labels"),
819 &payload,
820 || format!("add labels to issue {issue_number} for {owner}/{repository}"),
821 )?;
822 Ok(())
823 }
824
825 fn get(&self, path: &str) -> RequestBuilder {
826 self.get_anonymous(path).with_auth(self)
827 }
828
829 fn get_anonymous(&self, path: &str) -> RequestBuilder {
830 self.client.get(format!("{}{}", self.base_url, path))
831 }
832
833 fn post_json<T: Serialize, F>(&self, path: &str, payload: &T, describe: F) -> Result<Response>
834 where
835 F: Fn() -> String,
836 {
837 self.send_with_retry(
838 || self.client.post(format!("{}{}", self.base_url, path)).with_auth(self).json(payload),
839 describe,
840 )
841 }
842
843 fn patch_json<T: Serialize, F>(&self, path: &str, payload: &T, describe: F) -> Result<Response>
844 where
845 F: Fn() -> String,
846 {
847 self.send_with_retry(
848 || {
849 self.client
850 .patch(format!("{}{}", self.base_url, path))
851 .with_auth(self)
852 .json(payload)
853 },
854 describe,
855 )
856 }
857
858 fn send_raw_with_retry<F, D>(&self, mut build_request: F, describe: &D) -> Result<Response>
861 where
862 F: FnMut() -> RequestBuilder,
863 D: Fn() -> String,
864 {
865 let mut attempt = 0u32;
866
867 loop {
868 match build_request().send() {
869 Ok(response) => {
870 if Self::should_retry_response(&response) && attempt < self.max_retries {
871 self.sleep_for_retry(response.headers(), attempt);
872 attempt += 1;
873 continue;
874 }
875 return Ok(response);
876 }
877 Err(error) => {
878 if (error.is_timeout() || error.is_connect()) && attempt < self.max_retries {
879 thread::sleep(self.calculate_backoff(attempt));
880 attempt += 1;
881 continue;
882 }
883 return Err(error).with_context(describe);
884 }
885 }
886 }
887 }
888
889 fn send_with_retry<F, D>(&self, build_request: F, describe: D) -> Result<Response>
890 where
891 F: FnMut() -> RequestBuilder,
892 D: Fn() -> String,
893 {
894 let response = self.send_raw_with_retry(build_request, &describe)?;
895 if response.status().is_success() {
896 return Ok(response);
897 }
898 self.error_from_response(response, &describe())
899 }
900
901 fn send_read_with_retry<D>(&self, path: &str, describe: &D) -> Result<Response>
914 where
915 D: Fn() -> String,
916 {
917 let response = self.send_raw_with_retry(|| self.get(path), describe)?;
918 if self.token.is_none()
919 || response.status() != StatusCode::FORBIDDEN
920 || Self::should_retry_response(&response)
921 {
922 return Ok(response);
923 }
924
925 let body = response.text().unwrap_or_else(|_| String::from("<response body unavailable>"));
926 if body.to_ascii_lowercase().contains("rate limit") {
927 bail!("{}: GitHub API returned {} ({body})", describe(), StatusCode::FORBIDDEN);
928 }
929
930 let anonymous_outcome =
931 match self.send_raw_with_retry(|| self.get_anonymous(path), describe) {
932 Ok(anonymous) if anonymous.status().is_success() => return Ok(anonymous),
933 Ok(anonymous) => format!("returned {}", anonymous.status()),
934 Err(error) => format!("failed: {error}"),
935 };
936 bail!(
937 "{}: GitHub API returned {} ({body}); the anonymous retry without the token also {anonymous_outcome}",
938 describe(),
939 StatusCode::FORBIDDEN,
940 )
941 }
942
943 fn get_with_retry<D>(&self, path: &str, describe: D) -> Result<Response>
944 where
945 D: Fn() -> String,
946 {
947 let response = self.send_read_with_retry(path, &describe)?;
948 if response.status().is_success() {
949 return Ok(response);
950 }
951 self.error_from_response(response, &describe())
952 }
953
954 fn get_with_retry_allowing_not_found<D>(
955 &self,
956 path: &str,
957 describe: D,
958 ) -> Result<Option<Response>>
959 where
960 D: Fn() -> String,
961 {
962 let response = self.send_read_with_retry(path, &describe)?;
963 if response.status() == StatusCode::NOT_FOUND {
964 return Ok(None);
965 }
966 if response.status().is_success() {
967 return Ok(Some(response));
968 }
969 self.error_from_response(response, &describe()).map(Some)
970 }
971
972 fn send_with_retry_allowing_non_fast_forward<D>(
976 &self,
977 build_request: impl FnMut() -> RequestBuilder,
978 describe: D,
979 ) -> Result<Option<Response>>
980 where
981 D: Fn() -> String,
982 {
983 let response = self.send_raw_with_retry(build_request, &describe)?;
984 if response.status() == StatusCode::UNPROCESSABLE_ENTITY {
985 let body =
986 response.text().unwrap_or_else(|_| String::from("<response body unavailable>"));
987 if body.to_ascii_lowercase().contains("fast forward") {
988 return Ok(None);
989 }
990 bail!("{}: GitHub API returned 422 Unprocessable Entity ({body})", describe());
991 }
992 if response.status().is_success() {
993 return Ok(Some(response));
994 }
995 self.error_from_response(response, &describe()).map(Some)
996 }
997
998 fn should_retry_response(response: &Response) -> bool {
999 if response.status() == StatusCode::TOO_MANY_REQUESTS || response.status().is_server_error()
1000 {
1001 return true;
1002 }
1003
1004 response.status() == StatusCode::FORBIDDEN
1005 && (response
1006 .headers()
1007 .get("x-ratelimit-remaining")
1008 .and_then(|value| value.to_str().ok())
1009 == Some("0")
1010 || response.headers().contains_key(RETRY_AFTER))
1011 }
1012
1013 fn sleep_for_retry(&self, headers: &HeaderMap, attempt: u32) {
1014 let delay = retry_delay_from_headers(headers)
1015 .filter(|delay| *delay > Duration::ZERO && *delay <= self.max_retry_delay * 10)
1016 .unwrap_or_else(|| self.calculate_backoff(attempt));
1017 thread::sleep(delay);
1018 }
1019
1020 fn calculate_backoff(&self, attempt: u32) -> Duration {
1021 let shift = attempt.min(10);
1022 let candidate = self.retry_delay.saturating_mul(1u32 << shift);
1023 candidate.min(self.max_retry_delay)
1024 }
1025
1026 fn error_from_response(&self, response: Response, context: &str) -> Result<Response> {
1027 let status = response.status();
1028 let body = response.text().unwrap_or_else(|_| String::from("<response body unavailable>"));
1029
1030 if status == StatusCode::FORBIDDEN
1031 && body.to_ascii_lowercase().contains("rate limit")
1032 && self.token.is_none()
1033 {
1034 bail!(
1035 "{context}: GitHub API rate limit exceeded. Provide --token or GITHUB_TOKEN for higher limits."
1036 )
1037 }
1038 if status == StatusCode::NOT_FOUND {
1039 bail!("{context}: resource not found ({body})");
1040 }
1041
1042 bail!("{context}: GitHub API returned {status} ({body})")
1043 }
1044}
1045
1046fn commit_info_from_response(commit: RepoCommitResponse) -> CommitInfo {
1047 CommitInfo {
1048 sha: commit.sha,
1049 message: commit.commit.message,
1050 is_merge: commit.parents.len() > 1,
1051 }
1052}
1053
1054fn normalize_token(token: &str) -> Option<String> {
1055 let trimmed = token.trim();
1056 if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) }
1057}
1058
1059fn retry_delay_from_headers(headers: &HeaderMap) -> Option<Duration> {
1060 if let Some(retry_after) = headers.get(RETRY_AFTER).and_then(|value| value.to_str().ok())
1061 && let Ok(seconds) = retry_after.parse::<u64>()
1062 {
1063 return Some(Duration::from_secs(seconds));
1064 }
1065
1066 let remaining = headers
1067 .get("x-ratelimit-remaining")
1068 .and_then(|value| value.to_str().ok())
1069 .and_then(|value| value.parse::<u64>().ok());
1070 let reset = headers
1071 .get("x-ratelimit-reset")
1072 .and_then(|value| value.to_str().ok())
1073 .and_then(|value| value.parse::<u64>().ok());
1074
1075 if remaining == Some(0)
1076 && let Some(reset) = reset
1077 {
1078 let now =
1079 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
1080 if reset > now {
1081 return Some(Duration::from_secs(reset - now) + Duration::from_millis(100));
1082 }
1083 }
1084
1085 None
1086}
1087
1088trait RequestBuilderAuthExt {
1089 fn with_auth(self, client: &GitHubClient) -> Self;
1090}
1091
1092impl RequestBuilderAuthExt for RequestBuilder {
1093 fn with_auth(self, client: &GitHubClient) -> Self {
1094 if let Some(token) = client.token.as_deref() {
1095 self.header(AUTHORIZATION, format!("Bearer {token}"))
1096 } else {
1097 self
1098 }
1099 }
1100}
1101
1102#[cfg(test)]
1103#[allow(clippy::significant_drop_tightening)]
1104mod tests {
1105 use mockito::{Matcher, Server};
1106 use std::time::Duration;
1107
1108 use super::{GitHubClient, GitHubClientOptions};
1109
1110 #[test]
1111 fn resolve_reference_returns_commit_sha() {
1112 let mut server = Server::new();
1113 let _mock = server
1114 .mock("GET", "/repos/actions/checkout/commits/v4")
1115 .match_header("user-agent", "github-actions-maintainer")
1116 .with_status(200)
1117 .with_body(r#"{"sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"}"#)
1118 .create();
1119
1120 let client = GitHubClient::new(server.url(), None).expect("github client");
1121 let sha = client.resolve_reference("actions", "checkout", "v4").expect("resolve reference");
1122
1123 assert_eq!(sha, "de0fac2e4500dabe0009e67214ff5f5447ce83dd");
1124 }
1125
1126 #[test]
1127 fn resolve_reference_sends_authorization_when_token_is_present() {
1128 let mut server = Server::new();
1129 let _mock = server
1130 .mock("GET", "/repos/actions/cache/commits/v4")
1131 .match_header("authorization", Matcher::Regex(r"^Bearer\s+ghp_testtoken$".into()))
1132 .with_status(200)
1133 .with_body(r#"{"sha":"668228422ae6a00e4ad889ee87cd7109ec5666a7"}"#)
1134 .create();
1135
1136 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1137 .expect("github client");
1138 let sha = client.resolve_reference("actions", "cache", "v4").expect("resolve reference");
1139
1140 assert_eq!(sha, "668228422ae6a00e4ad889ee87cd7109ec5666a7");
1141 }
1142
1143 #[test]
1144 fn resolve_reference_retries_after_rate_limit() {
1145 let mut server = Server::new();
1146 let now = std::time::SystemTime::now()
1147 .duration_since(std::time::UNIX_EPOCH)
1148 .expect("system time")
1149 .as_secs();
1150
1151 let _rate_limited = server
1152 .mock("GET", "/repos/actions/checkout/commits/v4")
1153 .expect(1)
1154 .with_status(403)
1155 .with_header("x-ratelimit-remaining", "0")
1156 .with_header("x-ratelimit-reset", &now.to_string())
1157 .with_body(r#"{"message":"API rate limit exceeded"}"#)
1158 .create();
1159 let _success = server
1160 .mock("GET", "/repos/actions/checkout/commits/v4")
1161 .expect(1)
1162 .with_status(200)
1163 .with_body(r#"{"sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"}"#)
1164 .create();
1165
1166 let client = GitHubClient::with_options(GitHubClientOptions {
1167 base_url: server.url(),
1168 token: None,
1169 timeout: Duration::from_secs(5),
1170 max_retries: 1,
1171 retry_delay: Duration::from_millis(1),
1172 max_retry_delay: Duration::from_millis(5),
1173 })
1174 .expect("github client");
1175
1176 let sha = client.resolve_reference("actions", "checkout", "v4").expect("resolve reference");
1177
1178 assert_eq!(sha, "de0fac2e4500dabe0009e67214ff5f5447ce83dd");
1179 }
1180
1181 #[test]
1182 fn resolve_reference_retries_when_retry_after_is_present() {
1183 let mut server = Server::new();
1184
1185 let _rate_limited = server
1186 .mock("GET", "/repos/actions/cache/commits/v4")
1187 .expect(1)
1188 .with_status(403)
1189 .with_header("retry-after", "0")
1190 .with_body(r#"{"message":"You have exceeded a secondary rate limit"}"#)
1191 .create();
1192 let _success = server
1193 .mock("GET", "/repos/actions/cache/commits/v4")
1194 .expect(1)
1195 .with_status(200)
1196 .with_body(r#"{"sha":"668228422ae6a00e4ad889ee87cd7109ec5666a7"}"#)
1197 .create();
1198
1199 let client = GitHubClient::with_options(GitHubClientOptions {
1200 base_url: server.url(),
1201 token: Some(String::from("ghp_testtoken")),
1202 timeout: Duration::from_secs(5),
1203 max_retries: 1,
1204 retry_delay: Duration::from_millis(1),
1205 max_retry_delay: Duration::from_millis(5),
1206 })
1207 .expect("github client");
1208
1209 let sha = client.resolve_reference("actions", "cache", "v4").expect("resolve reference");
1210
1211 assert_eq!(sha, "668228422ae6a00e4ad889ee87cd7109ec5666a7");
1212 }
1213
1214 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."}"#;
1215
1216 #[test]
1217 fn resolve_reference_falls_back_to_an_anonymous_request_on_403() {
1218 let mut server = Server::new();
1219 let forbidden = server
1220 .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1221 .match_header("authorization", Matcher::Regex(r"^Bearer\s+ghp_testtoken$".into()))
1222 .expect(1)
1223 .with_status(403)
1224 .with_body(IP_ALLOW_LIST_BODY)
1225 .create();
1226 let anonymous = server
1227 .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1228 .match_header("authorization", Matcher::Missing)
1229 .expect(1)
1230 .with_status(200)
1231 .with_body(r#"{"sha":"6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5"}"#)
1232 .create();
1233
1234 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1235 .expect("github client");
1236 let sha = client
1237 .resolve_reference("aquasecurity", "trivy-action", "0.33.1")
1238 .expect("resolve reference");
1239
1240 assert_eq!(sha, "6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5");
1241 forbidden.assert();
1242 anonymous.assert();
1243 }
1244
1245 #[test]
1246 fn latest_reference_falls_back_to_an_anonymous_request_on_403() {
1247 let mut server = Server::new();
1248 let forbidden = server
1249 .mock("GET", "/repos/aquasecurity/trivy-action/releases/latest")
1250 .match_header("authorization", Matcher::Regex(r"^Bearer\s+ghp_testtoken$".into()))
1251 .expect(1)
1252 .with_status(403)
1253 .with_body(IP_ALLOW_LIST_BODY)
1254 .create();
1255 let anonymous = server
1256 .mock("GET", "/repos/aquasecurity/trivy-action/releases/latest")
1257 .match_header("authorization", Matcher::Missing)
1258 .expect(1)
1259 .with_status(200)
1260 .with_body(r#"{"tag_name":"0.33.1"}"#)
1261 .create();
1262 let _commit = server
1263 .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1264 .with_status(200)
1265 .with_body(r#"{"sha":"6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5"}"#)
1266 .create();
1267
1268 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1269 .expect("github client");
1270 let latest =
1271 client.latest_reference("aquasecurity", "trivy-action").expect("latest reference");
1272
1273 assert_eq!(latest.version, "0.33.1");
1274 assert_eq!(latest.sha, "6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5");
1275 forbidden.assert();
1276 anonymous.assert();
1277 }
1278
1279 #[test]
1280 fn resolve_reference_reports_the_original_403_when_the_anonymous_retry_fails() {
1281 let mut server = Server::new();
1282 let _forbidden = server
1283 .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1284 .match_header("authorization", Matcher::Regex(r"^Bearer\s+ghp_testtoken$".into()))
1285 .expect(1)
1286 .with_status(403)
1287 .with_body(IP_ALLOW_LIST_BODY)
1288 .create();
1289 let anonymous = server
1290 .mock("GET", "/repos/aquasecurity/trivy-action/commits/0.33.1")
1291 .match_header("authorization", Matcher::Missing)
1292 .expect(1)
1293 .with_status(404)
1294 .with_body(r#"{"message":"Not Found"}"#)
1295 .create();
1296
1297 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1298 .expect("github client");
1299 let error = client
1300 .resolve_reference("aquasecurity", "trivy-action", "0.33.1")
1301 .expect_err("forbidden");
1302 let message = error.to_string();
1303
1304 assert!(message.contains("403 Forbidden"), "{message}");
1305 assert!(message.contains("IP allow list"), "{message}");
1306 assert!(message.contains("anonymous retry"), "{message}");
1307 assert!(message.contains("404"), "{message}");
1308 anonymous.assert();
1309 }
1310
1311 #[test]
1312 fn resolve_reference_does_not_fall_back_to_anonymous_on_401() {
1313 let mut server = Server::new();
1314 let unauthorized = server
1315 .mock("GET", "/repos/actions/checkout/commits/v4")
1316 .expect(1)
1317 .with_status(401)
1318 .with_body(r#"{"message":"Bad credentials"}"#)
1319 .create();
1320
1321 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1322 .expect("github client");
1323 let error =
1324 client.resolve_reference("actions", "checkout", "v4").expect_err("bad credentials");
1325 let message = error.to_string();
1326
1327 assert!(message.contains("401 Unauthorized"), "{message}");
1328 assert!(!message.contains("anonymous retry"), "{message}");
1329 unauthorized.assert();
1330 }
1331
1332 #[test]
1333 fn resolve_reference_without_a_token_sends_a_single_request_on_403() {
1334 let mut server = Server::new();
1335 let forbidden = server
1336 .mock("GET", "/repos/actions/checkout/commits/v4")
1337 .match_header("authorization", Matcher::Missing)
1338 .expect(1)
1339 .with_status(403)
1340 .with_body(r#"{"message":"Resource not accessible"}"#)
1341 .create();
1342
1343 let client = GitHubClient::new(server.url(), None).expect("github client");
1344 let error = client.resolve_reference("actions", "checkout", "v4").expect_err("forbidden");
1345 let message = error.to_string();
1346
1347 assert!(message.contains("403 Forbidden"), "{message}");
1348 assert!(!message.contains("anonymous retry"), "{message}");
1349 forbidden.assert();
1350 }
1351
1352 #[test]
1353 fn create_ref_does_not_fall_back_to_anonymous_on_403() {
1354 let mut server = Server::new();
1355 let forbidden = server
1356 .mock("POST", "/repos/acme/demo/git/refs")
1357 .expect(1)
1358 .with_status(403)
1359 .with_body(r#"{"message":"Resource not accessible by integration"}"#)
1360 .create();
1361
1362 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1363 .expect("github client");
1364 let error =
1365 client.create_ref("acme", "demo", "tags/v1.2.3", "commitsha").expect_err("forbidden");
1366
1367 assert!(error.to_string().contains("403 Forbidden"), "{error}");
1368 forbidden.assert();
1369 }
1370
1371 #[test]
1372 fn reference_sha_returns_none_when_ref_is_missing() {
1373 let mut server = Server::new();
1374 let _mock = server
1375 .mock("GET", "/repos/acme/demo/git/ref/tags/v1.2.3")
1376 .with_status(404)
1377 .with_body(r#"{"message":"Not Found"}"#)
1378 .create();
1379
1380 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1381 .expect("github client");
1382 let sha = client.reference_sha("acme", "demo", "tags/v1.2.3").expect("reference sha");
1383
1384 assert_eq!(sha, None);
1385 }
1386
1387 #[test]
1388 fn reference_sha_returns_object_sha() {
1389 let mut server = Server::new();
1390 let _mock = server
1391 .mock("GET", "/repos/acme/demo/git/ref/tags/v1.2.3")
1392 .with_status(200)
1393 .with_body(r#"{"ref":"refs/tags/v1.2.3","object":{"sha":"tagsha"}}"#)
1394 .create();
1395
1396 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1397 .expect("github client");
1398 let sha = client.reference_sha("acme", "demo", "tags/v1.2.3").expect("reference sha");
1399
1400 assert_eq!(sha.as_deref(), Some("tagsha"));
1401 }
1402
1403 #[test]
1404 fn create_ref_posts_fully_qualified_tag_reference() {
1405 let mut server = Server::new();
1406 let _mock = server
1407 .mock("POST", "/repos/acme/demo/git/refs")
1408 .match_body(Matcher::Regex(r#""ref":"refs/tags/v1\.2\.3""#.into()))
1409 .with_status(201)
1410 .with_body(r#"{"ref":"refs/tags/v1.2.3"}"#)
1411 .create();
1412
1413 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1414 .expect("github client");
1415 client.create_ref("acme", "demo", "tags/v1.2.3", "commitsha").expect("create ref");
1416 }
1417
1418 #[test]
1419 fn update_ref_serializes_force_flag() {
1420 let mut server = Server::new();
1421 let _mock = server
1422 .mock("PATCH", "/repos/acme/demo/git/refs/tags/v1")
1423 .match_body(Matcher::Regex(r#""force":true"#.into()))
1424 .with_status(200)
1425 .with_body(r#"{"ref":"refs/tags/v1"}"#)
1426 .create();
1427
1428 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1429 .expect("github client");
1430 client.update_ref("acme", "demo", "tags/v1", "commitsha", true).expect("update ref");
1431 }
1432
1433 #[test]
1434 fn update_ref_fast_forward_reports_non_fast_forward_updates() {
1435 let mut server = Server::new();
1436 let _mock = server
1437 .mock("PATCH", "/repos/acme/demo/git/refs/heads/main")
1438 .match_body(Matcher::Regex(r#""force":false"#.into()))
1439 .with_status(422)
1440 .with_body(r#"{"message":"Update is not a fast forward"}"#)
1441 .create();
1442
1443 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1444 .expect("github client");
1445 let advanced = client
1446 .update_ref_fast_forward("acme", "demo", "heads/main", "commitsha")
1447 .expect("fast-forward ref");
1448
1449 assert!(!advanced);
1450 }
1451
1452 #[test]
1453 fn update_ref_fast_forward_errors_on_unrelated_validation_failures() {
1454 let mut server = Server::new();
1455 let _mock = server
1456 .mock("PATCH", "/repos/acme/demo/git/refs/heads/main")
1457 .with_status(422)
1458 .with_body(r#"{"message":"Object does not exist"}"#)
1459 .create();
1460
1461 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1462 .expect("github client");
1463 let error = client
1464 .update_ref_fast_forward("acme", "demo", "heads/main", "commitsha")
1465 .expect_err("validation failure");
1466
1467 assert!(error.to_string().contains("Object does not exist"), "{error}");
1468 }
1469
1470 #[test]
1471 fn update_ref_fast_forward_succeeds_when_ref_is_current() {
1472 let mut server = Server::new();
1473 let _mock = server
1474 .mock("PATCH", "/repos/acme/demo/git/refs/heads/main")
1475 .with_status(200)
1476 .with_body(r#"{"ref":"refs/heads/main"}"#)
1477 .create();
1478
1479 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1480 .expect("github client");
1481 let advanced = client
1482 .update_ref_fast_forward("acme", "demo", "heads/main", "commitsha")
1483 .expect("fast-forward ref");
1484
1485 assert!(advanced);
1486 }
1487
1488 #[test]
1489 fn list_tags_paginates_until_a_short_page() {
1490 let mut server = Server::new();
1491 let full_page: Vec<String> = (0..100)
1492 .map(|index| format!(r#"{{"name":"v0.0.{index}","commit":{{"sha":"{index:040}"}}}}"#))
1493 .collect();
1494 let _first = server
1495 .mock("GET", "/repos/acme/demo/tags?per_page=100&page=1")
1496 .expect(1)
1497 .with_status(200)
1498 .with_body(format!("[{}]", full_page.join(",")))
1499 .create();
1500 let _second = server
1501 .mock("GET", "/repos/acme/demo/tags?per_page=100&page=2")
1502 .expect(1)
1503 .with_status(200)
1504 .with_body(r#"[{"name":"v1.0.0","commit":{"sha":"lasttagsha"}}]"#)
1505 .create();
1506
1507 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1508 .expect("github client");
1509 let tags = client.list_tags("acme", "demo", 5).expect("list tags");
1510
1511 assert_eq!(tags.len(), 101);
1512 assert_eq!(tags[100].name, "v1.0.0");
1513 assert_eq!(tags[100].sha.as_deref(), Some("lasttagsha"));
1514 }
1515
1516 #[test]
1517 fn compare_commits_paginates_and_flags_merge_commits() {
1518 let mut server = Server::new();
1519 let first_page: Vec<String> = (0..100)
1520 .map(|index| {
1521 format!(
1522 r#"{{"sha":"{index:040}","commit":{{"message":"feat: change {index}"}},"parents":[{{}}]}}"#
1523 )
1524 })
1525 .collect();
1526 let _first = server
1527 .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=1")
1528 .expect(1)
1529 .with_status(200)
1530 .with_body(format!(r#"{{"total_commits":101,"commits":[{}]}}"#, first_page.join(",")))
1531 .create();
1532 let _second = server
1533 .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=2")
1534 .expect(1)
1535 .with_status(200)
1536 .with_body(
1537 r#"{"total_commits":101,"commits":[{"sha":"mergesha","commit":{"message":"Merge pull request #1"},"parents":[{},{}]}]}"#,
1538 )
1539 .create();
1540
1541 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1542 .expect("github client");
1543 let range = client
1544 .compare_commits("acme", "demo", "v0.1.0", "headsha", 5)
1545 .expect("compare commits");
1546
1547 assert_eq!(range.commits.len(), 101);
1548 assert!(!range.truncated);
1549 assert!(!range.commits[0].is_merge);
1550 assert!(range.commits[100].is_merge);
1551 assert_eq!(range.commits[100].sha, "mergesha");
1552 }
1553
1554 #[test]
1555 fn compare_commits_marks_truncation_at_the_page_cap() {
1556 let mut server = Server::new();
1557 let first_page: Vec<String> = (0..100)
1558 .map(|index| {
1559 format!(r#"{{"sha":"{index:040}","commit":{{"message":"fix: {index}"}},"parents":[{{}}]}}"#)
1560 })
1561 .collect();
1562 let _first = server
1563 .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=1")
1564 .expect(1)
1565 .with_status(200)
1566 .with_body(format!(r#"{{"total_commits":150,"commits":[{}]}}"#, first_page.join(",")))
1567 .create();
1568
1569 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1570 .expect("github client");
1571 let range = client
1572 .compare_commits("acme", "demo", "v0.1.0", "headsha", 1)
1573 .expect("compare commits");
1574
1575 assert_eq!(range.commits.len(), 100);
1576 assert!(range.truncated);
1577 }
1578
1579 #[test]
1580 fn compare_commits_stops_when_pages_run_dry() {
1581 let mut server = Server::new();
1582 let first_page: Vec<String> = (0..100)
1583 .map(|index| {
1584 format!(
1585 r#"{{"sha":"{index:040}","commit":{{"message":"fix: {index}"}},"parents":[{{}}]}}"#
1586 )
1587 })
1588 .collect();
1589 let _first = server
1590 .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=1")
1591 .expect(1)
1592 .with_status(200)
1593 .with_body(format!(r#"{{"total_commits":300,"commits":[{}]}}"#, first_page.join(",")))
1594 .create();
1595 let _second = server
1596 .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=2")
1597 .expect(1)
1598 .with_status(200)
1599 .with_body(r#"{"total_commits":300,"commits":[]}"#)
1600 .create();
1601 let _third = server
1602 .mock("GET", "/repos/acme/demo/compare/v0.1.0...headsha?per_page=100&page=3")
1603 .expect(0)
1604 .create();
1605
1606 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1607 .expect("github client");
1608 let range =
1609 client.compare_commits("acme", "demo", "v0.1.0", "headsha", 10).expect("compare");
1610
1611 assert_eq!(range.commits.len(), 100);
1612 assert!(range.truncated);
1613 }
1614
1615 #[test]
1616 fn list_commits_stops_on_a_short_page() {
1617 let mut server = Server::new();
1618 let _first = server
1619 .mock("GET", "/repos/acme/demo/commits?sha=headsha&per_page=100&page=1")
1620 .expect(1)
1621 .with_status(200)
1622 .with_body(r#"[{"sha":"onlysha","commit":{"message":"feat: initial"},"parents":[]}]"#)
1623 .create();
1624
1625 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1626 .expect("github client");
1627 let range = client.list_commits("acme", "demo", "headsha", 3).expect("list commits");
1628
1629 assert_eq!(range.commits.len(), 1);
1630 assert!(!range.truncated);
1631 assert_eq!(range.commits[0].message, "feat: initial");
1632 }
1633
1634 #[test]
1635 fn create_release_posts_tag_and_returns_url() {
1636 let mut server = Server::new();
1637 let _mock = server
1638 .mock("POST", "/repos/acme/demo/releases")
1639 .match_body(Matcher::AllOf(vec![
1640 Matcher::Regex(r#""tag_name":"v1\.2\.3""#.into()),
1641 Matcher::Regex(r#""target_commitish":"commitsha""#.into()),
1642 Matcher::Regex(r#""name":"Release v1\.2\.3""#.into()),
1643 ]))
1644 .with_status(201)
1645 .with_body(r#"{"html_url":"https://github.com/acme/demo/releases/tag/v1.2.3"}"#)
1646 .create();
1647
1648 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1649 .expect("github client");
1650 let release = client
1651 .create_release("acme", "demo", "v1.2.3", "Release v1.2.3", "notes", "commitsha")
1652 .expect("create release");
1653
1654 assert_eq!(release.url, "https://github.com/acme/demo/releases/tag/v1.2.3");
1655 }
1656
1657 #[test]
1658 fn ensure_token_requires_a_token() {
1659 let client = GitHubClient::new("https://api.github.com", None).expect("github client");
1660 let error = client.ensure_token().expect_err("missing token");
1661
1662 assert!(error.to_string().contains("GitHub token"));
1663 }
1664
1665 #[test]
1666 fn validate_token_scopes_requires_workflow_scope() {
1667 let mut server = Server::new();
1668 let _user = server
1669 .mock("GET", "/user")
1670 .match_header("authorization", Matcher::Regex("^Bearer\\s+ghp_testtoken$".into()))
1671 .with_status(200)
1672 .with_header("x-oauth-scopes", "repo")
1673 .with_body(r#"{"login":"octocat"}"#)
1674 .create();
1675
1676 let client = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
1677 .expect("github client");
1678 let error = client.validate_token_scopes().expect_err("missing workflow scope");
1679
1680 assert!(error.to_string().contains("workflow scope"));
1681 }
1682}