1use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
10use serde::{Deserialize, Serialize};
11
12use crate::error::{AiError, Result};
13
14#[derive(Clone)]
16pub struct GitHubClient {
17 client: reqwest::Client,
18 config: GitHubConfig,
19}
20
21#[derive(Debug, Clone)]
23pub struct GitHubConfig {
24 pub token: Option<String>,
26 pub api_base: String,
28 pub timeout_secs: u64,
30}
31
32impl Default for GitHubConfig {
33 fn default() -> Self {
34 Self {
35 token: None,
36 api_base: "https://api.github.com".to_string(),
37 timeout_secs: 30,
38 }
39 }
40}
41
42impl GitHubConfig {
43 #[must_use]
45 pub fn from_env() -> Self {
46 Self {
47 token: std::env::var("GITHUB_TOKEN").ok(),
48 ..Default::default()
49 }
50 }
51
52 #[must_use]
54 pub fn with_token(mut self, token: String) -> Self {
55 self.token = Some(token);
56 self
57 }
58}
59
60impl GitHubClient {
61 pub fn new(config: GitHubConfig) -> Result<Self> {
63 let mut headers = HeaderMap::new();
64 headers.insert(
65 ACCEPT,
66 HeaderValue::from_static("application/vnd.github.v3+json"),
67 );
68 headers.insert(USER_AGENT, HeaderValue::from_static("kaccy-ai/1.0"));
69
70 if let Some(ref token) = config.token {
71 headers.insert(
72 AUTHORIZATION,
73 HeaderValue::from_str(&format!("Bearer {token}"))
74 .map_err(|e| AiError::GitHub(format!("Invalid token: {e}")))?,
75 );
76 }
77
78 let client = reqwest::Client::builder()
79 .default_headers(headers)
80 .timeout(std::time::Duration::from_secs(config.timeout_secs))
81 .build()
82 .map_err(|e| AiError::GitHub(format!("Failed to create HTTP client: {e}")))?;
83
84 Ok(Self { client, config })
85 }
86
87 fn api_url(&self, path: &str) -> String {
89 format!("{}{}", self.config.api_base, path)
90 }
91
92 pub async fn get_repository(&self, owner: &str, repo: &str) -> Result<Repository> {
94 let url = self.api_url(&format!("/repos/{owner}/{repo}"));
95 let response = self
96 .client
97 .get(&url)
98 .send()
99 .await
100 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
101
102 if !response.status().is_success() {
103 return Err(AiError::GitHub(format!(
104 "GitHub API error: {} - {}",
105 response.status(),
106 response.text().await.unwrap_or_default()
107 )));
108 }
109
110 response
111 .json()
112 .await
113 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
114 }
115
116 pub async fn get_file_contents(
118 &self,
119 owner: &str,
120 repo: &str,
121 path: &str,
122 ref_name: Option<&str>,
123 ) -> Result<FileContents> {
124 let mut url = self.api_url(&format!("/repos/{owner}/{repo}/contents/{path}"));
125 if let Some(r) = ref_name {
126 url = format!("{url}?ref={r}");
127 }
128
129 let response = self
130 .client
131 .get(&url)
132 .send()
133 .await
134 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
135
136 if !response.status().is_success() {
137 return Err(AiError::GitHub(format!(
138 "GitHub API error: {} - {}",
139 response.status(),
140 response.text().await.unwrap_or_default()
141 )));
142 }
143
144 response
145 .json()
146 .await
147 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
148 }
149
150 pub async fn get_commit(&self, owner: &str, repo: &str, sha: &str) -> Result<Commit> {
152 let url = self.api_url(&format!("/repos/{owner}/{repo}/commits/{sha}"));
153 let response = self
154 .client
155 .get(&url)
156 .send()
157 .await
158 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
159
160 if !response.status().is_success() {
161 return Err(AiError::GitHub(format!(
162 "GitHub API error: {} - {}",
163 response.status(),
164 response.text().await.unwrap_or_default()
165 )));
166 }
167
168 response
169 .json()
170 .await
171 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
172 }
173
174 pub async fn get_commit_diff(&self, owner: &str, repo: &str, sha: &str) -> Result<String> {
176 let url = self.api_url(&format!("/repos/{owner}/{repo}/commits/{sha}"));
177 let response = self
178 .client
179 .get(&url)
180 .header(ACCEPT, "application/vnd.github.diff")
181 .send()
182 .await
183 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
184
185 if !response.status().is_success() {
186 return Err(AiError::GitHub(format!(
187 "GitHub API error: {}",
188 response.status()
189 )));
190 }
191
192 response
193 .text()
194 .await
195 .map_err(|e| AiError::GitHub(format!("Failed to get diff: {e}")))
196 }
197
198 pub async fn get_pull_request(
200 &self,
201 owner: &str,
202 repo: &str,
203 pr_number: u64,
204 ) -> Result<PullRequest> {
205 let url = self.api_url(&format!("/repos/{owner}/{repo}/pulls/{pr_number}"));
206 let response = self
207 .client
208 .get(&url)
209 .send()
210 .await
211 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
212
213 if !response.status().is_success() {
214 return Err(AiError::GitHub(format!(
215 "GitHub API error: {} - {}",
216 response.status(),
217 response.text().await.unwrap_or_default()
218 )));
219 }
220
221 response
222 .json()
223 .await
224 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
225 }
226
227 pub async fn get_pull_request_diff(
229 &self,
230 owner: &str,
231 repo: &str,
232 pr_number: u64,
233 ) -> Result<String> {
234 let url = self.api_url(&format!("/repos/{owner}/{repo}/pulls/{pr_number}"));
235 let response = self
236 .client
237 .get(&url)
238 .header(ACCEPT, "application/vnd.github.diff")
239 .send()
240 .await
241 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
242
243 if !response.status().is_success() {
244 return Err(AiError::GitHub(format!(
245 "GitHub API error: {}",
246 response.status()
247 )));
248 }
249
250 response
251 .text()
252 .await
253 .map_err(|e| AiError::GitHub(format!("Failed to get diff: {e}")))
254 }
255
256 pub async fn get_pull_request_files(
258 &self,
259 owner: &str,
260 repo: &str,
261 pr_number: u64,
262 ) -> Result<Vec<PullRequestFile>> {
263 let url = self.api_url(&format!("/repos/{owner}/{repo}/pulls/{pr_number}/files"));
264 let response = self
265 .client
266 .get(&url)
267 .send()
268 .await
269 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
270
271 if !response.status().is_success() {
272 return Err(AiError::GitHub(format!(
273 "GitHub API error: {}",
274 response.status()
275 )));
276 }
277
278 response
279 .json()
280 .await
281 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
282 }
283
284 pub async fn get_release_by_tag(&self, owner: &str, repo: &str, tag: &str) -> Result<Release> {
286 let url = self.api_url(&format!("/repos/{owner}/{repo}/releases/tags/{tag}"));
287 let response = self
288 .client
289 .get(&url)
290 .send()
291 .await
292 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
293
294 if !response.status().is_success() {
295 return Err(AiError::GitHub(format!(
296 "GitHub API error: {} - {}",
297 response.status(),
298 response.text().await.unwrap_or_default()
299 )));
300 }
301
302 response
303 .json()
304 .await
305 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
306 }
307
308 pub async fn get_latest_release(&self, owner: &str, repo: &str) -> Result<Release> {
310 let url = self.api_url(&format!("/repos/{owner}/{repo}/releases/latest"));
311 let response = self
312 .client
313 .get(&url)
314 .send()
315 .await
316 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
317
318 if !response.status().is_success() {
319 return Err(AiError::GitHub(format!(
320 "GitHub API error: {} - {}",
321 response.status(),
322 response.text().await.unwrap_or_default()
323 )));
324 }
325
326 response
327 .json()
328 .await
329 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
330 }
331
332 pub async fn get_issue(&self, owner: &str, repo: &str, issue_number: u64) -> Result<Issue> {
334 let url = self.api_url(&format!("/repos/{owner}/{repo}/issues/{issue_number}"));
335 let response = self
336 .client
337 .get(&url)
338 .send()
339 .await
340 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
341
342 if !response.status().is_success() {
343 return Err(AiError::GitHub(format!(
344 "GitHub API error: {} - {}",
345 response.status(),
346 response.text().await.unwrap_or_default()
347 )));
348 }
349
350 response
351 .json()
352 .await
353 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
354 }
355
356 pub async fn list_commits(
358 &self,
359 owner: &str,
360 repo: &str,
361 since: Option<&str>,
362 per_page: Option<u32>,
363 ) -> Result<Vec<CommitSummary>> {
364 let mut url = self.api_url(&format!("/repos/{owner}/{repo}/commits"));
365 let mut params = Vec::new();
366 if let Some(s) = since {
367 params.push(format!("since={s}"));
368 }
369 if let Some(p) = per_page {
370 params.push(format!("per_page={p}"));
371 }
372 if !params.is_empty() {
373 url = format!("{}?{}", url, params.join("&"));
374 }
375
376 let response = self
377 .client
378 .get(&url)
379 .send()
380 .await
381 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
382
383 if !response.status().is_success() {
384 return Err(AiError::GitHub(format!(
385 "GitHub API error: {}",
386 response.status()
387 )));
388 }
389
390 response
391 .json()
392 .await
393 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
394 }
395
396 pub async fn search_code(
398 &self,
399 query: &str,
400 per_page: Option<u32>,
401 ) -> Result<CodeSearchResult> {
402 let mut url = self.api_url(&format!("/search/code?q={}", urlencoding::encode(query)));
403 if let Some(p) = per_page {
404 url = format!("{url}&per_page={p}");
405 }
406
407 let response = self
408 .client
409 .get(&url)
410 .send()
411 .await
412 .map_err(|e| AiError::GitHub(format!("Request failed: {e}")))?;
413
414 if !response.status().is_success() {
415 return Err(AiError::GitHub(format!(
416 "GitHub API error: {} - {}",
417 response.status(),
418 response.text().await.unwrap_or_default()
419 )));
420 }
421
422 response
423 .json()
424 .await
425 .map_err(|e| AiError::GitHub(format!("Failed to parse response: {e}")))
426 }
427}
428
429#[derive(Debug, Clone, Deserialize, Serialize)]
433pub struct Repository {
434 pub id: u64,
435 pub name: String,
436 pub full_name: String,
437 pub description: Option<String>,
438 pub html_url: String,
439 pub clone_url: String,
440 pub default_branch: String,
441 pub stargazers_count: u32,
442 pub forks_count: u32,
443 pub language: Option<String>,
444 pub created_at: String,
445 pub updated_at: String,
446 pub pushed_at: Option<String>,
447}
448
449#[derive(Debug, Clone, Deserialize, Serialize)]
451pub struct FileContents {
452 pub name: String,
453 pub path: String,
454 pub sha: String,
455 pub size: u64,
456 pub url: String,
457 pub html_url: String,
458 pub download_url: Option<String>,
459 pub content: Option<String>,
460 pub encoding: Option<String>,
461 #[serde(rename = "type")]
462 pub file_type: String,
463}
464
465impl FileContents {
466 pub fn decode_content(&self) -> Result<String> {
468 if let Some(ref content) = self.content {
469 let clean_content: String = content.chars().filter(|c| !c.is_whitespace()).collect();
471 let decoded = base64_decode(&clean_content)?;
472 String::from_utf8(decoded)
473 .map_err(|e| AiError::GitHub(format!("Invalid UTF-8 content: {e}")))
474 } else {
475 Err(AiError::GitHub("No content available".to_string()))
476 }
477 }
478}
479
480fn base64_decode(input: &str) -> Result<Vec<u8>> {
481 const BASE64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
483
484 let mut output = Vec::new();
485 let mut buffer = 0u32;
486 let mut bits = 0u8;
487
488 for c in input.bytes() {
489 if c == b'=' {
490 break;
491 }
492
493 let value =
494 BASE64_CHARS.iter().position(|&x| x == c).ok_or_else(|| {
495 AiError::GitHub(format!("Invalid base64 character: {}", c as char))
496 })? as u32;
497
498 buffer = (buffer << 6) | value;
499 bits += 6;
500
501 if bits >= 8 {
502 bits -= 8;
503 output.push((buffer >> bits) as u8);
504 buffer &= (1 << bits) - 1;
505 }
506 }
507
508 Ok(output)
509}
510
511#[derive(Debug, Clone, Deserialize, Serialize)]
513pub struct Commit {
514 pub sha: String,
515 pub html_url: String,
516 pub commit: CommitData,
517 pub author: Option<GitHubUser>,
518 pub committer: Option<GitHubUser>,
519 pub stats: Option<CommitStats>,
520 pub files: Option<Vec<CommitFile>>,
521}
522
523#[derive(Debug, Clone, Deserialize, Serialize)]
525pub struct CommitSummary {
526 pub sha: String,
527 pub html_url: String,
528 pub commit: CommitData,
529 pub author: Option<GitHubUser>,
530 pub committer: Option<GitHubUser>,
531}
532
533#[derive(Debug, Clone, Deserialize, Serialize)]
535pub struct CommitData {
536 pub message: String,
537 pub author: GitAuthor,
538 pub committer: GitAuthor,
539}
540
541#[derive(Debug, Clone, Deserialize, Serialize)]
543pub struct GitAuthor {
544 pub name: String,
545 pub email: String,
546 pub date: String,
547}
548
549#[derive(Debug, Clone, Deserialize, Serialize)]
551pub struct CommitStats {
552 pub additions: u32,
553 pub deletions: u32,
554 pub total: u32,
555}
556
557#[derive(Debug, Clone, Deserialize, Serialize)]
559pub struct CommitFile {
560 pub filename: String,
561 pub status: String,
562 pub additions: u32,
563 pub deletions: u32,
564 pub changes: u32,
565 pub patch: Option<String>,
566}
567
568#[derive(Debug, Clone, Deserialize, Serialize)]
570pub struct GitHubUser {
571 pub login: String,
572 pub id: u64,
573 pub avatar_url: String,
574 pub html_url: String,
575}
576
577#[derive(Debug, Clone, Deserialize, Serialize)]
579pub struct PullRequest {
580 pub id: u64,
581 pub number: u64,
582 pub state: String,
583 pub title: String,
584 pub body: Option<String>,
585 pub html_url: String,
586 pub user: GitHubUser,
587 pub created_at: String,
588 pub updated_at: String,
589 pub closed_at: Option<String>,
590 pub merged_at: Option<String>,
591 pub merge_commit_sha: Option<String>,
592 pub head: PullRequestRef,
593 pub base: PullRequestRef,
594 pub additions: Option<u32>,
595 pub deletions: Option<u32>,
596 pub changed_files: Option<u32>,
597}
598
599impl PullRequest {
600 #[must_use]
601 pub fn is_merged(&self) -> bool {
602 self.merged_at.is_some()
603 }
604
605 #[must_use]
606 pub fn is_closed(&self) -> bool {
607 self.state == "closed"
608 }
609}
610
611#[derive(Debug, Clone, Deserialize, Serialize)]
613pub struct PullRequestRef {
614 pub label: String,
615 #[serde(rename = "ref")]
616 pub ref_name: String,
617 pub sha: String,
618}
619
620#[derive(Debug, Clone, Deserialize, Serialize)]
622pub struct PullRequestFile {
623 pub sha: String,
624 pub filename: String,
625 pub status: String,
626 pub additions: u32,
627 pub deletions: u32,
628 pub changes: u32,
629 pub patch: Option<String>,
630 pub raw_url: String,
631}
632
633#[derive(Debug, Clone, Deserialize, Serialize)]
635pub struct Release {
636 pub id: u64,
637 pub tag_name: String,
638 pub name: Option<String>,
639 pub body: Option<String>,
640 pub html_url: String,
641 pub draft: bool,
642 pub prerelease: bool,
643 pub created_at: String,
644 pub published_at: Option<String>,
645 pub author: GitHubUser,
646 pub assets: Vec<ReleaseAsset>,
647}
648
649#[derive(Debug, Clone, Deserialize, Serialize)]
651pub struct ReleaseAsset {
652 pub id: u64,
653 pub name: String,
654 pub size: u64,
655 pub download_count: u32,
656 pub browser_download_url: String,
657}
658
659#[derive(Debug, Clone, Deserialize, Serialize)]
661pub struct Issue {
662 pub id: u64,
663 pub number: u64,
664 pub state: String,
665 pub title: String,
666 pub body: Option<String>,
667 pub html_url: String,
668 pub user: GitHubUser,
669 pub labels: Vec<IssueLabel>,
670 pub created_at: String,
671 pub updated_at: String,
672 pub closed_at: Option<String>,
673}
674
675impl Issue {
676 #[must_use]
677 pub fn is_closed(&self) -> bool {
678 self.state == "closed"
679 }
680}
681
682#[derive(Debug, Clone, Deserialize, Serialize)]
684pub struct IssueLabel {
685 pub name: String,
686 pub color: String,
687}
688
689#[derive(Debug, Clone, Deserialize, Serialize)]
691pub struct CodeSearchResult {
692 pub total_count: u32,
693 pub incomplete_results: bool,
694 pub items: Vec<CodeSearchItem>,
695}
696
697#[derive(Debug, Clone, Deserialize, Serialize)]
699pub struct CodeSearchItem {
700 pub name: String,
701 pub path: String,
702 pub sha: String,
703 pub html_url: String,
704 pub repository: CodeSearchRepository,
705}
706
707#[derive(Debug, Clone, Deserialize, Serialize)]
709pub struct CodeSearchRepository {
710 pub id: u64,
711 pub name: String,
712 pub full_name: String,
713 pub html_url: String,
714}
715
716#[derive(Clone)]
720pub struct GitHubVerifier {
721 client: GitHubClient,
722}
723
724impl GitHubVerifier {
725 #[must_use]
726 pub fn new(client: GitHubClient) -> Self {
727 Self { client }
728 }
729
730 pub async fn verify_commit(
732 &self,
733 owner: &str,
734 repo: &str,
735 sha: &str,
736 ) -> Result<CommitVerification> {
737 let commit = self.client.get_commit(owner, repo, sha).await?;
738
739 Ok(CommitVerification {
740 exists: true,
741 sha: commit.sha,
742 message: commit.commit.message,
743 author: commit.commit.author.name,
744 date: commit.commit.author.date,
745 stats: commit.stats,
746 url: commit.html_url,
747 })
748 }
749
750 pub async fn verify_release(
752 &self,
753 owner: &str,
754 repo: &str,
755 tag: &str,
756 ) -> Result<ReleaseVerification> {
757 let release = self.client.get_release_by_tag(owner, repo, tag).await?;
758
759 Ok(ReleaseVerification {
760 exists: true,
761 tag: release.tag_name,
762 name: release.name,
763 body: release.body,
764 is_prerelease: release.prerelease,
765 is_draft: release.draft,
766 published_at: release.published_at,
767 assets_count: release.assets.len(),
768 url: release.html_url,
769 })
770 }
771
772 pub async fn verify_pr_merged(
774 &self,
775 owner: &str,
776 repo: &str,
777 pr_number: u64,
778 ) -> Result<PrVerification> {
779 let pr = self.client.get_pull_request(owner, repo, pr_number).await?;
780 let is_merged = pr.is_merged();
781
782 Ok(PrVerification {
783 exists: true,
784 number: pr.number,
785 title: pr.title,
786 state: pr.state,
787 is_merged,
788 merged_at: pr.merged_at,
789 author: pr.user.login,
790 additions: pr.additions,
791 deletions: pr.deletions,
792 url: pr.html_url,
793 })
794 }
795
796 pub async fn verify_issue_closed(
798 &self,
799 owner: &str,
800 repo: &str,
801 issue_number: u64,
802 ) -> Result<IssueVerification> {
803 let issue = self.client.get_issue(owner, repo, issue_number).await?;
804 let is_closed = issue.is_closed();
805
806 Ok(IssueVerification {
807 exists: true,
808 number: issue.number,
809 title: issue.title,
810 state: issue.state,
811 is_closed,
812 closed_at: issue.closed_at,
813 author: issue.user.login,
814 labels: issue.labels.into_iter().map(|l| l.name).collect(),
815 url: issue.html_url,
816 })
817 }
818
819 pub async fn verify_url(&self, url: &str) -> Result<GitHubVerificationResult> {
821 let parsed = parse_github_url(url)?;
822
823 match parsed {
824 ParsedGitHubUrl::Commit { owner, repo, sha } => {
825 let verification = self.verify_commit(&owner, &repo, &sha).await?;
826 Ok(GitHubVerificationResult::Commit(verification))
827 }
828 ParsedGitHubUrl::PullRequest {
829 owner,
830 repo,
831 number,
832 } => {
833 let verification = self.verify_pr_merged(&owner, &repo, number).await?;
834 Ok(GitHubVerificationResult::PullRequest(verification))
835 }
836 ParsedGitHubUrl::Release { owner, repo, tag } => {
837 let verification = self.verify_release(&owner, &repo, &tag).await?;
838 Ok(GitHubVerificationResult::Release(verification))
839 }
840 ParsedGitHubUrl::Issue {
841 owner,
842 repo,
843 number,
844 } => {
845 let verification = self.verify_issue_closed(&owner, &repo, number).await?;
846 Ok(GitHubVerificationResult::Issue(verification))
847 }
848 ParsedGitHubUrl::Repository { owner, repo } => {
849 let repository = self.client.get_repository(&owner, &repo).await?;
850 Ok(GitHubVerificationResult::Repository(repository))
851 }
852 }
853 }
854}
855
856#[derive(Debug, Clone)]
858pub enum ParsedGitHubUrl {
859 Commit {
860 owner: String,
861 repo: String,
862 sha: String,
863 },
864 PullRequest {
865 owner: String,
866 repo: String,
867 number: u64,
868 },
869 Release {
870 owner: String,
871 repo: String,
872 tag: String,
873 },
874 Issue {
875 owner: String,
876 repo: String,
877 number: u64,
878 },
879 Repository {
880 owner: String,
881 repo: String,
882 },
883}
884
885pub fn parse_github_url(url: &str) -> Result<ParsedGitHubUrl> {
887 let url = url.trim_end_matches('/');
889
890 if !url.contains("github.com") {
892 return Err(AiError::GitHub("Not a GitHub URL".to_string()));
893 }
894
895 let path = url
897 .split("github.com/")
898 .nth(1)
899 .ok_or_else(|| AiError::GitHub("Invalid GitHub URL format".to_string()))?;
900
901 let parts: Vec<&str> = path.split('/').collect();
902
903 if parts.len() < 2 {
904 return Err(AiError::GitHub(
905 "Invalid GitHub URL: missing owner/repo".to_string(),
906 ));
907 }
908
909 let owner = parts[0].to_string();
910 let repo = parts[1].to_string();
911
912 if parts.len() >= 4 {
914 match parts[2] {
915 "commit" | "commits" => {
916 let sha = parts[3].to_string();
917 return Ok(ParsedGitHubUrl::Commit { owner, repo, sha });
918 }
919 "pull" => {
920 let number = parts[3]
921 .parse()
922 .map_err(|_| AiError::GitHub("Invalid PR number".to_string()))?;
923 return Ok(ParsedGitHubUrl::PullRequest {
924 owner,
925 repo,
926 number,
927 });
928 }
929 "releases" if parts.len() >= 5 && parts[3] == "tag" => {
930 let tag = parts[4].to_string();
931 return Ok(ParsedGitHubUrl::Release { owner, repo, tag });
932 }
933 "issues" => {
934 let number = parts[3]
935 .parse()
936 .map_err(|_| AiError::GitHub("Invalid issue number".to_string()))?;
937 return Ok(ParsedGitHubUrl::Issue {
938 owner,
939 repo,
940 number,
941 });
942 }
943 _ => {}
944 }
945 }
946
947 Ok(ParsedGitHubUrl::Repository { owner, repo })
949}
950
951#[derive(Debug, Clone, Serialize)]
955pub struct CommitVerification {
956 pub exists: bool,
957 pub sha: String,
958 pub message: String,
959 pub author: String,
960 pub date: String,
961 pub stats: Option<CommitStats>,
962 pub url: String,
963}
964
965#[derive(Debug, Clone, Serialize)]
967pub struct ReleaseVerification {
968 pub exists: bool,
969 pub tag: String,
970 pub name: Option<String>,
971 pub body: Option<String>,
972 pub is_prerelease: bool,
973 pub is_draft: bool,
974 pub published_at: Option<String>,
975 pub assets_count: usize,
976 pub url: String,
977}
978
979#[derive(Debug, Clone, Serialize)]
981pub struct PrVerification {
982 pub exists: bool,
983 pub number: u64,
984 pub title: String,
985 pub state: String,
986 pub is_merged: bool,
987 pub merged_at: Option<String>,
988 pub author: String,
989 pub additions: Option<u32>,
990 pub deletions: Option<u32>,
991 pub url: String,
992}
993
994#[derive(Debug, Clone, Serialize)]
996pub struct IssueVerification {
997 pub exists: bool,
998 pub number: u64,
999 pub title: String,
1000 pub state: String,
1001 pub is_closed: bool,
1002 pub closed_at: Option<String>,
1003 pub author: String,
1004 pub labels: Vec<String>,
1005 pub url: String,
1006}
1007
1008#[derive(Debug, Clone, Serialize)]
1010pub enum GitHubVerificationResult {
1011 Commit(CommitVerification),
1012 PullRequest(PrVerification),
1013 Release(ReleaseVerification),
1014 Issue(IssueVerification),
1015 Repository(Repository),
1016}