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#[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>, }
32
33#[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>, pub created_at: Option<String>, }
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 #[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 fn build_client() -> Option<Octocrab> {
70 let connect_timeout = Some(Self::connect_timeout());
72 let read_timeout = Some(Self::read_timeout());
73
74 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 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 OctocrabBuilder::new()
96 .set_connect_timeout(connect_timeout)
97 .set_read_timeout(read_timeout)
98 .build()
99 .ok()
100 }
101
102 fn read_gh_config() -> Option<String> {
104 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 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 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 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 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 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 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 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 pub async fn fetch_releases(&self) -> Vec<ReleaseInfo> {
271 self.fetch_releases_since(None).await
272 }
273
274 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; 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; };
307
308 if page.items.is_empty() {
309 break; }
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 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; }
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; }
337
338 page_num += 1;
339 }
340
341 releases
342 }
343
344 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 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 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 #[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}