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#[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>, }
28
29#[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>, pub created_at: Option<String>, }
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 #[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 fn build_client() -> Option<Octocrab> {
66 let connect_timeout = Some(Duration::from_secs(5));
68 let read_timeout = Some(Duration::from_secs(30));
69
70 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 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 OctocrabBuilder::new()
92 .set_connect_timeout(connect_timeout)
93 .set_read_timeout(read_timeout)
94 .build()
95 .ok()
96 }
97
98 fn read_gh_config() -> Option<String> {
100 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 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 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 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 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 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 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 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 pub async fn fetch_releases(&self) -> Vec<ReleaseInfo> {
267 self.fetch_releases_since(None).await
268 }
269
270 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; 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; };
302
303 if page.items.is_empty() {
304 break; }
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 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; }
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; }
332
333 page_num += 1;
334 }
335
336 releases
337 }
338
339 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 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 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 #[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}