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
101pub struct GitHubClient {
109 main_client: Octocrab,
110 backup_client: LazyLock<Option<Octocrab>>,
113 is_authenticated: bool,
115 notice_callback: OnceLock<NoticeCallback>,
118}
119
120#[derive(Debug, Clone)]
122pub struct PullRequestInfo {
123 pub number: u64,
124 pub repo_info: Option<GhRepoInfo>,
125 pub title: String,
126 pub body: Option<String>,
127 pub state: String,
128 pub url: String,
129 pub merged: bool,
130 pub merge_commit_sha: Option<String>,
131 pub author: Option<String>,
132 pub author_url: Option<String>,
133 pub created_at: Option<DateTime<Utc>>, }
135
136impl From<octocrab::models::pulls::PullRequest> for PullRequestInfo {
137 fn from(pr: octocrab::models::pulls::PullRequest) -> Self {
138 let author = pr.user.as_ref().map(|u| u.login.clone());
139 let author_url = pr.user.as_ref().map(|u| u.html_url.to_string());
140 let created_at = pr.created_at;
141
142 Self {
143 number: pr.number,
144 repo_info: parse_github_repo_url(pr.url.as_str()),
145 title: pr.title.unwrap_or_default(),
146 body: pr.body,
147 state: format!("{:?}", pr.state),
148 url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
149 merged: pr.merged.unwrap_or(false),
150 merge_commit_sha: pr.merge_commit_sha,
151 author,
152 author_url,
153 created_at,
154 }
155 }
156}
157
158#[derive(Debug, Clone)]
160pub struct ExtendedIssueInfo {
161 pub number: u64,
162 pub title: String,
163 pub body: Option<String>,
164 pub state: octocrab::models::IssueState,
165 pub url: String,
166 pub author: Option<String>,
167 pub author_url: Option<String>,
168 pub closing_prs: Vec<PullRequestInfo>, pub created_at: Option<DateTime<Utc>>, }
171
172impl TryFrom<octocrab::models::issues::Issue> for ExtendedIssueInfo {
173 type Error = ();
174
175 fn try_from(issue: octocrab::models::issues::Issue) -> Result<Self, Self::Error> {
176 if issue.pull_request.is_some() {
178 return Err(());
179 }
180
181 let author = issue.user.login.clone();
182 let author_url = Some(issue.user.html_url.to_string());
183 let created_at = Some(issue.created_at);
184
185 Ok(Self {
186 number: issue.number,
187 title: issue.title,
188 body: issue.body,
189 state: issue.state,
190 url: issue.html_url.to_string(),
191 author: Some(author),
192 author_url,
193 closing_prs: Vec::new(), created_at,
195 })
196 }
197}
198
199#[derive(Debug, Clone)]
200pub struct ReleaseInfo {
201 pub tag_name: String,
202 pub name: Option<String>,
203 pub body: Option<String>,
204 pub url: String,
205 pub published_at: Option<DateTime<Utc>>,
206 pub created_at: Option<DateTime<Utc>>,
207 pub prerelease: bool,
208}
209
210impl GitHubClient {
211 #[must_use]
217 pub fn new() -> Option<Self> {
218 if let Some(auth) = Self::build_auth_client() {
220 return Some(Self {
222 main_client: auth,
223 backup_client: LazyLock::new(Self::build_anonymous_client),
224 is_authenticated: true,
225 notice_callback: OnceLock::new(),
226 });
227 }
228
229 let anonymous = Self::build_anonymous_client()?;
232 Some(Self {
233 main_client: anonymous,
234 backup_client: LazyLock::new(|| None),
235 is_authenticated: false,
236 notice_callback: OnceLock::new(),
237 })
238 }
239
240 pub fn set_notice_callback(&self, callback: NoticeCallback) {
244 let _ = self.notice_callback.set(callback);
246 }
247
248 fn emit(&self, notice: Notice) {
250 if let Some(cb) = self.notice_callback.get() {
251 (cb)(notice);
252 }
253 }
254
255 fn build_auth_client() -> Option<Octocrab> {
257 let connect_timeout = Some(Self::connect_timeout());
259 let read_timeout = Some(Self::read_timeout());
260
261 if let Ok(token) = env::var("GITHUB_TOKEN") {
263 return OctocrabBuilder::new()
264 .personal_token(token)
265 .set_connect_timeout(connect_timeout)
266 .set_read_timeout(read_timeout)
267 .build()
268 .ok();
269 }
270
271 if let Some(token) = Self::read_gh_config() {
273 return OctocrabBuilder::new()
274 .personal_token(token)
275 .set_connect_timeout(connect_timeout)
276 .set_read_timeout(read_timeout)
277 .build()
278 .ok();
279 }
280
281 None
282 }
283
284 fn build_anonymous_client() -> Option<Octocrab> {
286 let connect_timeout = Some(Self::connect_timeout());
287 let read_timeout = Some(Self::read_timeout());
288
289 OctocrabBuilder::new()
290 .set_connect_timeout(connect_timeout)
291 .set_read_timeout(read_timeout)
292 .build()
293 .ok()
294 }
295
296 fn read_gh_config() -> Option<String> {
298 if let Some(home) = dirs::home_dir() {
304 let xdg_path = home.join(".config").join("gh").join("hosts.yml");
305 if let Ok(content) = fs::read_to_string(&xdg_path)
306 && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
307 && let Some(token) = config.github_com.oauth_token
308 {
309 return Some(token);
310 }
311 }
312
313 if let Some(mut config_path) = dirs::config_dir() {
316 config_path.push("gh");
317 config_path.push("hosts.yml");
318
319 if let Ok(content) = fs::read_to_string(&config_path)
320 && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
321 {
322 return config.github_com.oauth_token;
323 }
324 }
325
326 None
327 }
328
329 pub async fn fetch_commit_full_info(
332 &self,
333 repo_info: &GhRepoInfo,
334 commit_hash: &str,
335 ) -> Option<CommitInfo> {
336 let commit = self
337 .call_client_api_with_fallback(move |client| {
338 let hash = commit_hash.to_string();
339 let repo_info = repo_info.clone();
340 Box::pin(async move {
341 client
342 .commits(repo_info.owner(), repo_info.repo())
343 .get(&hash)
344 .await
345 })
346 })
347 .await
348 .log_err(&format!(
349 "fetch_commit_full_info failed for {}/{} commit {}",
350 repo_info.owner(),
351 repo_info.repo(),
352 commit_hash
353 ))?;
354
355 Some(commit.into())
356 }
357
358 pub async fn fetch_pr(&self, repo_info: &GhRepoInfo, number: u64) -> Option<PullRequestInfo> {
360 let pr = self
361 .call_client_api_with_fallback(move |client| {
362 let repo_info = repo_info.clone();
363 Box::pin(async move {
364 client
365 .pulls(repo_info.owner(), repo_info.repo())
366 .get(number)
367 .await
368 })
369 })
370 .await
371 .log_err(&format!(
372 "fetch_pr failed for {}/{} PR #{}",
373 repo_info.owner(),
374 repo_info.repo(),
375 number
376 ))?;
377
378 Some(pr.into())
379 }
380
381 pub async fn fetch_issue(
383 &self,
384 repo_info: &GhRepoInfo,
385 number: u64,
386 ) -> Option<ExtendedIssueInfo> {
387 let issue = self
388 .call_client_api_with_fallback(move |client| {
389 let repo_info = repo_info.clone();
390 Box::pin(async move {
391 client
392 .issues(repo_info.owner(), repo_info.repo())
393 .get(number)
394 .await
395 })
396 })
397 .await
398 .log_err(&format!(
399 "fetch_issue failed for {}/{} issue #{}",
400 repo_info.owner(),
401 repo_info.repo(),
402 number
403 ))?;
404
405 let mut issue_info = ExtendedIssueInfo::try_from(issue).ok()?;
406
407 if matches!(issue_info.state, octocrab::models::IssueState::Closed) {
409 issue_info.closing_prs = self.find_closing_prs(repo_info, issue_info.number).await;
410 }
411
412 Some(issue_info)
413 }
414
415 async fn find_closing_prs(
421 &self,
422 repo_info: &GhRepoInfo,
423 issue_number: u64,
424 ) -> Vec<PullRequestInfo> {
425 let mut closing_prs = Vec::new();
426
427 let Ok((mut current_page, client)) = self
429 .call_api_and_get_client(move |client| {
430 let repo_info = repo_info.clone();
431 Box::pin(async move {
432 client
433 .issues(repo_info.owner(), repo_info.repo())
434 .list_timeline_events(issue_number)
435 .per_page(100)
436 .send()
437 .await
438 })
439 })
440 .await
441 else {
442 return Vec::new();
443 };
444
445 loop {
447 for event in ¤t_page.items {
448 if let Some(source) = event.source.as_ref() {
450 let issue = &source.issue;
451 if issue.pull_request.is_some() {
452 if let Some(repo_info) =
454 parse_github_repo_url(issue.repository_url.as_str())
455 {
456 let Some(pr_info) =
457 Box::pin(self.fetch_pr(&repo_info, issue.number)).await
458 else {
459 continue; };
461
462 if !pr_info.merged {
463 continue; }
465
466 if matches!(event.event, TimelineEventType::Closed) {
467 closing_prs.push(pr_info);
469 break; }
471
472 if !matches!(
474 event.event,
475 TimelineEventType::CrossReferenced | TimelineEventType::Referenced
476 ) {
477 continue;
478 }
479
480 if !closing_prs.iter().any(|p| {
483 p.number == issue.number
484 && p.repo_info
485 .as_ref()
486 .is_some_and(|ri| ri.owner() == repo_info.owner())
487 && p.repo_info
488 .as_ref()
489 .is_some_and(|ri| ri.repo() == repo_info.repo())
490 }) {
491 closing_prs.push(pr_info);
492 }
493 }
494 }
495 }
496 }
497
498 match Self::await_with_timeout_and_error(
499 client.get_page::<TimelineEvent>(¤t_page.next),
500 )
501 .await
502 .ok()
503 .flatten()
504 {
505 Some(next_page) => current_page = next_page,
506 None => break,
507 }
508 }
509
510 closing_prs
511 }
512
513 #[allow(clippy::too_many_lines)]
517 pub async fn fetch_releases_since(
518 &self,
519 repo_info: &GhRepoInfo,
520 since_date: DateTime<Utc>,
521 ) -> Vec<ReleaseInfo> {
522 let mut releases = Vec::new();
523 let mut page_num = 1u32;
524 let per_page = 100u8; let Ok((mut current_page, client)) = self
528 .call_api_and_get_client(move |client| {
529 let repo_info = repo_info.clone();
530 Box::pin(async move {
531 client
532 .repos(repo_info.owner(), repo_info.repo())
533 .releases()
534 .list()
535 .per_page(per_page)
536 .page(page_num)
537 .send()
538 .await
539 })
540 })
541 .await
542 else {
543 return releases;
544 };
545
546 'pagintaion: loop {
547 if current_page.items.is_empty() {
548 break; }
550
551 current_page
553 .items
554 .sort_by(|a, b| b.created_at.cmp(&a.created_at));
555
556 for release in current_page.items {
557 let release_tag_created_at = release.created_at.unwrap_or_default();
559
560 if release_tag_created_at < since_date {
561 break 'pagintaion; }
563
564 releases.push(ReleaseInfo {
565 tag_name: release.tag_name,
566 name: release.name,
567 body: release.body,
568 url: release.html_url.to_string(),
569 published_at: release.published_at,
570 created_at: release.created_at,
571 prerelease: release.prerelease,
572 });
573 }
574
575 if current_page.next.is_none() {
576 break; }
578
579 page_num += 1;
580
581 current_page = match Self::await_with_timeout_and_error(
583 client
584 .repos(repo_info.owner(), repo_info.repo())
585 .releases()
586 .list()
587 .per_page(per_page)
588 .page(page_num)
589 .send(),
590 )
591 .await
592 .ok()
593 {
594 Some(page) => page,
595 None => break, };
597 }
598
599 releases
600 }
601
602 pub async fn fetch_release_by_tag(
604 &self,
605 repo_info: &GhRepoInfo,
606 tag: &str,
607 ) -> Option<ReleaseInfo> {
608 let release = self
609 .call_client_api_with_fallback(move |client| {
610 let tag = tag.to_string();
611 let repo_info = repo_info.clone();
612 Box::pin(async move {
613 client
614 .repos(repo_info.owner(), repo_info.repo())
615 .releases()
616 .get_by_tag(tag.as_str())
617 .await
618 })
619 })
620 .await
621 .log_err(&format!(
622 "fetch_release_by_tag failed for {}/{} tag {}",
623 repo_info.owner(),
624 repo_info.repo(),
625 tag
626 ))?;
627
628 Some(ReleaseInfo {
629 tag_name: release.tag_name,
630 name: release.name,
631 body: release.body,
632 url: release.html_url.to_string(),
633 published_at: release.published_at,
634 created_at: release.created_at,
635 prerelease: release.prerelease,
636 })
637 }
638
639 pub async fn fetch_tag_info_for_release(
643 &self,
644 release: &ReleaseInfo,
645 repo_info: &GhRepoInfo,
646 target_commit: &str,
647 ) -> Option<TagInfo> {
648 let compare = self
650 .call_client_api_with_fallback(move |client| {
651 let tag_name = release.tag_name.clone();
652 let target_commit = target_commit.to_string();
653 let repo_info = repo_info.clone();
654 Box::pin(async move {
655 client
656 .commits(repo_info.owner(), repo_info.repo())
657 .compare(&tag_name, &target_commit)
658 .per_page(1)
659 .send()
660 .await
661 })
662 })
663 .await
664 .log_err(&format!(
665 "fetch_tag_info_for_release failed for {}/{} tag {} vs commit {}",
666 repo_info.owner(),
667 repo_info.repo(),
668 release.tag_name,
669 target_commit
670 ))?;
671
672 if !matches!(
675 compare.status,
676 GithubCommitStatus::Behind | GithubCommitStatus::Identical
677 ) {
678 return None;
679 }
680
681 let semver_info = parse_semver(&release.tag_name);
682
683 Some(TagInfo {
684 name: release.tag_name.clone(),
685 commit_hash: compare.base_commit.sha,
686 semver_info,
687 created_at: release.created_at?,
688 is_release: true,
689 release_name: release.name.clone(),
690 release_url: Some(release.url.clone()),
691 published_at: release.published_at,
692 tag_url: Some(release.url.clone()),
693 })
694 }
695
696 pub async fn tag_contains_commit(
700 &self,
701 repo_info: &GhRepoInfo,
702 tag: &str,
703 commit: &str,
704 ) -> bool {
705 let compare = self
706 .call_client_api_with_fallback(move |client| {
707 let tag = tag.to_string();
708 let commit = commit.to_string();
709 let repo_info = repo_info.clone();
710 Box::pin(async move {
711 client
712 .commits(repo_info.owner(), repo_info.repo())
713 .compare(&tag, &commit)
714 .per_page(1)
715 .send()
716 .await
717 })
718 })
719 .await
720 .ok();
721
722 matches!(
723 compare.map(|c| c.status),
724 Some(GithubCommitStatus::Behind | GithubCommitStatus::Identical)
725 )
726 }
727
728 pub async fn fetch_tag(&self, repo_info: &GhRepoInfo, tag_name: &str) -> Option<TagInfo> {
732 let commit = self.fetch_commit_full_info(repo_info, tag_name).await?;
734
735 let release = self.fetch_release_by_tag(repo_info, tag_name).await;
737
738 let semver_info = parse_semver(tag_name);
739
740 let tag_url = Some(
742 release
743 .as_ref()
744 .map_or_else(|| Self::tag_url(repo_info, tag_name), |r| r.url.clone()),
745 );
746
747 Some(TagInfo {
748 name: tag_name.to_string(),
749 commit_hash: commit.hash,
750 semver_info,
751 created_at: commit.date,
752 is_release: release.is_some(),
753 release_name: release.as_ref().and_then(|r| r.name.clone()),
754 release_url: release.as_ref().map(|r| r.url.clone()),
755 published_at: release.and_then(|r| r.published_at),
756 tag_url,
757 })
758 }
759
760 pub async fn fetch_file_content(&self, repo_info: &GhRepoInfo, path: &str) -> Option<String> {
765 use base64::Engine;
766 use base64::engine::general_purpose::STANDARD;
767
768 let content = self
769 .call_client_api_with_fallback(move |client| {
770 let path = path.to_string();
771 let repo_info = repo_info.clone();
772 Box::pin(async move {
773 client
774 .repos(repo_info.owner(), repo_info.repo())
775 .get_content()
776 .path(&path)
777 .send()
778 .await
779 })
780 })
781 .await
782 .ok()?;
783
784 let file_content = match content.items.into_iter().next()? {
786 octocrab::models::repos::Content {
787 content: Some(encoded),
788 ..
789 } => {
790 let cleaned: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
792 STANDARD.decode(&cleaned).ok()?
793 }
794 _ => return None, };
796
797 String::from_utf8(file_content).ok()
798 }
799
800 #[must_use]
804 pub fn commit_url(repo_info: &GhRepoInfo, hash: &str) -> String {
805 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
806 format!(
807 "https://github.com/{}/{}/commit/{}",
808 utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
809 utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
810 utf8_percent_encode(hash, NON_ALPHANUMERIC)
811 )
812 }
813
814 #[must_use]
817 pub fn tag_url(repo_info: &GhRepoInfo, tag: &str) -> String {
818 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
819 format!(
820 "https://github.com/{}/{}/tree/{}",
821 utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
822 utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
823 utf8_percent_encode(tag, NON_ALPHANUMERIC)
824 )
825 }
826
827 #[must_use]
830 pub fn release_tag_url(repo_info: &GhRepoInfo, tag: &str) -> String {
831 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
832 format!(
833 "https://github.com/{}/{}/releases/tag/{}",
834 utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
835 utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
836 utf8_percent_encode(tag, NON_ALPHANUMERIC)
837 )
838 }
839
840 #[must_use]
843 pub fn profile_url(username: &str) -> String {
844 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
845 format!(
846 "https://github.com/{}",
847 utf8_percent_encode(username, NON_ALPHANUMERIC)
848 )
849 }
850
851 #[must_use]
857 pub fn author_url_from_email(email: &str) -> Option<String> {
858 if email.ends_with("@users.noreply.github.com") {
859 let parts: Vec<&str> = email.split('@').collect();
860 if let Some(user_part) = parts.first()
861 && let Some(username) = user_part.split('+').next_back()
862 {
863 return Some(Self::profile_url(username));
864 }
865 }
866 None
867 }
868
869 const fn connect_timeout() -> Duration {
870 Duration::from_secs(CONNECT_TIMEOUT_SECS)
871 }
872
873 const fn read_timeout() -> Duration {
874 Duration::from_secs(READ_TIMEOUT_SECS)
875 }
876
877 const fn request_timeout() -> Duration {
878 Duration::from_secs(REQUEST_TIMEOUT_SECS)
879 }
880
881 async fn call_client_api_with_fallback<F, T>(&self, api_call: F) -> WtgResult<T>
883 where
884 for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
885 {
886 let (result, _client) = self.call_api_and_get_client(api_call).await?;
887 Ok(result)
888 }
889
890 async fn call_api_and_get_client<F, T>(&self, api_call: F) -> WtgResult<(T, &Octocrab)>
894 where
895 for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
896 {
897 let main_error = match Self::await_with_timeout_and_error(api_call(&self.main_client)).await
899 {
900 Ok(result) => return Ok((result, &self.main_client)),
901 Err(e) if e.is_gh_rate_limit() => {
902 log::debug!(
903 "GitHub API rate limit hit (authenticated={}): {:?}",
904 self.is_authenticated,
905 e
906 );
907 self.emit(Notice::GhRateLimitHit {
908 authenticated: self.is_authenticated,
909 });
910 return Err(e);
911 }
912 Err(e) if e.is_gh_saml() && self.is_authenticated => {
913 e
915 }
916 Err(e) => {
917 log::debug!("GitHub API error: {e:?}");
919 return Err(e);
920 }
921 };
922
923 let Some(backup) = self.backup_client.as_ref() else {
925 return Err(WtgError::GhConnectionLost);
927 };
928
929 match Self::await_with_timeout_and_error(api_call(backup)).await {
931 Ok(result) => Ok((result, backup)),
932 Err(e) if e.is_gh_rate_limit() => {
933 log::debug!("GitHub API rate limit hit on backup client: {e:?}");
934 self.emit(Notice::GhRateLimitHit {
937 authenticated: false,
938 });
939 Err(e)
940 }
941 Err(e) if e.is_gh_saml() => Err(main_error), Err(e) => {
943 log::debug!("GitHub API error on backup client: {e:?}");
944 Err(e)
945 }
946 }
947 }
948
949 async fn await_with_timeout_and_error<F, T>(future: F) -> WtgResult<T>
951 where
952 F: Future<Output = OctoResult<T>>,
953 {
954 match tokio::time::timeout(Self::request_timeout(), future).await {
955 Ok(Ok(value)) => Ok(value),
956 Ok(Err(e)) => Err(e.into()),
957 Err(_) => Err(WtgError::Timeout),
958 }
959 }
960}