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 #[allow(dead_code)] pub const fn is_available(&self) -> bool {
134 self.client.is_some()
135 }
136
137 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 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 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 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 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 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 pub async fn fetch_releases(&self) -> Vec<ReleaseInfo> {
265 self.fetch_releases_since(None).await
266 }
267
268 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; 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; };
300
301 if page.items.is_empty() {
302 break; }
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 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; }
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; }
330
331 page_num += 1;
332 }
333
334 releases
335 }
336
337 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 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)] 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)] 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)] 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}