wtg_cli/
github.rs

1use std::{env, fs, future::Future, pin::Pin, sync::LazyLock, 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::{WtgError, WtgResult};
14use crate::git::{CommitInfo, TagInfo, parse_semver};
15use crate::parse_input::parse_github_repo_url;
16
17impl From<RepoCommit> for CommitInfo {
18    fn from(commit: RepoCommit) -> Self {
19        let message = commit.commit.message;
20        let message_lines = message.lines().count();
21
22        let author_name = commit
23            .commit
24            .author
25            .as_ref()
26            .map_or_else(|| "Unknown".to_string(), |a| a.name.clone());
27
28        let author_email = commit.commit.author.as_ref().and_then(|a| a.email.clone());
29
30        let commit_url = commit.html_url;
31
32        let (author_login, author_url) = commit
33            .author
34            .map(|author| (Some(author.login), Some(author.html_url.into())))
35            .unwrap_or_default();
36
37        let date = commit
38            .commit
39            .author
40            .as_ref()
41            .and_then(|a| a.date.as_ref())
42            .copied()
43            .unwrap_or_else(Utc::now);
44
45        let full_hash = commit.sha;
46
47        Self {
48            hash: full_hash.clone(),
49            short_hash: full_hash[..7.min(full_hash.len())].to_string(),
50            message: message.lines().next().unwrap_or("").to_string(),
51            message_lines,
52            commit_url: Some(commit_url),
53            author_name,
54            author_email,
55            author_login,
56            author_url,
57            date,
58        }
59    }
60}
61
62const CONNECT_TIMEOUT_SECS: u64 = 5;
63const READ_TIMEOUT_SECS: u64 = 30;
64const REQUEST_TIMEOUT_SECS: u64 = 5;
65
66#[derive(Debug, Deserialize)]
67struct GhConfig {
68    #[serde(rename = "github.com")]
69    github_com: GhHostConfig,
70}
71
72#[derive(Debug, Deserialize)]
73struct GhHostConfig {
74    oauth_token: Option<String>,
75}
76
77#[derive(Debug, Clone)]
78pub struct GhRepoInfo {
79    owner: String,
80    repo: String,
81}
82
83impl GhRepoInfo {
84    #[must_use]
85    pub const fn new(owner: String, repo: String) -> Self {
86        Self { owner, repo }
87    }
88
89    #[must_use]
90    pub fn owner(&self) -> &str {
91        &self.owner
92    }
93
94    #[must_use]
95    pub fn repo(&self) -> &str {
96        &self.repo
97    }
98}
99
100/// GitHub API client wrapper.
101///
102/// - Provides a simplified interface for common GitHub operations used in wtg over direct octocrab usage.
103/// - Handles authentication via `GITHUB_TOKEN` env var or gh CLI config.
104/// - Supports fallback to anonymous requests on SAML errors via backup client.
105/// - Converts known octocrab errors into `WtgError` variants.
106/// - Returns `None` from `new()` if no client can be created.
107#[derive(Debug)]
108pub struct GitHubClient {
109    main_client: Octocrab,
110    /// Backup client for SAML fallback. Only populated when `main_client` is authenticated.
111    /// When `main_client` is anonymous, there's no point in falling back to another anonymous client.
112    backup_client: LazyLock<Option<Octocrab>>,
113    /// Whether `main_client` is authenticated (vs anonymous).
114    is_authenticated: bool,
115}
116
117/// Information about a Pull Request
118#[derive(Debug, Clone)]
119pub struct PullRequestInfo {
120    pub number: u64,
121    pub repo_info: Option<GhRepoInfo>,
122    pub title: String,
123    pub body: Option<String>,
124    pub state: String,
125    pub url: String,
126    pub merged: bool,
127    pub merge_commit_sha: Option<String>,
128    pub author: Option<String>,
129    pub author_url: Option<String>,
130    pub created_at: Option<DateTime<Utc>>, // When the PR was created
131}
132
133impl From<octocrab::models::pulls::PullRequest> for PullRequestInfo {
134    fn from(pr: octocrab::models::pulls::PullRequest) -> Self {
135        let author = pr.user.as_ref().map(|u| u.login.clone());
136        let author_url = pr.user.as_ref().map(|u| u.html_url.to_string());
137        let created_at = pr.created_at;
138
139        Self {
140            number: pr.number,
141            repo_info: parse_github_repo_url(pr.url.as_str()),
142            title: pr.title.unwrap_or_default(),
143            body: pr.body,
144            state: format!("{:?}", pr.state),
145            url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
146            merged: pr.merged.unwrap_or(false),
147            merge_commit_sha: pr.merge_commit_sha,
148            author,
149            author_url,
150            created_at,
151        }
152    }
153}
154
155/// Information about an Issue
156#[derive(Debug, Clone)]
157pub struct ExtendedIssueInfo {
158    pub number: u64,
159    pub title: String,
160    pub body: Option<String>,
161    pub state: octocrab::models::IssueState,
162    pub url: String,
163    pub author: Option<String>,
164    pub author_url: Option<String>,
165    pub closing_prs: Vec<PullRequestInfo>, // PRs that closed this issue (may be cross-repo)
166    pub created_at: Option<DateTime<Utc>>, // When the issue was created
167}
168
169impl TryFrom<octocrab::models::issues::Issue> for ExtendedIssueInfo {
170    type Error = ();
171
172    fn try_from(issue: octocrab::models::issues::Issue) -> Result<Self, Self::Error> {
173        // If it has a pull_request field, it's actually a PR - reject it
174        if issue.pull_request.is_some() {
175            return Err(());
176        }
177
178        let author = issue.user.login.clone();
179        let author_url = Some(issue.user.html_url.to_string());
180        let created_at = Some(issue.created_at);
181
182        Ok(Self {
183            number: issue.number,
184            title: issue.title,
185            body: issue.body,
186            state: issue.state,
187            url: issue.html_url.to_string(),
188            author: Some(author),
189            author_url,
190            closing_prs: Vec::new(), // Will be populated by caller if needed
191            created_at,
192        })
193    }
194}
195
196#[derive(Debug, Clone)]
197pub struct ReleaseInfo {
198    pub tag_name: String,
199    pub name: Option<String>,
200    pub url: String,
201    pub published_at: Option<DateTime<Utc>>,
202    pub created_at: Option<DateTime<Utc>>,
203    pub prerelease: bool,
204}
205
206impl GitHubClient {
207    /// Create a new GitHub client.
208    ///
209    /// Returns `None` if no client (neither authenticated nor anonymous) can be created.
210    /// If authentication succeeds, an anonymous backup client is created for SAML fallback.
211    /// If authentication fails, the anonymous client becomes the main client with no backup.
212    #[must_use]
213    pub fn new() -> Option<Self> {
214        // Try authenticated client first
215        if let Some(auth) = Self::build_auth_client() {
216            // Auth succeeded - create anonymous as lazy backup for SAML fallback
217            return Some(Self {
218                main_client: auth,
219                backup_client: LazyLock::new(Self::build_anonymous_client),
220                is_authenticated: true,
221            });
222        }
223
224        // Auth failed - try anonymous as main
225        // No backup needed: falling back to anonymous when already anonymous is pointless
226        let anonymous = Self::build_anonymous_client()?;
227        Some(Self {
228            main_client: anonymous,
229            backup_client: LazyLock::new(|| None),
230            is_authenticated: false,
231        })
232    }
233
234    /// Build an authenticated octocrab client
235    fn build_auth_client() -> Option<Octocrab> {
236        // Set reasonable timeouts: 5s connect, 30s read/write
237        let connect_timeout = Some(Self::connect_timeout());
238        let read_timeout = Some(Self::read_timeout());
239
240        // Try GITHUB_TOKEN env var first
241        if let Ok(token) = env::var("GITHUB_TOKEN") {
242            return OctocrabBuilder::new()
243                .personal_token(token)
244                .set_connect_timeout(connect_timeout)
245                .set_read_timeout(read_timeout)
246                .build()
247                .ok();
248        }
249
250        // Try reading from gh CLI config
251        if let Some(token) = Self::read_gh_config() {
252            return OctocrabBuilder::new()
253                .personal_token(token)
254                .set_connect_timeout(connect_timeout)
255                .set_read_timeout(read_timeout)
256                .build()
257                .ok();
258        }
259
260        None
261    }
262
263    /// Build an anonymous octocrab client (no authentication)
264    fn build_anonymous_client() -> Option<Octocrab> {
265        let connect_timeout = Some(Self::connect_timeout());
266        let read_timeout = Some(Self::read_timeout());
267
268        OctocrabBuilder::new()
269            .set_connect_timeout(connect_timeout)
270            .set_read_timeout(read_timeout)
271            .build()
272            .ok()
273    }
274
275    /// Read GitHub token from gh CLI config (cross-platform)
276    fn read_gh_config() -> Option<String> {
277        // gh CLI follows XDG conventions and stores config in:
278        // - Unix/macOS: ~/.config/gh/hosts.yml
279        // - Windows: %APPDATA%/gh/hosts.yml (but dirs crate handles this)
280
281        // Try XDG-style path first (~/.config/gh/hosts.yml)
282        if let Some(home) = dirs::home_dir() {
283            let xdg_path = home.join(".config").join("gh").join("hosts.yml");
284            if let Ok(content) = fs::read_to_string(&xdg_path)
285                && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
286                && let Some(token) = config.github_com.oauth_token
287            {
288                return Some(token);
289            }
290        }
291
292        // Fall back to platform-specific config dir
293        // (~/Library/Application Support/gh/hosts.yml on macOS)
294        if let Some(mut config_path) = dirs::config_dir() {
295            config_path.push("gh");
296            config_path.push("hosts.yml");
297
298            if let Ok(content) = fs::read_to_string(&config_path)
299                && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
300            {
301                return config.github_com.oauth_token;
302            }
303        }
304
305        None
306    }
307
308    /// Fetch full commit information from a specific repository
309    /// Returns None if the commit doesn't exist on GitHub or client errors
310    pub async fn fetch_commit_full_info(
311        &self,
312        repo_info: &GhRepoInfo,
313        commit_hash: &str,
314    ) -> Option<CommitInfo> {
315        let commit = self
316            .call_client_api_with_fallback(move |client| {
317                let hash = commit_hash.to_string();
318                let repo_info = repo_info.clone();
319                Box::pin(async move {
320                    client
321                        .commits(repo_info.owner(), repo_info.repo())
322                        .get(&hash)
323                        .await
324                })
325            })
326            .await
327            .ok()?;
328
329        Some(commit.into())
330    }
331
332    /// Try to fetch a PR
333    pub async fn fetch_pr(&self, repo_info: &GhRepoInfo, number: u64) -> Option<PullRequestInfo> {
334        let pr = self
335            .call_client_api_with_fallback(move |client| {
336                let repo_info = repo_info.clone();
337                Box::pin(async move {
338                    client
339                        .pulls(repo_info.owner(), repo_info.repo())
340                        .get(number)
341                        .await
342                })
343            })
344            .await
345            .ok()?;
346
347        Some(pr.into())
348    }
349
350    /// Try to fetch an issue
351    pub async fn fetch_issue(
352        &self,
353        repo_info: &GhRepoInfo,
354        number: u64,
355    ) -> Option<ExtendedIssueInfo> {
356        let issue = self
357            .call_client_api_with_fallback(move |client| {
358                let repo_info = repo_info.clone();
359                Box::pin(async move {
360                    client
361                        .issues(repo_info.owner(), repo_info.repo())
362                        .get(number)
363                        .await
364                })
365            })
366            .await
367            .ok()?;
368
369        let mut issue_info = ExtendedIssueInfo::try_from(issue).ok()?;
370
371        // Only fetch timeline for closed issues (open issues can't have closing PRs)
372        if matches!(issue_info.state, octocrab::models::IssueState::Closed) {
373            issue_info.closing_prs = self.find_closing_prs(repo_info, issue_info.number).await;
374        }
375
376        Some(issue_info)
377    }
378
379    /// Find closing PRs for an issue by examining timeline events
380    /// Returns list of PR references (may be from different repositories)
381    /// Priority:
382    /// 1. Closed events with `commit_id` (clearly indicate the PR/commit that closed the issue)
383    /// 2. CrossReferenced/Referenced events (fallback, but only merged PRs)
384    async fn find_closing_prs(
385        &self,
386        repo_info: &GhRepoInfo,
387        issue_number: u64,
388    ) -> Vec<PullRequestInfo> {
389        let mut closing_prs = Vec::new();
390
391        // Try to get first page with auth client, fallback to anonymous
392        let Ok((mut current_page, client)) = self
393            .call_api_and_get_client(move |client| {
394                let repo_info = repo_info.clone();
395                Box::pin(async move {
396                    client
397                        .issues(repo_info.owner(), repo_info.repo())
398                        .list_timeline_events(issue_number)
399                        .per_page(100)
400                        .send()
401                        .await
402                })
403            })
404            .await
405        else {
406            return Vec::new();
407        };
408
409        // Collect all timeline events to get closing commits and referenced PRs
410        loop {
411            for event in &current_page.items {
412                // Collect candidate PRs from cross-references
413                if let Some(source) = event.source.as_ref() {
414                    let issue = &source.issue;
415                    if issue.pull_request.is_some() {
416                        // Extract repository info from repository_url using existing parser
417                        if let Some(repo_info) =
418                            parse_github_repo_url(issue.repository_url.as_str())
419                        {
420                            let Some(pr_info) =
421                                Box::pin(self.fetch_pr(&repo_info, issue.number)).await
422                            else {
423                                continue; // Skip if PR fetch failed
424                            };
425
426                            if !pr_info.merged {
427                                continue; // Only consider merged PRs
428                            }
429
430                            if matches!(event.event, TimelineEventType::Closed) {
431                                // If it's a Closed event, assume this is the closing PR
432                                closing_prs.push(pr_info);
433                                break; // No need to check further events
434                            }
435
436                            // Otherwise, only consider CrossReferenced/Referenced events
437                            if !matches!(
438                                event.event,
439                                TimelineEventType::CrossReferenced | TimelineEventType::Referenced
440                            ) {
441                                continue;
442                            }
443
444                            // Check if we already have this PR
445                            // Note: GitHub API returns PRs as issues, so issue.number is the PR number
446                            if !closing_prs.iter().any(|p| {
447                                p.number == issue.number
448                                    && p.repo_info
449                                        .as_ref()
450                                        .is_some_and(|ri| ri.owner() == repo_info.owner())
451                                    && p.repo_info
452                                        .as_ref()
453                                        .is_some_and(|ri| ri.repo() == repo_info.repo())
454                            }) {
455                                closing_prs.push(pr_info);
456                            }
457                        }
458                    }
459                }
460            }
461
462            match Self::await_with_timeout_and_error(
463                client.get_page::<TimelineEvent>(&current_page.next),
464            )
465            .await
466            .ok()
467            .flatten()
468            {
469                Some(next_page) => current_page = next_page,
470                None => break,
471            }
472        }
473
474        closing_prs
475    }
476
477    /// Fetch releases from GitHub, optionally filtered by date
478    /// If `since_date` is provided, stop fetching releases older than this date
479    /// This significantly speeds up lookups for recent PRs/issues
480    #[allow(clippy::too_many_lines)]
481    pub async fn fetch_releases_since(
482        &self,
483        repo_info: &GhRepoInfo,
484        since_date: DateTime<Utc>,
485    ) -> Vec<ReleaseInfo> {
486        let mut releases = Vec::new();
487        let mut page_num = 1u32;
488        let per_page = 100u8; // Max allowed by GitHub API
489
490        // Try to get first page with auth client, fallback to anonymous
491        let Ok((mut current_page, client)) = self
492            .call_api_and_get_client(move |client| {
493                let repo_info = repo_info.clone();
494                Box::pin(async move {
495                    client
496                        .repos(repo_info.owner(), repo_info.repo())
497                        .releases()
498                        .list()
499                        .per_page(per_page)
500                        .page(page_num)
501                        .send()
502                        .await
503                })
504            })
505            .await
506        else {
507            return releases;
508        };
509
510        'pagintaion: loop {
511            if current_page.items.is_empty() {
512                break; // No more pages
513            }
514
515            // Sort releases by created_at descending
516            current_page
517                .items
518                .sort_by(|a, b| b.created_at.cmp(&a.created_at));
519
520            for release in current_page.items {
521                // Check if this release is too old
522                let release_tag_created_at = release.created_at.unwrap_or_default();
523
524                if release_tag_created_at < since_date {
525                    break 'pagintaion; // Stop processing
526                }
527
528                releases.push(ReleaseInfo {
529                    tag_name: release.tag_name,
530                    name: release.name,
531                    url: release.html_url.to_string(),
532                    published_at: release.published_at,
533                    created_at: release.created_at,
534                    prerelease: release.prerelease,
535                });
536            }
537
538            if current_page.next.is_none() {
539                break; // No more pages
540            }
541
542            page_num += 1;
543
544            // Fetch next page
545            current_page = match Self::await_with_timeout_and_error(
546                client
547                    .repos(repo_info.owner(), repo_info.repo())
548                    .releases()
549                    .list()
550                    .per_page(per_page)
551                    .page(page_num)
552                    .send(),
553            )
554            .await
555            .ok()
556            {
557                Some(page) => page,
558                None => break, // Stop on error
559            };
560        }
561
562        releases
563    }
564
565    /// Fetch a GitHub release by tag.
566    pub async fn fetch_release_by_tag(
567        &self,
568        repo_info: &GhRepoInfo,
569        tag: &str,
570    ) -> Option<ReleaseInfo> {
571        let release = self
572            .call_client_api_with_fallback(move |client| {
573                let tag = tag.to_string();
574                let repo_info = repo_info.clone();
575                Box::pin(async move {
576                    client
577                        .repos(repo_info.owner(), repo_info.repo())
578                        .releases()
579                        .get_by_tag(tag.as_str())
580                        .await
581                })
582            })
583            .await
584            .ok()?;
585
586        Some(ReleaseInfo {
587            tag_name: release.tag_name,
588            name: release.name,
589            url: release.html_url.to_string(),
590            published_at: release.published_at,
591            created_at: release.created_at,
592            prerelease: release.prerelease,
593        })
594    }
595
596    /// Fetch tag info for a release by checking if target commit is contained in the tag.
597    /// Uses GitHub compare API to verify ancestry and get tag's commit hash.
598    /// Returns None if the tag doesn't contain the target commit.
599    pub async fn fetch_tag_info_for_release(
600        &self,
601        release: &ReleaseInfo,
602        repo_info: &GhRepoInfo,
603        target_commit: &str,
604    ) -> Option<TagInfo> {
605        // Use compare API with per_page=1 to optimize
606        let compare = self
607            .call_client_api_with_fallback(move |client| {
608                let tag_name = release.tag_name.clone();
609                let target_commit = target_commit.to_string();
610                let repo_info = repo_info.clone();
611                Box::pin(async move {
612                    client
613                        .commits(repo_info.owner(), repo_info.repo())
614                        .compare(&tag_name, &target_commit)
615                        .per_page(1)
616                        .send()
617                        .await
618                })
619            })
620            .await
621            .ok()?;
622
623        // If status is "behind" or "identical", the target commit is in the tag's history
624        // "ahead" or "diverged" means the commit is NOT in the tag
625        if !matches!(
626            compare.status,
627            GithubCommitStatus::Behind | GithubCommitStatus::Identical
628        ) {
629            return None;
630        }
631
632        let semver_info = parse_semver(&release.tag_name);
633
634        Some(TagInfo {
635            name: release.tag_name.clone(),
636            commit_hash: compare.base_commit.sha,
637            semver_info,
638            created_at: release.created_at?,
639            is_release: true,
640            release_name: release.name.clone(),
641            release_url: Some(release.url.clone()),
642            published_at: release.published_at,
643        })
644    }
645
646    /// Fetch tag info by name.
647    /// Uses the commits API (which accepts refs) to resolve the tag to a commit,
648    /// then optionally enriches with release info if available.
649    pub async fn fetch_tag(&self, repo_info: &GhRepoInfo, tag_name: &str) -> Option<TagInfo> {
650        // Use commits API with tag name as ref to get the commit
651        let commit = self.fetch_commit_full_info(repo_info, tag_name).await?;
652
653        // Try to get release info (may not exist if tag has no release)
654        let release = self.fetch_release_by_tag(repo_info, tag_name).await;
655
656        let semver_info = parse_semver(tag_name);
657
658        Some(TagInfo {
659            name: tag_name.to_string(),
660            commit_hash: commit.hash,
661            semver_info,
662            created_at: commit.date,
663            is_release: release.is_some(),
664            release_name: release.as_ref().and_then(|r| r.name.clone()),
665            release_url: release.as_ref().map(|r| r.url.clone()),
666            published_at: release.and_then(|r| r.published_at),
667        })
668    }
669
670    /// Build GitHub URLs for various things
671    /// Build a commit URL (fallback when API data unavailable)
672    /// Uses URL encoding to prevent injection
673    #[must_use]
674    pub fn commit_url(repo_info: &GhRepoInfo, hash: &str) -> String {
675        use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
676        format!(
677            "https://github.com/{}/{}/commit/{}",
678            utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
679            utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
680            utf8_percent_encode(hash, NON_ALPHANUMERIC)
681        )
682    }
683
684    /// Build a tag URL (fallback when API data unavailable)
685    /// Uses URL encoding to prevent injection
686    #[must_use]
687    pub fn tag_url(repo_info: &GhRepoInfo, tag: &str) -> String {
688        use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
689        format!(
690            "https://github.com/{}/{}/tree/{}",
691            utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
692            utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
693            utf8_percent_encode(tag, NON_ALPHANUMERIC)
694        )
695    }
696
697    /// Build a profile URL (fallback when API data unavailable)
698    /// Uses URL encoding to prevent injection
699    #[must_use]
700    pub fn profile_url(username: &str) -> String {
701        use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
702        format!(
703            "https://github.com/{}",
704            utf8_percent_encode(username, NON_ALPHANUMERIC)
705        )
706    }
707
708    /// Build a profile URL from a GitHub noreply email address.
709    ///
710    /// Extracts username from patterns:
711    /// - `username@users.noreply.github.com`
712    /// - `id+username@users.noreply.github.com`
713    #[must_use]
714    pub fn author_url_from_email(email: &str) -> Option<String> {
715        if email.ends_with("@users.noreply.github.com") {
716            let parts: Vec<&str> = email.split('@').collect();
717            if let Some(user_part) = parts.first()
718                && let Some(username) = user_part.split('+').next_back()
719            {
720                return Some(Self::profile_url(username));
721            }
722        }
723        None
724    }
725
726    const fn connect_timeout() -> Duration {
727        Duration::from_secs(CONNECT_TIMEOUT_SECS)
728    }
729
730    const fn read_timeout() -> Duration {
731        Duration::from_secs(READ_TIMEOUT_SECS)
732    }
733
734    const fn request_timeout() -> Duration {
735        Duration::from_secs(REQUEST_TIMEOUT_SECS)
736    }
737
738    /// Call a GitHub API with fallback from authenticated to anonymous client.
739    async fn call_client_api_with_fallback<F, T>(&self, api_call: F) -> WtgResult<T>
740    where
741        for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
742    {
743        let (result, _client) = self.call_api_and_get_client(api_call).await?;
744        Ok(result)
745    }
746
747    /// Call a GitHub API with fallback to backup client on SAML errors.
748    /// Returns results & the client used, or error.
749    async fn call_api_and_get_client<F, T>(&self, api_call: F) -> WtgResult<(T, &Octocrab)>
750    where
751        for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
752    {
753        // Try with main client first
754        let main_error = match Self::await_with_timeout_and_error(api_call(&self.main_client)).await
755        {
756            Ok(result) => return Ok((result, &self.main_client)),
757            Err(e) if e.is_gh_saml() && self.is_authenticated => {
758                // SAML error with authenticated client - fall through to try backup
759                e
760            }
761            Err(e) => {
762                // Non-SAML error, or SAML with anonymous client (no fallback possible)
763                return Err(e);
764            }
765        };
766
767        // Try with backup client on SAML error (only reached if authenticated)
768        let Some(backup) = self.backup_client.as_ref() else {
769            // Backup client failed to build - connection was lost between auth and now
770            return Err(WtgError::GhConnectionLost);
771        };
772
773        // Try the backup, but if it also fails with SAML, return original error
774        match Self::await_with_timeout_and_error(api_call(backup)).await {
775            Ok(result) => Ok((result, backup)),
776            Err(e) if e.is_gh_saml() => Err(main_error), // Return original SAML error
777            Err(e) => Err(e),
778        }
779    }
780
781    /// Await with timeout, returning non-timeout error if any
782    async fn await_with_timeout_and_error<F, T>(future: F) -> WtgResult<T>
783    where
784        F: Future<Output = OctoResult<T>>,
785    {
786        match tokio::time::timeout(Self::request_timeout(), future).await {
787            Ok(Ok(value)) => Ok(value),
788            Ok(Err(e)) => Err(e.into()),
789            Err(_) => Err(WtgError::Timeout),
790        }
791    }
792}