1use std::{env, fs, future::Future, pin::Pin, sync::LazyLock, time::Duration};
2
3use chrono::{DateTime, Utc};
4use octocrab::{
5 Octocrab, OctocrabBuilder, Result as OctoResult,
6 models::{
7 Event as TimelineEventType, commits::GithubCommitStatus, repos::RepoCommit,
8 timelines::TimelineEvent,
9 },
10};
11use serde::Deserialize;
12
13use crate::error::{WtgError, WtgResult};
14use crate::git::{CommitInfo, TagInfo, parse_semver};
15use crate::parse_input::parse_github_repo_url;
16
17impl From<RepoCommit> for CommitInfo {
18 fn from(commit: RepoCommit) -> Self {
19 let message = commit.commit.message;
20 let message_lines = message.lines().count();
21
22 let author_name = commit
23 .commit
24 .author
25 .as_ref()
26 .map_or_else(|| "Unknown".to_string(), |a| a.name.clone());
27
28 let author_email = commit.commit.author.as_ref().and_then(|a| a.email.clone());
29
30 let commit_url = commit.html_url;
31
32 let (author_login, author_url) = commit
33 .author
34 .map(|author| (Some(author.login), Some(author.html_url.into())))
35 .unwrap_or_default();
36
37 let date = commit
38 .commit
39 .author
40 .as_ref()
41 .and_then(|a| a.date.as_ref())
42 .copied()
43 .unwrap_or_else(Utc::now);
44
45 let full_hash = commit.sha;
46
47 Self {
48 hash: full_hash.clone(),
49 short_hash: full_hash[..7.min(full_hash.len())].to_string(),
50 message: message.lines().next().unwrap_or("").to_string(),
51 message_lines,
52 commit_url: Some(commit_url),
53 author_name,
54 author_email,
55 author_login,
56 author_url,
57 date,
58 }
59 }
60}
61
62const CONNECT_TIMEOUT_SECS: u64 = 5;
63const READ_TIMEOUT_SECS: u64 = 30;
64const REQUEST_TIMEOUT_SECS: u64 = 5;
65
66#[derive(Debug, Deserialize)]
67struct GhConfig {
68 #[serde(rename = "github.com")]
69 github_com: GhHostConfig,
70}
71
72#[derive(Debug, Deserialize)]
73struct GhHostConfig {
74 oauth_token: Option<String>,
75}
76
77#[derive(Debug, Clone)]
78pub struct GhRepoInfo {
79 owner: String,
80 repo: String,
81}
82
83impl GhRepoInfo {
84 #[must_use]
85 pub const fn new(owner: String, repo: String) -> Self {
86 Self { owner, repo }
87 }
88
89 #[must_use]
90 pub fn owner(&self) -> &str {
91 &self.owner
92 }
93
94 #[must_use]
95 pub fn repo(&self) -> &str {
96 &self.repo
97 }
98}
99
100#[derive(Debug)]
108pub struct GitHubClient {
109 main_client: Octocrab,
110 backup_client: LazyLock<Option<Octocrab>>,
113 is_authenticated: bool,
115}
116
117#[derive(Debug, Clone)]
119pub struct PullRequestInfo {
120 pub number: u64,
121 pub repo_info: Option<GhRepoInfo>,
122 pub title: String,
123 pub body: Option<String>,
124 pub state: String,
125 pub url: String,
126 pub merged: bool,
127 pub merge_commit_sha: Option<String>,
128 pub author: Option<String>,
129 pub author_url: Option<String>,
130 pub created_at: Option<DateTime<Utc>>, }
132
133impl From<octocrab::models::pulls::PullRequest> for PullRequestInfo {
134 fn from(pr: octocrab::models::pulls::PullRequest) -> Self {
135 let author = pr.user.as_ref().map(|u| u.login.clone());
136 let author_url = pr.user.as_ref().map(|u| u.html_url.to_string());
137 let created_at = pr.created_at;
138
139 Self {
140 number: pr.number,
141 repo_info: parse_github_repo_url(pr.url.as_str()),
142 title: pr.title.unwrap_or_default(),
143 body: pr.body,
144 state: format!("{:?}", pr.state),
145 url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
146 merged: pr.merged.unwrap_or(false),
147 merge_commit_sha: pr.merge_commit_sha,
148 author,
149 author_url,
150 created_at,
151 }
152 }
153}
154
155#[derive(Debug, Clone)]
157pub struct ExtendedIssueInfo {
158 pub number: u64,
159 pub title: String,
160 pub body: Option<String>,
161 pub state: octocrab::models::IssueState,
162 pub url: String,
163 pub author: Option<String>,
164 pub author_url: Option<String>,
165 pub closing_prs: Vec<PullRequestInfo>, pub created_at: Option<DateTime<Utc>>, }
168
169impl TryFrom<octocrab::models::issues::Issue> for ExtendedIssueInfo {
170 type Error = ();
171
172 fn try_from(issue: octocrab::models::issues::Issue) -> Result<Self, Self::Error> {
173 if issue.pull_request.is_some() {
175 return Err(());
176 }
177
178 let author = issue.user.login.clone();
179 let author_url = Some(issue.user.html_url.to_string());
180 let created_at = Some(issue.created_at);
181
182 Ok(Self {
183 number: issue.number,
184 title: issue.title,
185 body: issue.body,
186 state: issue.state,
187 url: issue.html_url.to_string(),
188 author: Some(author),
189 author_url,
190 closing_prs: Vec::new(), created_at,
192 })
193 }
194}
195
196#[derive(Debug, Clone)]
197pub struct ReleaseInfo {
198 pub tag_name: String,
199 pub name: Option<String>,
200 pub url: String,
201 pub published_at: Option<DateTime<Utc>>,
202 pub created_at: Option<DateTime<Utc>>,
203 pub prerelease: bool,
204}
205
206impl GitHubClient {
207 #[must_use]
213 pub fn new() -> Option<Self> {
214 if let Some(auth) = Self::build_auth_client() {
216 return Some(Self {
218 main_client: auth,
219 backup_client: LazyLock::new(Self::build_anonymous_client),
220 is_authenticated: true,
221 });
222 }
223
224 let anonymous = Self::build_anonymous_client()?;
227 Some(Self {
228 main_client: anonymous,
229 backup_client: LazyLock::new(|| None),
230 is_authenticated: false,
231 })
232 }
233
234 fn build_auth_client() -> Option<Octocrab> {
236 let connect_timeout = Some(Self::connect_timeout());
238 let read_timeout = Some(Self::read_timeout());
239
240 if let Ok(token) = env::var("GITHUB_TOKEN") {
242 return OctocrabBuilder::new()
243 .personal_token(token)
244 .set_connect_timeout(connect_timeout)
245 .set_read_timeout(read_timeout)
246 .build()
247 .ok();
248 }
249
250 if let Some(token) = Self::read_gh_config() {
252 return OctocrabBuilder::new()
253 .personal_token(token)
254 .set_connect_timeout(connect_timeout)
255 .set_read_timeout(read_timeout)
256 .build()
257 .ok();
258 }
259
260 None
261 }
262
263 fn build_anonymous_client() -> Option<Octocrab> {
265 let connect_timeout = Some(Self::connect_timeout());
266 let read_timeout = Some(Self::read_timeout());
267
268 OctocrabBuilder::new()
269 .set_connect_timeout(connect_timeout)
270 .set_read_timeout(read_timeout)
271 .build()
272 .ok()
273 }
274
275 fn read_gh_config() -> Option<String> {
277 if let Some(home) = dirs::home_dir() {
283 let xdg_path = home.join(".config").join("gh").join("hosts.yml");
284 if let Ok(content) = fs::read_to_string(&xdg_path)
285 && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
286 && let Some(token) = config.github_com.oauth_token
287 {
288 return Some(token);
289 }
290 }
291
292 if let Some(mut config_path) = dirs::config_dir() {
295 config_path.push("gh");
296 config_path.push("hosts.yml");
297
298 if let Ok(content) = fs::read_to_string(&config_path)
299 && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
300 {
301 return config.github_com.oauth_token;
302 }
303 }
304
305 None
306 }
307
308 pub async fn fetch_commit_full_info(
311 &self,
312 repo_info: &GhRepoInfo,
313 commit_hash: &str,
314 ) -> Option<CommitInfo> {
315 let commit = self
316 .call_client_api_with_fallback(move |client| {
317 let hash = commit_hash.to_string();
318 let repo_info = repo_info.clone();
319 Box::pin(async move {
320 client
321 .commits(repo_info.owner(), repo_info.repo())
322 .get(&hash)
323 .await
324 })
325 })
326 .await
327 .ok()?;
328
329 Some(commit.into())
330 }
331
332 pub async fn fetch_pr(&self, repo_info: &GhRepoInfo, number: u64) -> Option<PullRequestInfo> {
334 let pr = self
335 .call_client_api_with_fallback(move |client| {
336 let repo_info = repo_info.clone();
337 Box::pin(async move {
338 client
339 .pulls(repo_info.owner(), repo_info.repo())
340 .get(number)
341 .await
342 })
343 })
344 .await
345 .ok()?;
346
347 Some(pr.into())
348 }
349
350 pub async fn fetch_issue(
352 &self,
353 repo_info: &GhRepoInfo,
354 number: u64,
355 ) -> Option<ExtendedIssueInfo> {
356 let issue = self
357 .call_client_api_with_fallback(move |client| {
358 let repo_info = repo_info.clone();
359 Box::pin(async move {
360 client
361 .issues(repo_info.owner(), repo_info.repo())
362 .get(number)
363 .await
364 })
365 })
366 .await
367 .ok()?;
368
369 let mut issue_info = ExtendedIssueInfo::try_from(issue).ok()?;
370
371 if matches!(issue_info.state, octocrab::models::IssueState::Closed) {
373 issue_info.closing_prs = self.find_closing_prs(repo_info, issue_info.number).await;
374 }
375
376 Some(issue_info)
377 }
378
379 async fn find_closing_prs(
385 &self,
386 repo_info: &GhRepoInfo,
387 issue_number: u64,
388 ) -> Vec<PullRequestInfo> {
389 let mut closing_prs = Vec::new();
390
391 let Ok((mut current_page, client)) = self
393 .call_api_and_get_client(move |client| {
394 let repo_info = repo_info.clone();
395 Box::pin(async move {
396 client
397 .issues(repo_info.owner(), repo_info.repo())
398 .list_timeline_events(issue_number)
399 .per_page(100)
400 .send()
401 .await
402 })
403 })
404 .await
405 else {
406 return Vec::new();
407 };
408
409 loop {
411 for event in ¤t_page.items {
412 if let Some(source) = event.source.as_ref() {
414 let issue = &source.issue;
415 if issue.pull_request.is_some() {
416 if let Some(repo_info) =
418 parse_github_repo_url(issue.repository_url.as_str())
419 {
420 let Some(pr_info) =
421 Box::pin(self.fetch_pr(&repo_info, issue.number)).await
422 else {
423 continue; };
425
426 if !pr_info.merged {
427 continue; }
429
430 if matches!(event.event, TimelineEventType::Closed) {
431 closing_prs.push(pr_info);
433 break; }
435
436 if !matches!(
438 event.event,
439 TimelineEventType::CrossReferenced | TimelineEventType::Referenced
440 ) {
441 continue;
442 }
443
444 if !closing_prs.iter().any(|p| {
447 p.number == issue.number
448 && p.repo_info
449 .as_ref()
450 .is_some_and(|ri| ri.owner() == repo_info.owner())
451 && p.repo_info
452 .as_ref()
453 .is_some_and(|ri| ri.repo() == repo_info.repo())
454 }) {
455 closing_prs.push(pr_info);
456 }
457 }
458 }
459 }
460 }
461
462 match Self::await_with_timeout_and_error(
463 client.get_page::<TimelineEvent>(¤t_page.next),
464 )
465 .await
466 .ok()
467 .flatten()
468 {
469 Some(next_page) => current_page = next_page,
470 None => break,
471 }
472 }
473
474 closing_prs
475 }
476
477 #[allow(clippy::too_many_lines)]
481 pub async fn fetch_releases_since(
482 &self,
483 repo_info: &GhRepoInfo,
484 since_date: DateTime<Utc>,
485 ) -> Vec<ReleaseInfo> {
486 let mut releases = Vec::new();
487 let mut page_num = 1u32;
488 let per_page = 100u8; let Ok((mut current_page, client)) = self
492 .call_api_and_get_client(move |client| {
493 let repo_info = repo_info.clone();
494 Box::pin(async move {
495 client
496 .repos(repo_info.owner(), repo_info.repo())
497 .releases()
498 .list()
499 .per_page(per_page)
500 .page(page_num)
501 .send()
502 .await
503 })
504 })
505 .await
506 else {
507 return releases;
508 };
509
510 'pagintaion: loop {
511 if current_page.items.is_empty() {
512 break; }
514
515 current_page
517 .items
518 .sort_by(|a, b| b.created_at.cmp(&a.created_at));
519
520 for release in current_page.items {
521 let release_tag_created_at = release.created_at.unwrap_or_default();
523
524 if release_tag_created_at < since_date {
525 break 'pagintaion; }
527
528 releases.push(ReleaseInfo {
529 tag_name: release.tag_name,
530 name: release.name,
531 url: release.html_url.to_string(),
532 published_at: release.published_at,
533 created_at: release.created_at,
534 prerelease: release.prerelease,
535 });
536 }
537
538 if current_page.next.is_none() {
539 break; }
541
542 page_num += 1;
543
544 current_page = match Self::await_with_timeout_and_error(
546 client
547 .repos(repo_info.owner(), repo_info.repo())
548 .releases()
549 .list()
550 .per_page(per_page)
551 .page(page_num)
552 .send(),
553 )
554 .await
555 .ok()
556 {
557 Some(page) => page,
558 None => break, };
560 }
561
562 releases
563 }
564
565 pub async fn fetch_release_by_tag(
567 &self,
568 repo_info: &GhRepoInfo,
569 tag: &str,
570 ) -> Option<ReleaseInfo> {
571 let release = self
572 .call_client_api_with_fallback(move |client| {
573 let tag = tag.to_string();
574 let repo_info = repo_info.clone();
575 Box::pin(async move {
576 client
577 .repos(repo_info.owner(), repo_info.repo())
578 .releases()
579 .get_by_tag(tag.as_str())
580 .await
581 })
582 })
583 .await
584 .ok()?;
585
586 Some(ReleaseInfo {
587 tag_name: release.tag_name,
588 name: release.name,
589 url: release.html_url.to_string(),
590 published_at: release.published_at,
591 created_at: release.created_at,
592 prerelease: release.prerelease,
593 })
594 }
595
596 pub async fn fetch_tag_info_for_release(
600 &self,
601 release: &ReleaseInfo,
602 repo_info: &GhRepoInfo,
603 target_commit: &str,
604 ) -> Option<TagInfo> {
605 let compare = self
607 .call_client_api_with_fallback(move |client| {
608 let tag_name = release.tag_name.clone();
609 let target_commit = target_commit.to_string();
610 let repo_info = repo_info.clone();
611 Box::pin(async move {
612 client
613 .commits(repo_info.owner(), repo_info.repo())
614 .compare(&tag_name, &target_commit)
615 .per_page(1)
616 .send()
617 .await
618 })
619 })
620 .await
621 .ok()?;
622
623 if !matches!(
626 compare.status,
627 GithubCommitStatus::Behind | GithubCommitStatus::Identical
628 ) {
629 return None;
630 }
631
632 let semver_info = parse_semver(&release.tag_name);
633
634 Some(TagInfo {
635 name: release.tag_name.clone(),
636 commit_hash: compare.base_commit.sha,
637 semver_info,
638 created_at: release.created_at?,
639 is_release: true,
640 release_name: release.name.clone(),
641 release_url: Some(release.url.clone()),
642 published_at: release.published_at,
643 })
644 }
645
646 pub async fn fetch_tag(&self, repo_info: &GhRepoInfo, tag_name: &str) -> Option<TagInfo> {
650 let commit = self.fetch_commit_full_info(repo_info, tag_name).await?;
652
653 let release = self.fetch_release_by_tag(repo_info, tag_name).await;
655
656 let semver_info = parse_semver(tag_name);
657
658 Some(TagInfo {
659 name: tag_name.to_string(),
660 commit_hash: commit.hash,
661 semver_info,
662 created_at: commit.date,
663 is_release: release.is_some(),
664 release_name: release.as_ref().and_then(|r| r.name.clone()),
665 release_url: release.as_ref().map(|r| r.url.clone()),
666 published_at: release.and_then(|r| r.published_at),
667 })
668 }
669
670 #[must_use]
674 pub fn commit_url(repo_info: &GhRepoInfo, hash: &str) -> String {
675 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
676 format!(
677 "https://github.com/{}/{}/commit/{}",
678 utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
679 utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
680 utf8_percent_encode(hash, NON_ALPHANUMERIC)
681 )
682 }
683
684 #[must_use]
687 pub fn tag_url(repo_info: &GhRepoInfo, tag: &str) -> String {
688 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
689 format!(
690 "https://github.com/{}/{}/tree/{}",
691 utf8_percent_encode(repo_info.owner(), NON_ALPHANUMERIC),
692 utf8_percent_encode(repo_info.repo(), NON_ALPHANUMERIC),
693 utf8_percent_encode(tag, NON_ALPHANUMERIC)
694 )
695 }
696
697 #[must_use]
700 pub fn profile_url(username: &str) -> String {
701 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
702 format!(
703 "https://github.com/{}",
704 utf8_percent_encode(username, NON_ALPHANUMERIC)
705 )
706 }
707
708 #[must_use]
714 pub fn author_url_from_email(email: &str) -> Option<String> {
715 if email.ends_with("@users.noreply.github.com") {
716 let parts: Vec<&str> = email.split('@').collect();
717 if let Some(user_part) = parts.first()
718 && let Some(username) = user_part.split('+').next_back()
719 {
720 return Some(Self::profile_url(username));
721 }
722 }
723 None
724 }
725
726 const fn connect_timeout() -> Duration {
727 Duration::from_secs(CONNECT_TIMEOUT_SECS)
728 }
729
730 const fn read_timeout() -> Duration {
731 Duration::from_secs(READ_TIMEOUT_SECS)
732 }
733
734 const fn request_timeout() -> Duration {
735 Duration::from_secs(REQUEST_TIMEOUT_SECS)
736 }
737
738 async fn call_client_api_with_fallback<F, T>(&self, api_call: F) -> WtgResult<T>
740 where
741 for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
742 {
743 let (result, _client) = self.call_api_and_get_client(api_call).await?;
744 Ok(result)
745 }
746
747 async fn call_api_and_get_client<F, T>(&self, api_call: F) -> WtgResult<(T, &Octocrab)>
750 where
751 for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
752 {
753 let main_error = match Self::await_with_timeout_and_error(api_call(&self.main_client)).await
755 {
756 Ok(result) => return Ok((result, &self.main_client)),
757 Err(e) if e.is_gh_saml() && self.is_authenticated => {
758 e
760 }
761 Err(e) => {
762 return Err(e);
764 }
765 };
766
767 let Some(backup) = self.backup_client.as_ref() else {
769 return Err(WtgError::GhConnectionLost);
771 };
772
773 match Self::await_with_timeout_and_error(api_call(backup)).await {
775 Ok(result) => Ok((result, backup)),
776 Err(e) if e.is_gh_saml() => Err(main_error), Err(e) => Err(e),
778 }
779 }
780
781 async fn await_with_timeout_and_error<F, T>(future: F) -> WtgResult<T>
783 where
784 F: Future<Output = OctoResult<T>>,
785 {
786 match tokio::time::timeout(Self::request_timeout(), future).await {
787 Ok(Ok(value)) => Ok(value),
788 Ok(Err(e)) => Err(e.into()),
789 Err(_) => Err(WtgError::Timeout),
790 }
791 }
792}