wtg_cli/
github.rs

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/// GitHub API client wrapper.
102///
103/// - Provides a simplified interface for common GitHub operations used in wtg over direct octocrab usage.
104/// - Handles authentication via `GITHUB_TOKEN` env var or gh CLI config.
105/// - Supports fallback to anonymous requests on SAML errors via backup client.
106/// - Converts known octocrab errors into `WtgError` variants.
107/// - Returns `None` from `new()` if no client can be created.
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    /// Callback for emitting notices (e.g., rate limit hit).
116    /// Uses `OnceLock` since callback is set at most once after construction.
117    notice_callback: OnceLock<NoticeCallback>,
118}
119
120/// Information about a Pull Request
121#[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>>, // When the PR was created
134}
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/// Information about an Issue
159#[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>, // PRs that closed this issue (may be cross-repo)
169    pub created_at: Option<DateTime<Utc>>, // When the issue was created
170}
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 it has a pull_request field, it's actually a PR - reject it
177        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(), // Will be populated by caller if needed
194            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    /// Create a new GitHub client.
212    ///
213    /// Returns `None` if no client (neither authenticated nor anonymous) can be created.
214    /// If authentication succeeds, an anonymous backup client is created for SAML fallback.
215    /// If authentication fails, the anonymous client becomes the main client with no backup.
216    #[must_use]
217    pub fn new() -> Option<Self> {
218        // Try authenticated client first
219        if let Some(auth) = Self::build_auth_client() {
220            // Auth succeeded - create anonymous as lazy backup for SAML fallback
221            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        // Auth failed - try anonymous as main
230        // No backup needed: falling back to anonymous when already anonymous is pointless
231        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    /// Set the notice callback for this client.
241    /// Can be called even when client is behind an `Arc`.
242    /// First call wins - subsequent calls are ignored.
243    pub fn set_notice_callback(&self, callback: NoticeCallback) {
244        // set() returns Err if already set - we ignore since first-set wins
245        let _ = self.notice_callback.set(callback);
246    }
247
248    /// Emit a notice via the callback, if one is set.
249    fn emit(&self, notice: Notice) {
250        if let Some(cb) = self.notice_callback.get() {
251            (cb)(notice);
252        }
253    }
254
255    /// Build an authenticated octocrab client
256    fn build_auth_client() -> Option<Octocrab> {
257        // Set reasonable timeouts: 5s connect, 30s read/write
258        let connect_timeout = Some(Self::connect_timeout());
259        let read_timeout = Some(Self::read_timeout());
260
261        // Try GITHUB_TOKEN env var first
262        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        // Try reading from gh CLI config
272        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    /// Build an anonymous octocrab client (no authentication)
285    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    /// Read GitHub token from gh CLI config (cross-platform)
297    fn read_gh_config() -> Option<String> {
298        // gh CLI follows XDG conventions and stores config in:
299        // - Unix/macOS: ~/.config/gh/hosts.yml
300        // - Windows: %APPDATA%/gh/hosts.yml (but dirs crate handles this)
301
302        // Try XDG-style path first (~/.config/gh/hosts.yml)
303        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        // Fall back to platform-specific config dir
314        // (~/Library/Application Support/gh/hosts.yml on macOS)
315        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    /// Fetch full commit information from a specific repository
330    /// Returns None if the commit doesn't exist on GitHub or client errors
331    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    /// Try to fetch a PR
359    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    /// Try to fetch an issue
382    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        // Only fetch timeline for closed issues (open issues can't have closing PRs)
408        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    /// Find closing PRs for an issue by examining timeline events
416    /// Returns list of PR references (may be from different repositories)
417    /// Priority:
418    /// 1. Closed events with `commit_id` (clearly indicate the PR/commit that closed the issue)
419    /// 2. CrossReferenced/Referenced events (fallback, but only merged PRs)
420    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        // Try to get first page with auth client, fallback to anonymous
428        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        // Collect all timeline events to get closing commits and referenced PRs
446        loop {
447            for event in &current_page.items {
448                // Collect candidate PRs from cross-references
449                if let Some(source) = event.source.as_ref() {
450                    let issue = &source.issue;
451                    if issue.pull_request.is_some() {
452                        // Extract repository info from repository_url using existing parser
453                        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; // Skip if PR fetch failed
460                            };
461
462                            if !pr_info.merged {
463                                continue; // Only consider merged PRs
464                            }
465
466                            if matches!(event.event, TimelineEventType::Closed) {
467                                // If it's a Closed event, assume this is the closing PR
468                                closing_prs.push(pr_info);
469                                break; // No need to check further events
470                            }
471
472                            // Otherwise, only consider CrossReferenced/Referenced events
473                            if !matches!(
474                                event.event,
475                                TimelineEventType::CrossReferenced | TimelineEventType::Referenced
476                            ) {
477                                continue;
478                            }
479
480                            // Check if we already have this PR
481                            // Note: GitHub API returns PRs as issues, so issue.number is the PR number
482                            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>(&current_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    /// Fetch releases from GitHub, optionally filtered by date
514    /// If `since_date` is provided, stop fetching releases older than this date
515    /// This significantly speeds up lookups for recent PRs/issues
516    #[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; // Max allowed by GitHub API
525
526        // Try to get first page with auth client, fallback to anonymous
527        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; // No more pages
549            }
550
551            // Sort releases by created_at descending
552            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                // Check if this release is too old
558                let release_tag_created_at = release.created_at.unwrap_or_default();
559
560                if release_tag_created_at < since_date {
561                    break 'pagintaion; // Stop processing
562                }
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; // No more pages
577            }
578
579            page_num += 1;
580
581            // Fetch next page
582            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, // Stop on error
596            };
597        }
598
599        releases
600    }
601
602    /// Fetch a GitHub release by tag.
603    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    /// Fetch tag info for a release by checking if target commit is contained in the tag.
640    /// Uses GitHub compare API to verify ancestry and get tag's commit hash.
641    /// Returns None if the tag doesn't contain the target commit.
642    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        // Use compare API with per_page=1 to optimize
649        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 status is "behind" or "identical", the target commit is in the tag's history
673        // "ahead" or "diverged" means the commit is NOT in the tag
674        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    /// Check if a tag contains a specific commit using the GitHub compare API.
697    ///
698    /// Returns true if the commit is in the tag's history (status is "behind" or "identical").
699    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    /// Fetch tag info by name.
729    /// Uses the commits API (which accepts refs) to resolve the tag to a commit,
730    /// then optionally enriches with release info if available.
731    pub async fn fetch_tag(&self, repo_info: &GhRepoInfo, tag_name: &str) -> Option<TagInfo> {
732        // Use commits API with tag name as ref to get the commit
733        let commit = self.fetch_commit_full_info(repo_info, tag_name).await?;
734
735        // Try to get release info (may not exist if tag has no release)
736        let release = self.fetch_release_by_tag(repo_info, tag_name).await;
737
738        let semver_info = parse_semver(tag_name);
739
740        // Compute tag_url: release URL for releases, tree URL for plain tags
741        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    /// Fetch file content from the default branch.
761    ///
762    /// Returns the decoded file content as a String, or None if the file
763    /// doesn't exist or can't be decoded (e.g., binary files).
764    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        // The API returns an array for directories, single item for files
785        let file_content = match content.items.into_iter().next()? {
786            octocrab::models::repos::Content {
787                content: Some(encoded),
788                ..
789            } => {
790                // Content is base64 encoded with newlines, need to remove them
791                let cleaned: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
792                STANDARD.decode(&cleaned).ok()?
793            }
794            _ => return None, // No content or it's a directory
795        };
796
797        String::from_utf8(file_content).ok()
798    }
799
800    /// Build GitHub URLs for various things
801    /// Build a commit URL (fallback when API data unavailable)
802    /// Uses URL encoding to prevent injection
803    #[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    /// Build a tag URL pointing to the tree view (for plain git tags).
815    /// Uses URL encoding to prevent injection.
816    #[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    /// Build a release URL pointing to the releases page (for tags with releases).
828    /// Uses URL encoding to prevent injection.
829    #[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    /// Build a profile URL (fallback when API data unavailable)
841    /// Uses URL encoding to prevent injection
842    #[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    /// Build a profile URL from a GitHub noreply email address.
852    ///
853    /// Extracts username from patterns:
854    /// - `username@users.noreply.github.com`
855    /// - `id+username@users.noreply.github.com`
856    #[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    /// Call a GitHub API with fallback from authenticated to anonymous client.
882    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    /// Call a GitHub API with fallback to backup client on SAML errors.
891    /// Returns results & the client used, or error.
892    /// Emits `Notice::GhRateLimitHit` if rate limit is detected.
893    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        // Try with main client first
898        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                // SAML error with authenticated client - fall through to try backup
914                e
915            }
916            Err(e) => {
917                // Non-SAML error, or SAML with anonymous client (no fallback possible)
918                log::debug!("GitHub API error: {e:?}");
919                return Err(e);
920            }
921        };
922
923        // Try with backup client on SAML error (only reached if authenticated)
924        let Some(backup) = self.backup_client.as_ref() else {
925            // Backup client failed to build - connection was lost between auth and now
926            return Err(WtgError::GhConnectionLost);
927        };
928
929        // Try the backup, but if it also fails with SAML, return original error
930        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                // Emit notice for anonymous fallback (authenticated was true to reach here,
935                // but backup is anonymous)
936                self.emit(Notice::GhRateLimitHit {
937                    authenticated: false,
938                });
939                Err(e)
940            }
941            Err(e) if e.is_gh_saml() => Err(main_error), // Return original SAML error
942            Err(e) => {
943                log::debug!("GitHub API error on backup client: {e:?}");
944                Err(e)
945            }
946        }
947    }
948
949    /// Await with timeout, returning non-timeout error if any
950    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}