wtg_cli/
github.rs

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