Skip to main content

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/// Describes which client served an API call and why.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103enum ClientSelection {
104    /// The main (possibly authenticated) client succeeded directly.
105    Main,
106    /// The backup (anonymous) client was used after the main client failed.
107    Fallback(FallbackReason),
108}
109
110/// Why the main client failed, causing fallback to the backup client.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112enum FallbackReason {
113    /// SAML SSO enforcement blocked the authenticated request (403).
114    Saml,
115    /// Token was invalid, expired, or revoked (401).
116    BadCredentials,
117}
118
119/// Result from [`GitHubClient::call_api_and_get_client`] containing the API
120/// response, the client that served it, and how the client was selected.
121struct ApiCallResult<'a, T> {
122    value: T,
123    client: &'a Octocrab,
124    selection: ClientSelection,
125}
126
127/// GitHub API client wrapper.
128///
129/// - Provides a simplified interface for common GitHub operations used in wtg over direct octocrab usage.
130/// - Handles authentication via `GITHUB_TOKEN` env var or gh CLI config.
131/// - Supports fallback to anonymous requests on SAML errors via backup client.
132/// - Converts known octocrab errors into `WtgError` variants.
133/// - Returns `None` from `new()` if no client can be created.
134pub struct GitHubClient {
135    main_client: Octocrab,
136    /// Backup client for SAML fallback. Only populated when `main_client` is authenticated.
137    /// When `main_client` is anonymous, there's no point in falling back to another anonymous client.
138    backup_client: LazyLock<Option<Octocrab>>,
139    /// Whether `main_client` is authenticated (vs anonymous).
140    is_authenticated: bool,
141    /// Callback for emitting notices (e.g., rate limit hit).
142    /// Uses `OnceLock` since callback is set at most once after construction.
143    notice_callback: OnceLock<NoticeCallback>,
144}
145
146/// Information about a Pull Request
147#[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>>, // When the PR was created
160}
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/// Information about an Issue
185#[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>, // PRs that closed this issue (may be cross-repo)
195    pub created_at: Option<DateTime<Utc>>, // When the issue was created
196    /// Timeline data may be incomplete due to SAML-restricted org access.
197    /// When true, closing PRs may exist but aren't visible without SSO auth.
198    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 it has a pull_request field, it's actually a PR - reject it
206        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(), // Will be populated by caller if needed
223            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    /// Create a new GitHub client.
242    ///
243    /// Returns `None` if no client (neither authenticated nor anonymous) can be created.
244    /// If authentication succeeds, an anonymous backup client is created for SAML fallback.
245    /// If authentication fails, the anonymous client becomes the main client with no backup.
246    #[must_use]
247    pub fn new() -> Option<Self> {
248        // Try authenticated client first
249        if let Some(auth) = Self::build_auth_client() {
250            // Auth succeeded - create anonymous as lazy backup for SAML fallback
251            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        // Auth failed - try anonymous as main
260        // No backup needed: falling back to anonymous when already anonymous is pointless
261        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    /// Create a GitHub client with a specific token.
271    ///
272    /// Builds an authenticated client using the given token, with an anonymous
273    /// backup for fallback on SAML or bad credentials errors.
274    /// Returns `None` if the client cannot be built.
275    #[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    /// Set the notice callback for this client.
300    /// Can be called even when client is behind an `Arc`.
301    /// First call wins - subsequent calls are ignored.
302    pub fn set_notice_callback(&self, callback: NoticeCallback) {
303        // set() returns Err if already set - we ignore since first-set wins
304        let _ = self.notice_callback.set(callback);
305    }
306
307    /// Emit a notice via the callback, if one is set.
308    pub(crate) fn emit(&self, notice: Notice) {
309        if let Some(cb) = self.notice_callback.get() {
310            (cb)(notice);
311        }
312    }
313
314    /// Build an authenticated octocrab client.
315    /// Returns `None` if `WTG_GH_NO_AUTH` is set or no valid token is found.
316    fn build_auth_client() -> Option<Octocrab> {
317        // Undocumented: skip all authentication when WTG_GH_NO_AUTH is set
318        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        // Set reasonable timeouts: 5s connect, 30s read/write
324        let connect_timeout = Some(Self::connect_timeout());
325        let read_timeout = Some(Self::read_timeout());
326
327        // Try GITHUB_TOKEN env var first (skip empty/whitespace-only values)
328        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        // Try reading from gh CLI config
340        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    /// Build an anonymous octocrab client (no authentication)
353    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    /// Read GitHub token from gh CLI config (cross-platform)
365    fn read_gh_config() -> Option<String> {
366        // gh CLI follows XDG conventions and stores config in:
367        // - Unix/macOS: ~/.config/gh/hosts.yml
368        // - Windows: %APPDATA%/gh/hosts.yml (but dirs crate handles this)
369
370        // Try XDG-style path first (~/.config/gh/hosts.yml)
371        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        // Fall back to platform-specific config dir
383        // (~/Library/Application Support/gh/hosts.yml on macOS)
384        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    /// Fetch full commit information from a specific repository
402    /// Returns None if the commit doesn't exist on GitHub or client errors
403    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    /// Try to fetch a PR
431    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    /// Try to fetch an issue
454    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        // Only fetch timeline for closed issues (open issues can't have closing PRs)
480        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    /// Find closing PRs for an issue by examining timeline events.
491    /// Returns `(prs, timeline_may_be_incomplete)` where the flag is true when
492    /// the timeline was fetched via an anonymous fallback client (after a SAML
493    /// or bad-credentials error) or the API call failed entirely, meaning
494    /// cross-project PR references may be missing from the response.
495    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        // Try to get first page with auth client, fallback to anonymous
503        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            // API call failed entirely -- timeline is definitely incomplete
518            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        // Collect all timeline events to get closing commits and referenced PRs
529        'pagination: loop {
530            for event in &current_page.items {
531                // Collect candidate PRs from cross-references
532                if let Some(source) = event.source.as_ref() {
533                    let issue = &source.issue;
534                    if issue.pull_request.is_some() {
535                        // Extract repository info from repository_url using existing parser
536                        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; // Skip if PR fetch failed
543                            };
544
545                            if !pr_info.merged {
546                                continue; // Only consider merged PRs
547                            }
548
549                            if matches!(event.event, TimelineEventType::Closed) {
550                                // If it's a Closed event, assume this is the closing PR
551                                closing_prs.push(pr_info);
552                                break 'pagination; // No need to check further events or pages
553                            }
554
555                            // Otherwise, only consider CrossReferenced/Referenced events
556                            if !matches!(
557                                event.event,
558                                TimelineEventType::CrossReferenced | TimelineEventType::Referenced
559                            ) {
560                                continue;
561                            }
562
563                            // Check if we already have this PR
564                            // Note: GitHub API returns PRs as issues, so issue.number is the PR number
565                            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>(&current_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    /// Fetch releases from GitHub, optionally filtered by date
597    /// If `since_date` is provided, stop fetching releases older than this date
598    /// This significantly speeds up lookups for recent PRs/issues
599    #[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; // Max allowed by GitHub API
608
609        // Try to get first page with auth client, fallback to anonymous
610        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; // No more pages
634            }
635
636            // Sort releases by created_at descending
637            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                // Check if this release is too old
643                let release_tag_created_at = release.created_at.unwrap_or_default();
644
645                if release_tag_created_at < since_date {
646                    break 'pagintaion; // Stop processing
647                }
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; // No more pages
662            }
663
664            page_num += 1;
665
666            // Fetch next page
667            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, // Stop on error
681            };
682        }
683
684        releases
685    }
686
687    /// Fetch a GitHub release by tag.
688    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    /// Fetch tag info for a release by checking if target commit is contained in the tag.
725    /// Uses GitHub compare API to verify ancestry and get tag's commit hash.
726    /// Returns None if the tag doesn't contain the target commit.
727    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        // Use compare API with per_page=1 to optimize
734        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 status is "behind" or "identical", the target commit is in the tag's history
758        // "ahead" or "diverged" means the commit is NOT in the tag
759        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    /// Check if a tag contains a specific commit using the GitHub compare API.
782    ///
783    /// Returns true if the commit is in the tag's history (status is "behind" or "identical").
784    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    /// Fetch tag info by name.
814    /// Uses the commits API (which accepts refs) to resolve the tag to a commit,
815    /// then optionally enriches with release info if available.
816    pub async fn fetch_tag(&self, repo_info: &GhRepoInfo, tag_name: &str) -> Option<TagInfo> {
817        // Use commits API with tag name as ref to get the commit
818        let commit = self.fetch_commit_full_info(repo_info, tag_name).await?;
819
820        // Try to get release info (may not exist if tag has no release)
821        let release = self.fetch_release_by_tag(repo_info, tag_name).await;
822
823        let semver_info = parse_semver(tag_name);
824
825        // Compute tag_url: release URL for releases, tree URL for plain tags
826        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    /// Fetch file content from the default branch.
846    ///
847    /// Returns the decoded file content as a String, or None if the file
848    /// doesn't exist or can't be decoded (e.g., binary files).
849    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        // The API returns an array for directories, single item for files
870        let file_content = match content.items.into_iter().next()? {
871            octocrab::models::repos::Content {
872                content: Some(encoded),
873                ..
874            } => {
875                // Content is base64 encoded with newlines, need to remove them
876                let cleaned: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
877                STANDARD.decode(&cleaned).ok()?
878            }
879            _ => return None, // No content or it's a directory
880        };
881
882        String::from_utf8(file_content).ok()
883    }
884
885    /// Build GitHub URLs for various things
886    /// Build a commit URL (fallback when API data unavailable)
887    /// Uses URL encoding to prevent injection
888    #[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    /// Build a tag URL pointing to the tree view (for plain git tags).
900    /// Uses URL encoding to prevent injection.
901    #[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    /// Build a release URL pointing to the releases page (for tags with releases).
913    /// Uses URL encoding to prevent injection.
914    #[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    /// Build a profile URL (fallback when API data unavailable)
926    /// Uses URL encoding to prevent injection
927    #[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    /// Build a profile URL from a GitHub noreply email address.
937    ///
938    /// Extracts username from patterns:
939    /// - `username@users.noreply.github.com`
940    /// - `id+username@users.noreply.github.com`
941    #[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    /// Call a GitHub API with fallback from authenticated to anonymous client.
967    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    /// Call a GitHub API with fallback to backup client on SAML or bad-credentials errors.
976    /// Returns an [`ApiCallResult`] containing the value, the client used, and how the
977    /// client was selected (see [`ClientSelection`]).
978    /// Emits `Notice::GhRateLimitHit` if rate limit is detected.
979    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        // Try with main client first
984        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                    // SAML error with authenticated client - fall through to try backup
1006                    (e, FallbackReason::Saml)
1007                }
1008                Err(e) if e.is_gh_bad_credentials() && self.is_authenticated => {
1009                    // Bad credentials (401) - token is invalid/expired, fall through to try backup
1010                    log::debug!("GitHub API bad credentials, falling back to anonymous client");
1011                    (e, FallbackReason::BadCredentials)
1012                }
1013                Err(e) => {
1014                    // Non-recoverable error, or unauthenticated client (no fallback possible)
1015                    log::debug!("GitHub API error: {e:?}");
1016                    return Err(e);
1017                }
1018            };
1019
1020        // Try with backup client (only reached if authenticated and main failed with SAML or bad creds)
1021        let Some(backup) = self.backup_client.as_ref() else {
1022            // Backup client failed to build - connection was lost between auth and now
1023            return Err(WtgError::GhConnectionLost);
1024        };
1025
1026        // Try the backup; if it also fails, return the appropriate error
1027        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                // Emit notice for anonymous fallback (authenticated was true to reach here,
1036                // but backup is anonymous)
1037                self.emit(Notice::GhRateLimitHit {
1038                    authenticated: false,
1039                });
1040                Err(e)
1041            }
1042            Err(e) if e.is_gh_saml() => Err(main_error), // Return original main-client error
1043            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    /// Await with timeout, returning non-timeout error if any
1054    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}