1use std::{env, fs, future::Future, pin::Pin, sync::LazyLock, sync::OnceLock, time::Duration};
2
3use chrono::{DateTime, Utc};
4use octocrab::{
5 Octocrab, OctocrabBuilder, Result as OctoResult,
6 models::{
7 Event as TimelineEventType, commits::GithubCommitStatus, repos::RepoCommit,
8 timelines::TimelineEvent,
9 },
10};
11use serde::Deserialize;
12
13use crate::error::{LogError, WtgError, WtgResult};
14use crate::git::{CommitInfo, TagInfo, parse_semver};
15use crate::notice::{Notice, NoticeCallback};
16use crate::parse_input::parse_github_repo_url;
17
18impl From<RepoCommit> for CommitInfo {
19 fn from(commit: RepoCommit) -> Self {
20 let message = commit.commit.message;
21 let message_lines = message.lines().count();
22
23 let author_name = commit
24 .commit
25 .author
26 .as_ref()
27 .map_or_else(|| "Unknown".to_string(), |a| a.name.clone());
28
29 let author_email = commit.commit.author.as_ref().and_then(|a| a.email.clone());
30
31 let commit_url = commit.html_url;
32
33 let (author_login, author_url) = commit
34 .author
35 .map(|author| (Some(author.login), Some(author.html_url.into())))
36 .unwrap_or_default();
37
38 let date = commit
39 .commit
40 .author
41 .as_ref()
42 .and_then(|a| a.date.as_ref())
43 .copied()
44 .unwrap_or_else(Utc::now);
45
46 let full_hash = commit.sha;
47
48 Self {
49 hash: full_hash.clone(),
50 short_hash: full_hash[..7.min(full_hash.len())].to_string(),
51 message: message.lines().next().unwrap_or("").to_string(),
52 message_lines,
53 commit_url: Some(commit_url),
54 author_name,
55 author_email,
56 author_login,
57 author_url,
58 date,
59 }
60 }
61}
62
63const CONNECT_TIMEOUT_SECS: u64 = 5;
64const READ_TIMEOUT_SECS: u64 = 30;
65const REQUEST_TIMEOUT_SECS: u64 = 5;
66
67#[derive(Debug, Deserialize)]
68struct GhConfig {
69 #[serde(rename = "github.com")]
70 github_com: GhHostConfig,
71}
72
73#[derive(Debug, Deserialize)]
74struct GhHostConfig {
75 oauth_token: Option<String>,
76}
77
78#[derive(Debug, Clone)]
79pub struct GhRepoInfo {
80 owner: String,
81 repo: String,
82}
83
84impl GhRepoInfo {
85 #[must_use]
86 pub const fn new(owner: String, repo: String) -> Self {
87 Self { owner, repo }
88 }
89
90 #[must_use]
91 pub fn owner(&self) -> &str {
92 &self.owner
93 }
94
95 #[must_use]
96 pub fn repo(&self) -> &str {
97 &self.repo
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103enum ClientSelection {
104 Main,
106 Fallback(FallbackReason),
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112enum FallbackReason {
113 Saml,
115 BadCredentials,
117}
118
119struct ApiCallResult<'a, T> {
122 value: T,
123 client: &'a Octocrab,
124 selection: ClientSelection,
125}
126
127pub struct GitHubClient {
135 main_client: Octocrab,
136 backup_client: LazyLock<Option<Octocrab>>,
139 is_authenticated: bool,
141 notice_callback: OnceLock<NoticeCallback>,
144}
145
146#[derive(Debug, Clone)]
148pub struct PullRequestInfo {
149 pub number: u64,
150 pub repo_info: Option<GhRepoInfo>,
151 pub title: String,
152 pub body: Option<String>,
153 pub state: String,
154 pub url: String,
155 pub merged: bool,
156 pub merge_commit_sha: Option<String>,
157 pub author: Option<String>,
158 pub author_url: Option<String>,
159 pub created_at: Option<DateTime<Utc>>, }
161
162impl From<octocrab::models::pulls::PullRequest> for PullRequestInfo {
163 fn from(pr: octocrab::models::pulls::PullRequest) -> Self {
164 let author = pr.user.as_ref().map(|u| u.login.clone());
165 let author_url = pr.user.as_ref().map(|u| u.html_url.to_string());
166 let created_at = pr.created_at;
167
168 Self {
169 number: pr.number,
170 repo_info: parse_github_repo_url(pr.url.as_str()),
171 title: pr.title.unwrap_or_default(),
172 body: pr.body,
173 state: format!("{:?}", pr.state),
174 url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
175 merged: pr.merged.unwrap_or(false),
176 merge_commit_sha: pr.merge_commit_sha,
177 author,
178 author_url,
179 created_at,
180 }
181 }
182}
183
184#[derive(Debug, Clone)]
186pub struct ExtendedIssueInfo {
187 pub number: u64,
188 pub title: String,
189 pub body: Option<String>,
190 pub state: octocrab::models::IssueState,
191 pub url: String,
192 pub author: Option<String>,
193 pub author_url: Option<String>,
194 pub closing_prs: Vec<PullRequestInfo>, pub created_at: Option<DateTime<Utc>>, pub timeline_may_be_incomplete: bool,
199}
200
201impl TryFrom<octocrab::models::issues::Issue> for ExtendedIssueInfo {
202 type Error = ();
203
204 fn try_from(issue: octocrab::models::issues::Issue) -> Result<Self, Self::Error> {
205 if issue.pull_request.is_some() {
207 return Err(());
208 }
209
210 let author = issue.user.login.clone();
211 let author_url = Some(issue.user.html_url.to_string());
212 let created_at = Some(issue.created_at);
213
214 Ok(Self {
215 number: issue.number,
216 title: issue.title,
217 body: issue.body,
218 state: issue.state,
219 url: issue.html_url.to_string(),
220 author: Some(author),
221 author_url,
222 closing_prs: Vec::new(), created_at,
224 timeline_may_be_incomplete: false,
225 })
226 }
227}
228
229#[derive(Debug, Clone)]
230pub struct ReleaseInfo {
231 pub tag_name: String,
232 pub name: Option<String>,
233 pub body: Option<String>,
234 pub url: String,
235 pub published_at: Option<DateTime<Utc>>,
236 pub created_at: Option<DateTime<Utc>>,
237 pub prerelease: bool,
238}
239
240impl GitHubClient {
241 #[must_use]
247 pub fn new() -> Option<Self> {
248 if let Some(auth) = Self::build_auth_client() {
250 return Some(Self {
252 main_client: auth,
253 backup_client: LazyLock::new(Self::build_anonymous_client),
254 is_authenticated: true,
255 notice_callback: OnceLock::new(),
256 });
257 }
258
259 let anonymous = Self::build_anonymous_client()?;
262 Some(Self {
263 main_client: anonymous,
264 backup_client: LazyLock::new(|| None),
265 is_authenticated: false,
266 notice_callback: OnceLock::new(),
267 })
268 }
269
270 #[must_use]
276 pub fn new_with_token(token: String) -> Option<Self> {
277 if token.trim().is_empty() {
278 return None;
279 }
280
281 let connect_timeout = Some(Self::connect_timeout());
282 let read_timeout = Some(Self::read_timeout());
283
284 let auth = OctocrabBuilder::new()
285 .personal_token(token)
286 .set_connect_timeout(connect_timeout)
287 .set_read_timeout(read_timeout)
288 .build()
289 .ok()?;
290
291 Some(Self {
292 main_client: auth,
293 backup_client: LazyLock::new(Self::build_anonymous_client),
294 is_authenticated: true,
295 notice_callback: OnceLock::new(),
296 })
297 }
298
299 pub fn set_notice_callback(&self, callback: NoticeCallback) {
303 let _ = self.notice_callback.set(callback);
305 }
306
307 pub(crate) fn emit(&self, notice: Notice) {
309 if let Some(cb) = self.notice_callback.get() {
310 (cb)(notice);
311 }
312 }
313
314 fn build_auth_client() -> Option<Octocrab> {
317 if env::var("WTG_GH_NO_AUTH").is_ok() {
319 log::debug!("WTG_GH_NO_AUTH set, skipping GitHub authentication");
320 return None;
321 }
322
323 let connect_timeout = Some(Self::connect_timeout());
325 let read_timeout = Some(Self::read_timeout());
326
327 if let Ok(token) = env::var("GITHUB_TOKEN")
329 && !token.trim().is_empty()
330 {
331 return OctocrabBuilder::new()
332 .personal_token(token)
333 .set_connect_timeout(connect_timeout)
334 .set_read_timeout(read_timeout)
335 .build()
336 .ok();
337 }
338
339 if let Some(token) = Self::read_gh_config() {
341 return OctocrabBuilder::new()
342 .personal_token(token)
343 .set_connect_timeout(connect_timeout)
344 .set_read_timeout(read_timeout)
345 .build()
346 .ok();
347 }
348
349 None
350 }
351
352 fn build_anonymous_client() -> Option<Octocrab> {
354 let connect_timeout = Some(Self::connect_timeout());
355 let read_timeout = Some(Self::read_timeout());
356
357 OctocrabBuilder::new()
358 .set_connect_timeout(connect_timeout)
359 .set_read_timeout(read_timeout)
360 .build()
361 .ok()
362 }
363
364 fn read_gh_config() -> Option<String> {
366 if let Some(home) = dirs::home_dir() {
372 let xdg_path = home.join(".config").join("gh").join("hosts.yml");
373 if let Ok(content) = fs::read_to_string(&xdg_path)
374 && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
375 && let Some(token) = config.github_com.oauth_token
376 && !token.trim().is_empty()
377 {
378 return Some(token);
379 }
380 }
381
382 if let Some(mut config_path) = dirs::config_dir() {
385 config_path.push("gh");
386 config_path.push("hosts.yml");
387
388 if let Ok(content) = fs::read_to_string(&config_path)
389 && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
390 {
391 return config
392 .github_com
393 .oauth_token
394 .filter(|t| !t.trim().is_empty());
395 }
396 }
397
398 None
399 }
400
401 pub async fn fetch_commit_full_info(
404 &self,
405 repo_info: &GhRepoInfo,
406 commit_hash: &str,
407 ) -> Option<CommitInfo> {
408 let commit = self
409 .call_client_api_with_fallback(move |client| {
410 let hash = commit_hash.to_string();
411 let repo_info = repo_info.clone();
412 Box::pin(async move {
413 client
414 .commits(repo_info.owner(), repo_info.repo())
415 .get(&hash)
416 .await
417 })
418 })
419 .await
420 .log_err(&format!(
421 "fetch_commit_full_info failed for {}/{} commit {}",
422 repo_info.owner(),
423 repo_info.repo(),
424 commit_hash
425 ))?;
426
427 Some(commit.into())
428 }
429
430 pub async fn fetch_pr(&self, repo_info: &GhRepoInfo, number: u64) -> Option<PullRequestInfo> {
432 let pr = self
433 .call_client_api_with_fallback(move |client| {
434 let repo_info = repo_info.clone();
435 Box::pin(async move {
436 client
437 .pulls(repo_info.owner(), repo_info.repo())
438 .get(number)
439 .await
440 })
441 })
442 .await
443 .log_err(&format!(
444 "fetch_pr failed for {}/{} PR #{}",
445 repo_info.owner(),
446 repo_info.repo(),
447 number
448 ))?;
449
450 Some(pr.into())
451 }
452
453 pub async fn fetch_issue(
455 &self,
456 repo_info: &GhRepoInfo,
457 number: u64,
458 ) -> Option<ExtendedIssueInfo> {
459 let issue = self
460 .call_client_api_with_fallback(move |client| {
461 let repo_info = repo_info.clone();
462 Box::pin(async move {
463 client
464 .issues(repo_info.owner(), repo_info.repo())
465 .get(number)
466 .await
467 })
468 })
469 .await
470 .log_err(&format!(
471 "fetch_issue failed for {}/{} issue #{}",
472 repo_info.owner(),
473 repo_info.repo(),
474 number
475 ))?;
476
477 let mut issue_info = ExtendedIssueInfo::try_from(issue).ok()?;
478
479 if matches!(issue_info.state, octocrab::models::IssueState::Closed) {
481 let (closing_prs, timeline_may_be_incomplete) =
482 self.find_closing_prs(repo_info, issue_info.number).await;
483 issue_info.closing_prs = closing_prs;
484 issue_info.timeline_may_be_incomplete = timeline_may_be_incomplete;
485 }
486
487 Some(issue_info)
488 }
489
490 async fn find_closing_prs(
496 &self,
497 repo_info: &GhRepoInfo,
498 issue_number: u64,
499 ) -> (Vec<PullRequestInfo>, bool) {
500 let mut closing_prs = Vec::new();
501
502 let Ok(result) = self
504 .call_api_and_get_client(move |client| {
505 let repo_info = repo_info.clone();
506 Box::pin(async move {
507 client
508 .issues(repo_info.owner(), repo_info.repo())
509 .list_timeline_events(issue_number)
510 .per_page(100)
511 .send()
512 .await
513 })
514 })
515 .await
516 else {
517 return (Vec::new(), true);
519 };
520
521 let mut current_page = result.value;
522 let client = result.client;
523 let timeline_may_be_incomplete = matches!(
524 result.selection,
525 ClientSelection::Fallback(FallbackReason::Saml | FallbackReason::BadCredentials)
526 );
527
528 'pagination: loop {
530 for event in ¤t_page.items {
531 if let Some(source) = event.source.as_ref() {
533 let issue = &source.issue;
534 if issue.pull_request.is_some() {
535 if let Some(repo_info) =
537 parse_github_repo_url(issue.repository_url.as_str())
538 {
539 let Some(pr_info) =
540 Box::pin(self.fetch_pr(&repo_info, issue.number)).await
541 else {
542 continue; };
544
545 if !pr_info.merged {
546 continue; }
548
549 if matches!(event.event, TimelineEventType::Closed) {
550 closing_prs.push(pr_info);
552 break 'pagination; }
554
555 if !matches!(
557 event.event,
558 TimelineEventType::CrossReferenced | TimelineEventType::Referenced
559 ) {
560 continue;
561 }
562
563 if !closing_prs.iter().any(|p| {
566 p.number == issue.number
567 && p.repo_info
568 .as_ref()
569 .is_some_and(|ri| ri.owner() == repo_info.owner())
570 && p.repo_info
571 .as_ref()
572 .is_some_and(|ri| ri.repo() == repo_info.repo())
573 }) {
574 closing_prs.push(pr_info);
575 }
576 }
577 }
578 }
579 }
580
581 match Self::await_with_timeout_and_error(
582 client.get_page::<TimelineEvent>(¤t_page.next),
583 )
584 .await
585 .ok()
586 .flatten()
587 {
588 Some(next_page) => current_page = next_page,
589 None => break,
590 }
591 }
592
593 (closing_prs, timeline_may_be_incomplete)
594 }
595
596 #[allow(clippy::too_many_lines)]
600 pub async fn fetch_releases_since(
601 &self,
602 repo_info: &GhRepoInfo,
603 since_date: DateTime<Utc>,
604 ) -> Vec<ReleaseInfo> {
605 let mut releases = Vec::new();
606 let mut page_num = 1u32;
607 let per_page = 100u8; let Ok(result) = self
611 .call_api_and_get_client(move |client| {
612 let repo_info = repo_info.clone();
613 Box::pin(async move {
614 client
615 .repos(repo_info.owner(), repo_info.repo())
616 .releases()
617 .list()
618 .per_page(per_page)
619 .page(page_num)
620 .send()
621 .await
622 })
623 })
624 .await
625 else {
626 return releases;
627 };
628 let mut current_page = result.value;
629 let client = result.client;
630
631 'pagintaion: loop {
632 if current_page.items.is_empty() {
633 break; }
635
636 current_page
638 .items
639 .sort_by(|a, b| b.created_at.cmp(&a.created_at));
640
641 for release in current_page.items {
642 let release_tag_created_at = release.created_at.unwrap_or_default();
644
645 if release_tag_created_at < since_date {
646 break 'pagintaion; }
648
649 releases.push(ReleaseInfo {
650 tag_name: release.tag_name,
651 name: release.name,
652 body: release.body,
653 url: release.html_url.to_string(),
654 published_at: release.published_at,
655 created_at: release.created_at,
656 prerelease: release.prerelease,
657 });
658 }
659
660 if current_page.next.is_none() {
661 break; }
663
664 page_num += 1;
665
666 current_page = match Self::await_with_timeout_and_error(
668 client
669 .repos(repo_info.owner(), repo_info.repo())
670 .releases()
671 .list()
672 .per_page(per_page)
673 .page(page_num)
674 .send(),
675 )
676 .await
677 .ok()
678 {
679 Some(page) => page,
680 None => break, };
682 }
683
684 releases
685 }
686
687 pub async fn fetch_release_by_tag(
689 &self,
690 repo_info: &GhRepoInfo,
691 tag: &str,
692 ) -> Option<ReleaseInfo> {
693 let release = self
694 .call_client_api_with_fallback(move |client| {
695 let tag = tag.to_string();
696 let repo_info = repo_info.clone();
697 Box::pin(async move {
698 client
699 .repos(repo_info.owner(), repo_info.repo())
700 .releases()
701 .get_by_tag(tag.as_str())
702 .await
703 })
704 })
705 .await
706 .log_err(&format!(
707 "fetch_release_by_tag failed for {}/{} tag {}",
708 repo_info.owner(),
709 repo_info.repo(),
710 tag
711 ))?;
712
713 Some(ReleaseInfo {
714 tag_name: release.tag_name,
715 name: release.name,
716 body: release.body,
717 url: release.html_url.to_string(),
718 published_at: release.published_at,
719 created_at: release.created_at,
720 prerelease: release.prerelease,
721 })
722 }
723
724 pub async fn fetch_tag_info_for_release(
728 &self,
729 release: &ReleaseInfo,
730 repo_info: &GhRepoInfo,
731 target_commit: &str,
732 ) -> Option<TagInfo> {
733 let compare = self
735 .call_client_api_with_fallback(move |client| {
736 let tag_name = release.tag_name.clone();
737 let target_commit = target_commit.to_string();
738 let repo_info = repo_info.clone();
739 Box::pin(async move {
740 client
741 .commits(repo_info.owner(), repo_info.repo())
742 .compare(&tag_name, &target_commit)
743 .per_page(1)
744 .send()
745 .await
746 })
747 })
748 .await
749 .log_err(&format!(
750 "fetch_tag_info_for_release failed for {}/{} tag {} vs commit {}",
751 repo_info.owner(),
752 repo_info.repo(),
753 release.tag_name,
754 target_commit
755 ))?;
756
757 if !matches!(
760 compare.status,
761 GithubCommitStatus::Behind | GithubCommitStatus::Identical
762 ) {
763 return None;
764 }
765
766 let semver_info = parse_semver(&release.tag_name);
767
768 Some(TagInfo {
769 name: release.tag_name.clone(),
770 commit_hash: compare.base_commit.sha,
771 semver_info,
772 created_at: release.created_at?,
773 is_release: true,
774 release_name: release.name.clone(),
775 release_url: Some(release.url.clone()),
776 published_at: release.published_at,
777 tag_url: Some(release.url.clone()),
778 })
779 }
780
781 pub async fn tag_contains_commit(
785 &self,
786 repo_info: &GhRepoInfo,
787 tag: &str,
788 commit: &str,
789 ) -> bool {
790 let compare = self
791 .call_client_api_with_fallback(move |client| {
792 let tag = tag.to_string();
793 let commit = commit.to_string();
794 let repo_info = repo_info.clone();
795 Box::pin(async move {
796 client
797 .commits(repo_info.owner(), repo_info.repo())
798 .compare(&tag, &commit)
799 .per_page(1)
800 .send()
801 .await
802 })
803 })
804 .await
805 .ok();
806
807 matches!(
808 compare.map(|c| c.status),
809 Some(GithubCommitStatus::Behind | GithubCommitStatus::Identical)
810 )
811 }
812
813 pub async fn fetch_tag(&self, repo_info: &GhRepoInfo, tag_name: &str) -> Option<TagInfo> {
817 let commit = self.fetch_commit_full_info(repo_info, tag_name).await?;
819
820 let release = self.fetch_release_by_tag(repo_info, tag_name).await;
822
823 let semver_info = parse_semver(tag_name);
824
825 let tag_url = Some(
827 release
828 .as_ref()
829 .map_or_else(|| Self::tag_url(repo_info, tag_name), |r| r.url.clone()),
830 );
831
832 Some(TagInfo {
833 name: tag_name.to_string(),
834 commit_hash: commit.hash,
835 semver_info,
836 created_at: commit.date,
837 is_release: release.is_some(),
838 release_name: release.as_ref().and_then(|r| r.name.clone()),
839 release_url: release.as_ref().map(|r| r.url.clone()),
840 published_at: release.and_then(|r| r.published_at),
841 tag_url,
842 })
843 }
844
845 pub async fn fetch_file_content(&self, repo_info: &GhRepoInfo, path: &str) -> Option<String> {
850 use base64::Engine;
851 use base64::engine::general_purpose::STANDARD;
852
853 let content = self
854 .call_client_api_with_fallback(move |client| {
855 let path = path.to_string();
856 let repo_info = repo_info.clone();
857 Box::pin(async move {
858 client
859 .repos(repo_info.owner(), repo_info.repo())
860 .get_content()
861 .path(&path)
862 .send()
863 .await
864 })
865 })
866 .await
867 .ok()?;
868
869 let file_content = match content.items.into_iter().next()? {
871 octocrab::models::repos::Content {
872 content: Some(encoded),
873 ..
874 } => {
875 let cleaned: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
877 STANDARD.decode(&cleaned).ok()?
878 }
879 _ => return None, };
881
882 String::from_utf8(file_content).ok()
883 }
884
885 #[must_use]
889 pub fn commit_url(repo_info: &GhRepoInfo, hash: &str) -> String {
890 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
891 format!(
892 "https://github.com/{}/{}/commit/{}",
893 utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
894 utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
895 utf8_percent_encode(hash, NON_ALPHANUMERIC)
896 )
897 }
898
899 #[must_use]
902 pub fn tag_url(repo_info: &GhRepoInfo, tag: &str) -> String {
903 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
904 format!(
905 "https://github.com/{}/{}/tree/{}",
906 utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
907 utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
908 utf8_percent_encode(tag, NON_ALPHANUMERIC)
909 )
910 }
911
912 #[must_use]
915 pub fn release_tag_url(repo_info: &GhRepoInfo, tag: &str) -> String {
916 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
917 format!(
918 "https://github.com/{}/{}/releases/tag/{}",
919 utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
920 utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
921 utf8_percent_encode(tag, NON_ALPHANUMERIC)
922 )
923 }
924
925 #[must_use]
928 pub fn profile_url(username: &str) -> String {
929 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
930 format!(
931 "https://github.com/{}",
932 utf8_percent_encode(username, NON_ALPHANUMERIC)
933 )
934 }
935
936 #[must_use]
942 pub fn author_url_from_email(email: &str) -> Option<String> {
943 if email.ends_with("@users.noreply.github.com") {
944 let parts: Vec<&str> = email.split('@').collect();
945 if let Some(user_part) = parts.first()
946 && let Some(username) = user_part.split('+').next_back()
947 {
948 return Some(Self::profile_url(username));
949 }
950 }
951 None
952 }
953
954 const fn connect_timeout() -> Duration {
955 Duration::from_secs(CONNECT_TIMEOUT_SECS)
956 }
957
958 const fn read_timeout() -> Duration {
959 Duration::from_secs(READ_TIMEOUT_SECS)
960 }
961
962 const fn request_timeout() -> Duration {
963 Duration::from_secs(REQUEST_TIMEOUT_SECS)
964 }
965
966 async fn call_client_api_with_fallback<F, T>(&self, api_call: F) -> WtgResult<T>
968 where
969 for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
970 {
971 let result = self.call_api_and_get_client(api_call).await?;
972 Ok(result.value)
973 }
974
975 async fn call_api_and_get_client<F, T>(&self, api_call: F) -> WtgResult<ApiCallResult<'_, T>>
980 where
981 for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
982 {
983 let (main_error, fallback_reason) =
985 match Self::await_with_timeout_and_error(api_call(&self.main_client)).await {
986 Ok(result) => {
987 return Ok(ApiCallResult {
988 value: result,
989 client: &self.main_client,
990 selection: ClientSelection::Main,
991 });
992 }
993 Err(e) if e.is_gh_rate_limit() => {
994 log::debug!(
995 "GitHub API rate limit hit (authenticated={}): {:?}",
996 self.is_authenticated,
997 e
998 );
999 self.emit(Notice::GhRateLimitHit {
1000 authenticated: self.is_authenticated,
1001 });
1002 return Err(e);
1003 }
1004 Err(e) if e.is_gh_saml() && self.is_authenticated => {
1005 (e, FallbackReason::Saml)
1007 }
1008 Err(e) if e.is_gh_bad_credentials() && self.is_authenticated => {
1009 log::debug!("GitHub API bad credentials, falling back to anonymous client");
1011 (e, FallbackReason::BadCredentials)
1012 }
1013 Err(e) => {
1014 log::debug!("GitHub API error: {e:?}");
1016 return Err(e);
1017 }
1018 };
1019
1020 let Some(backup) = self.backup_client.as_ref() else {
1022 return Err(WtgError::GhConnectionLost);
1024 };
1025
1026 match Self::await_with_timeout_and_error(api_call(backup)).await {
1028 Ok(result) => Ok(ApiCallResult {
1029 value: result,
1030 client: backup,
1031 selection: ClientSelection::Fallback(fallback_reason),
1032 }),
1033 Err(e) if e.is_gh_rate_limit() => {
1034 log::debug!("GitHub API rate limit hit on backup client: {e:?}");
1035 self.emit(Notice::GhRateLimitHit {
1038 authenticated: false,
1039 });
1040 Err(e)
1041 }
1042 Err(e) if e.is_gh_saml() => Err(main_error), Err(e) => {
1044 log::debug!("GitHub API error on backup client: {e:?}");
1045 self.emit(Notice::GhAnonymousFallbackFailed {
1046 error: format!("{e}"),
1047 });
1048 Err(e)
1049 }
1050 }
1051 }
1052
1053 async fn await_with_timeout_and_error<F, T>(future: F) -> WtgResult<T>
1055 where
1056 F: Future<Output = OctoResult<T>>,
1057 {
1058 match tokio::time::timeout(Self::request_timeout(), future).await {
1059 Ok(Ok(value)) => Ok(value),
1060 Ok(Err(e)) => Err(e.into()),
1061 Err(_) => Err(WtgError::Timeout),
1062 }
1063 }
1064}