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    /// Check if client is available
132    #[allow(dead_code)] // Will be used for network availability checks
133    pub const fn is_available(&self) -> bool {
134        self.client.is_some()
135    }
136
137    /// Fetch the GitHub username of a commit author
138    /// Returns None if the commit doesn't exist on GitHub or has no author
139    pub async fn fetch_commit_author(&self, commit_hash: &str) -> Option<String> {
140        let client = self.client.as_ref()?;
141
142        let commit = client
143            .commits(&self.owner, &self.repo)
144            .get(commit_hash)
145            .await
146            .ok()?;
147
148        commit.author.map(|author| author.login)
149    }
150
151    /// Try to fetch a PR
152    pub async fn fetch_pr(&self, number: u64) -> Option<PullRequestInfo> {
153        let client = self.client.as_ref()?;
154
155        if let Ok(pr) = client.pulls(&self.owner, &self.repo).get(number).await {
156            let author = pr.user.as_ref().map(|u| u.login.clone());
157            let author_url = author.as_ref().map(|login| Self::profile_url(login));
158            let created_at = pr.created_at.map(|dt| dt.to_string());
159
160            return Some(PullRequestInfo {
161                number,
162                title: pr.title.unwrap_or_default(),
163                body: pr.body,
164                state: format!("{:?}", pr.state),
165                url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
166                merge_commit_sha: pr.merge_commit_sha,
167                author,
168                author_url,
169                created_at,
170            });
171        }
172
173        None
174    }
175
176    /// Try to fetch an issue
177    pub async fn fetch_issue(&self, number: u64) -> Option<IssueInfo> {
178        let client = self.client.as_ref()?;
179
180        match client.issues(&self.owner, &self.repo).get(number).await {
181            Ok(issue) => {
182                // If it has a pull_request field, it's actually a PR - skip it
183                if issue.pull_request.is_some() {
184                    return None;
185                }
186
187                let author = issue.user.login.clone();
188                let author_url = Some(Self::profile_url(&author));
189                let created_at = Some(issue.created_at.to_string());
190
191                // OPTIMIZED: Only fetch timeline for closed issues (open issues can't have closing PRs)
192                let is_closed = matches!(issue.state, octocrab::models::IssueState::Closed);
193                let closing_prs = if is_closed {
194                    self.find_closing_prs(number).await
195                } else {
196                    Vec::new()
197                };
198
199                Some(IssueInfo {
200                    number,
201                    title: issue.title,
202                    body: issue.body,
203                    state: issue.state,
204                    url: issue.html_url.to_string(),
205                    author: Some(author),
206                    author_url,
207                    closing_prs,
208                    created_at,
209                })
210            }
211            Err(_) => None,
212        }
213    }
214
215    /// Find closing PRs for an issue by examining timeline events
216    /// Returns list of PR numbers that closed this issue
217    async fn find_closing_prs(&self, issue_number: u64) -> Vec<u64> {
218        let Some(client) = self.client.as_ref() else {
219            return Vec::new();
220        };
221
222        let mut closing_prs = Vec::new();
223
224        let Ok(mut page) = client
225            .issues(&self.owner, &self.repo)
226            .list_timeline_events(issue_number)
227            .per_page(100)
228            .send()
229            .await
230        else {
231            return closing_prs;
232        };
233
234        loop {
235            for event in &page.items {
236                if let Some(source) = event.source.as_ref()
237                    && matches!(
238                        event.event,
239                        TimelineEventType::CrossReferenced | TimelineEventType::Referenced
240                    )
241                {
242                    let issue = &source.issue;
243                    if issue.pull_request.is_some() && !closing_prs.contains(&issue.number) {
244                        closing_prs.push(issue.number);
245                    }
246                }
247            }
248
249            match client
250                .get_page::<TimelineEvent>(&page.next)
251                .await
252                .ok()
253                .flatten()
254            {
255                Some(next_page) => page = next_page,
256                None => break,
257            }
258        }
259
260        closing_prs
261    }
262
263    /// Fetch all releases from GitHub
264    pub async fn fetch_releases(&self) -> Vec<ReleaseInfo> {
265        self.fetch_releases_since(None).await
266    }
267
268    /// Fetch releases from GitHub, optionally filtered by date
269    /// If `since_date` is provided, stop fetching releases older than this date
270    /// This significantly speeds up lookups for recent PRs/issues
271    pub async fn fetch_releases_since(&self, since_date: Option<&str>) -> Vec<ReleaseInfo> {
272        let Some(client) = self.client.as_ref() else {
273            return Vec::new();
274        };
275
276        let mut releases = Vec::new();
277        let mut page_num = 1u32;
278        let per_page = 100u8; // Max allowed by GitHub API
279
280        // Parse the cutoff date if provided
281        let cutoff_timestamp = since_date.and_then(|date_str| {
282            chrono::DateTime::parse_from_rfc3339(date_str)
283                .ok()
284                .map(|dt| dt.timestamp())
285        });
286
287        loop {
288            let page_result = client
289                .repos(&self.owner, &self.repo)
290                .releases()
291                .list()
292                .per_page(per_page)
293                .page(page_num)
294                .send()
295                .await;
296
297            let Ok(page) = page_result else {
298                break; // Stop on error
299            };
300
301            if page.items.is_empty() {
302                break; // No more pages
303            }
304
305            let mut should_stop = false;
306
307            for release in page.items {
308                let published_at_str = release.published_at.map(|dt| dt.to_string());
309
310                // Check if this release is too old
311                if let Some(cutoff) = cutoff_timestamp
312                    && let Some(pub_at) = &release.published_at
313                    && pub_at.timestamp() < cutoff
314                {
315                    should_stop = true;
316                    break; // Stop processing this page
317                }
318
319                releases.push(ReleaseInfo {
320                    tag_name: release.tag_name,
321                    name: release.name,
322                    url: release.html_url.to_string(),
323                    published_at: published_at_str,
324                });
325            }
326
327            if should_stop {
328                break; // Stop pagination
329            }
330
331            page_num += 1;
332        }
333
334        releases
335    }
336
337    /// Fetch a GitHub release by tag.
338    pub async fn fetch_release_by_tag(&self, tag: &str) -> Option<ReleaseInfo> {
339        let client = self.client.as_ref()?;
340        let release = client
341            .repos(&self.owner, &self.repo)
342            .releases()
343            .get_by_tag(tag)
344            .await
345            .ok()?;
346
347        Some(ReleaseInfo {
348            tag_name: release.tag_name,
349            name: release.name,
350            url: release.html_url.to_string(),
351            published_at: release.published_at.map(|dt| dt.to_string()),
352        })
353    }
354
355    /// Build GitHub URLs for various things
356    pub fn commit_url(&self, hash: &str) -> String {
357        format!(
358            "https://github.com/{}/{}/commit/{}",
359            self.owner, self.repo, hash
360        )
361    }
362
363    #[allow(dead_code)] // Will be used when displaying release info
364    pub fn release_url(&self, tag: &str) -> String {
365        format!(
366            "https://github.com/{}/{}/releases/tag/{}",
367            self.owner, self.repo, tag
368        )
369    }
370
371    pub fn tag_url(&self, tag: &str) -> String {
372        format!(
373            "https://github.com/{}/{}/tree/{}",
374            self.owner, self.repo, tag
375        )
376    }
377
378    #[must_use]
379    pub fn profile_url(username: &str) -> String {
380        format!("https://github.com/{username}")
381    }
382
383    #[allow(dead_code)] // Will be used for issue link generation
384    pub fn issue_url(&self, number: u64) -> String {
385        format!(
386            "https://github.com/{}/{}/issues/{}",
387            self.owner, self.repo, number
388        )
389    }
390
391    #[allow(dead_code)] // Will be used for PR link generation
392    pub fn pr_url(&self, number: u64) -> String {
393        format!(
394            "https://github.com/{}/{}/pull/{}",
395            self.owner, self.repo, number
396        )
397    }
398}
399
400#[derive(Debug, Deserialize)]
401struct GhConfig {
402    #[serde(rename = "github.com")]
403    github_com: GhHostConfig,
404}
405
406#[derive(Debug, Deserialize)]
407struct GhHostConfig {
408    oauth_token: Option<String>,
409}