wtg_cli/
github.rs

1use octocrab::{
2    Octocrab, OctocrabBuilder,
3    models::{Event as TimelineEventType, timelines::TimelineEvent},
4};
5use serde::Deserialize;
6use std::{future::Future, time::Duration};
7
8const CONNECT_TIMEOUT_SECS: u64 = 5;
9const READ_TIMEOUT_SECS: u64 = 30;
10const REQUEST_TIMEOUT_SECS: u64 = 5;
11
12#[derive(Debug, Clone)]
13pub struct GitHubClient {
14    client: Option<Octocrab>,
15    owner: String,
16    repo: String,
17}
18
19/// Information about a Pull Request
20#[derive(Debug, Clone)]
21pub struct PullRequestInfo {
22    pub number: u64,
23    pub title: String,
24    pub body: Option<String>,
25    pub state: String,
26    pub url: String,
27    pub merge_commit_sha: Option<String>,
28    pub author: Option<String>,
29    pub author_url: Option<String>,
30    pub created_at: Option<String>, // When the PR was created
31}
32
33/// Information about an Issue
34#[derive(Debug, Clone)]
35pub struct IssueInfo {
36    pub number: u64,
37    pub title: String,
38    pub body: Option<String>,
39    pub state: octocrab::models::IssueState,
40    pub url: String,
41    pub author: Option<String>,
42    pub author_url: Option<String>,
43    pub closing_prs: Vec<u64>,      // PR numbers that closed this issue
44    pub created_at: Option<String>, // When the issue was created
45}
46
47#[derive(Debug, Clone)]
48pub struct ReleaseInfo {
49    pub tag_name: String,
50    pub name: Option<String>,
51    pub url: String,
52    pub published_at: Option<String>,
53}
54
55impl GitHubClient {
56    /// Create a new GitHub client with authentication
57    #[must_use]
58    pub fn new(owner: String, repo: String) -> Self {
59        let client = Self::build_client();
60
61        Self {
62            client,
63            owner,
64            repo,
65        }
66    }
67
68    /// Build an authenticated octocrab client
69    fn build_client() -> Option<Octocrab> {
70        // Set reasonable timeouts: 5s connect, 30s read/write
71        let connect_timeout = Some(Self::connect_timeout());
72        let read_timeout = Some(Self::read_timeout());
73
74        // Try GITHUB_TOKEN env var first
75        if let Ok(token) = std::env::var("GITHUB_TOKEN") {
76            return OctocrabBuilder::new()
77                .personal_token(token)
78                .set_connect_timeout(connect_timeout)
79                .set_read_timeout(read_timeout)
80                .build()
81                .ok();
82        }
83
84        // Try reading from gh CLI config
85        if let Some(token) = Self::read_gh_config() {
86            return OctocrabBuilder::new()
87                .personal_token(token)
88                .set_connect_timeout(connect_timeout)
89                .set_read_timeout(read_timeout)
90                .build()
91                .ok();
92        }
93
94        // Fall back to anonymous
95        OctocrabBuilder::new()
96            .set_connect_timeout(connect_timeout)
97            .set_read_timeout(read_timeout)
98            .build()
99            .ok()
100    }
101
102    /// Read GitHub token from gh CLI config (cross-platform)
103    fn read_gh_config() -> Option<String> {
104        // gh CLI follows XDG conventions and stores config in:
105        // - Unix/macOS: ~/.config/gh/hosts.yml
106        // - Windows: %APPDATA%/gh/hosts.yml (but dirs crate handles this)
107
108        // Try XDG-style path first (~/.config/gh/hosts.yml)
109        if let Some(home) = dirs::home_dir() {
110            let xdg_path = home.join(".config").join("gh").join("hosts.yml");
111            if let Ok(content) = std::fs::read_to_string(&xdg_path)
112                && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
113                && let Some(token) = config.github_com.oauth_token
114            {
115                return Some(token);
116            }
117        }
118
119        // Fall back to platform-specific config dir
120        // (~/Library/Application Support/gh/hosts.yml on macOS)
121        if let Some(mut config_path) = dirs::config_dir() {
122            config_path.push("gh");
123            config_path.push("hosts.yml");
124
125            if let Ok(content) = std::fs::read_to_string(&config_path)
126                && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
127            {
128                return config.github_com.oauth_token;
129            }
130        }
131
132        None
133    }
134
135    /// Fetch the GitHub username and URLs for a commit
136    /// Returns None if the commit doesn't exist on GitHub
137    pub async fn fetch_commit_info(
138        &self,
139        commit_hash: &str,
140    ) -> Option<(String, String, Option<(String, String)>)> {
141        let client = self.client.as_ref()?;
142
143        let commit =
144            Self::await_with_timeout(client.commits(&self.owner, &self.repo).get(commit_hash))
145                .await?;
146
147        let commit_url = commit.html_url;
148        let author_info = commit
149            .author
150            .map(|author| (author.login, author.html_url.into()));
151
152        Some((commit_hash.to_string(), commit_url, author_info))
153    }
154
155    /// Try to fetch a PR
156    pub async fn fetch_pr(&self, number: u64) -> Option<PullRequestInfo> {
157        let client = self.client.as_ref()?;
158
159        if let Some(pr) =
160            Self::await_with_timeout(client.pulls(&self.owner, &self.repo).get(number)).await
161        {
162            let author = pr.user.as_ref().map(|u| u.login.clone());
163            let author_url = pr.user.as_ref().map(|u| u.html_url.to_string());
164            let created_at = pr.created_at.map(|dt| dt.to_string());
165
166            return Some(PullRequestInfo {
167                number,
168                title: pr.title.unwrap_or_default(),
169                body: pr.body,
170                state: format!("{:?}", pr.state),
171                url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
172                merge_commit_sha: pr.merge_commit_sha,
173                author,
174                author_url,
175                created_at,
176            });
177        }
178
179        None
180    }
181
182    /// Try to fetch an issue
183    pub async fn fetch_issue(&self, number: u64) -> Option<IssueInfo> {
184        let client = self.client.as_ref()?;
185
186        match Self::await_with_timeout(client.issues(&self.owner, &self.repo).get(number)).await {
187            Some(issue) => {
188                // If it has a pull_request field, it's actually a PR - skip it
189                if issue.pull_request.is_some() {
190                    return None;
191                }
192
193                let author = issue.user.login.clone();
194                let author_url = Some(issue.user.html_url.to_string());
195                let created_at = Some(issue.created_at.to_string());
196
197                // OPTIMIZED: Only fetch timeline for closed issues (open issues can't have closing PRs)
198                let is_closed = matches!(issue.state, octocrab::models::IssueState::Closed);
199                let closing_prs = if is_closed {
200                    self.find_closing_prs(number).await
201                } else {
202                    Vec::new()
203                };
204
205                Some(IssueInfo {
206                    number,
207                    title: issue.title,
208                    body: issue.body,
209                    state: issue.state,
210                    url: issue.html_url.to_string(),
211                    author: Some(author),
212                    author_url,
213                    closing_prs,
214                    created_at,
215                })
216            }
217            None => None,
218        }
219    }
220
221    /// Find closing PRs for an issue by examining timeline events
222    /// Returns list of PR numbers that closed this issue
223    async fn find_closing_prs(&self, issue_number: u64) -> Vec<u64> {
224        let Some(client) = self.client.as_ref() else {
225            return Vec::new();
226        };
227
228        let mut closing_prs = Vec::new();
229
230        let Some(mut page) = Self::await_with_timeout(
231            client
232                .issues(&self.owner, &self.repo)
233                .list_timeline_events(issue_number)
234                .per_page(100)
235                .send(),
236        )
237        .await
238        else {
239            return closing_prs;
240        };
241
242        loop {
243            for event in &page.items {
244                if let Some(source) = event.source.as_ref()
245                    && matches!(
246                        event.event,
247                        TimelineEventType::CrossReferenced | TimelineEventType::Referenced
248                    )
249                {
250                    let issue = &source.issue;
251                    if issue.pull_request.is_some() && !closing_prs.contains(&issue.number) {
252                        closing_prs.push(issue.number);
253                    }
254                }
255            }
256
257            match Self::await_with_timeout(client.get_page::<TimelineEvent>(&page.next))
258                .await
259                .flatten()
260            {
261                Some(next_page) => page = next_page,
262                None => break,
263            }
264        }
265
266        closing_prs
267    }
268
269    /// Fetch all releases from GitHub
270    pub async fn fetch_releases(&self) -> Vec<ReleaseInfo> {
271        self.fetch_releases_since(None).await
272    }
273
274    /// Fetch releases from GitHub, optionally filtered by date
275    /// If `since_date` is provided, stop fetching releases older than this date
276    /// This significantly speeds up lookups for recent PRs/issues
277    pub async fn fetch_releases_since(&self, since_date: Option<&str>) -> Vec<ReleaseInfo> {
278        let Some(client) = self.client.as_ref() else {
279            return Vec::new();
280        };
281
282        let mut releases = Vec::new();
283        let mut page_num = 1u32;
284        let per_page = 100u8; // Max allowed by GitHub API
285
286        // Parse the cutoff date if provided
287        let cutoff_timestamp = since_date.and_then(|date_str| {
288            chrono::DateTime::parse_from_rfc3339(date_str)
289                .ok()
290                .map(|dt| dt.timestamp())
291        });
292
293        loop {
294            let Some(page) = Self::await_with_timeout(
295                client
296                    .repos(&self.owner, &self.repo)
297                    .releases()
298                    .list()
299                    .per_page(per_page)
300                    .page(page_num)
301                    .send(),
302            )
303            .await
304            else {
305                break; // Stop on error
306            };
307
308            if page.items.is_empty() {
309                break; // No more pages
310            }
311
312            let mut should_stop = false;
313
314            for release in page.items {
315                let published_at_str = release.published_at.map(|dt| dt.to_string());
316
317                // Check if this release is too old
318                if let Some(cutoff) = cutoff_timestamp
319                    && let Some(pub_at) = &release.published_at
320                    && pub_at.timestamp() < cutoff
321                {
322                    should_stop = true;
323                    break; // Stop processing this page
324                }
325
326                releases.push(ReleaseInfo {
327                    tag_name: release.tag_name,
328                    name: release.name,
329                    url: release.html_url.to_string(),
330                    published_at: published_at_str,
331                });
332            }
333
334            if should_stop {
335                break; // Stop pagination
336            }
337
338            page_num += 1;
339        }
340
341        releases
342    }
343
344    /// Fetch a GitHub release by tag.
345    pub async fn fetch_release_by_tag(&self, tag: &str) -> Option<ReleaseInfo> {
346        let client = self.client.as_ref()?;
347        let release = Self::await_with_timeout(
348            client
349                .repos(&self.owner, &self.repo)
350                .releases()
351                .get_by_tag(tag),
352        )
353        .await?;
354
355        Some(ReleaseInfo {
356            tag_name: release.tag_name,
357            name: release.name,
358            url: release.html_url.to_string(),
359            published_at: release.published_at.map(|dt| dt.to_string()),
360        })
361    }
362
363    /// Build GitHub URLs for various things
364    /// Build a commit URL (fallback when API data unavailable)
365    /// Uses URL encoding to prevent injection
366    pub fn commit_url(&self, hash: &str) -> String {
367        use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
368        format!(
369            "https://github.com/{}/{}/commit/{}",
370            utf8_percent_encode(&self.owner, NON_ALPHANUMERIC),
371            utf8_percent_encode(&self.repo, NON_ALPHANUMERIC),
372            utf8_percent_encode(hash, NON_ALPHANUMERIC)
373        )
374    }
375
376    /// Build a tag URL (fallback when API data unavailable)
377    /// Uses URL encoding to prevent injection
378    pub fn tag_url(&self, tag: &str) -> String {
379        use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
380        format!(
381            "https://github.com/{}/{}/tree/{}",
382            utf8_percent_encode(&self.owner, NON_ALPHANUMERIC),
383            utf8_percent_encode(&self.repo, NON_ALPHANUMERIC),
384            utf8_percent_encode(tag, NON_ALPHANUMERIC)
385        )
386    }
387
388    /// Build a profile URL (fallback when API data unavailable)
389    /// Uses URL encoding to prevent injection
390    #[must_use]
391    pub fn profile_url(username: &str) -> String {
392        use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
393        format!(
394            "https://github.com/{}",
395            utf8_percent_encode(username, NON_ALPHANUMERIC)
396        )
397    }
398    const fn connect_timeout() -> Duration {
399        Duration::from_secs(CONNECT_TIMEOUT_SECS)
400    }
401
402    const fn read_timeout() -> Duration {
403        Duration::from_secs(READ_TIMEOUT_SECS)
404    }
405
406    const fn request_timeout() -> Duration {
407        Duration::from_secs(REQUEST_TIMEOUT_SECS)
408    }
409
410    async fn await_with_timeout<F, T>(future: F) -> Option<T>
411    where
412        F: Future<Output = octocrab::Result<T>>,
413    {
414        match tokio::time::timeout(Self::request_timeout(), future).await {
415            Ok(Ok(value)) => Some(value),
416            _ => None,
417        }
418    }
419}
420
421#[derive(Debug, Deserialize)]
422struct GhConfig {
423    #[serde(rename = "github.com")]
424    github_com: GhHostConfig,
425}
426
427#[derive(Debug, Deserialize)]
428struct GhHostConfig {
429    oauth_token: Option<String>,
430}