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,
436 pub name: String,
438 pub full_name: String,
440 pub description: Option<String>,
442 pub html_url: String,
444 pub clone_url: String,
446 pub default_branch: String,
448 pub stargazers_count: u32,
450 pub forks_count: u32,
452 pub language: Option<String>,
454 pub created_at: String,
456 pub updated_at: String,
458 pub pushed_at: Option<String>,
460}
461
462#[derive(Debug, Clone, Deserialize, Serialize)]
464pub struct FileContents {
465 pub name: String,
467 pub path: String,
469 pub sha: String,
471 pub size: u64,
473 pub url: String,
475 pub html_url: String,
477 pub download_url: Option<String>,
479 pub content: Option<String>,
481 pub encoding: Option<String>,
483 #[serde(rename = "type")]
485 pub file_type: String,
486}
487
488impl FileContents {
489 pub fn decode_content(&self) -> Result<String> {
491 if let Some(ref content) = self.content {
492 let clean_content: String = content.chars().filter(|c| !c.is_whitespace()).collect();
494 let decoded = base64_decode(&clean_content)?;
495 String::from_utf8(decoded)
496 .map_err(|e| AiError::GitHub(format!("Invalid UTF-8 content: {e}")))
497 } else {
498 Err(AiError::GitHub("No content available".to_string()))
499 }
500 }
501}
502
503fn base64_decode(input: &str) -> Result<Vec<u8>> {
504 const BASE64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
506
507 let mut output = Vec::new();
508 let mut buffer = 0u32;
509 let mut bits = 0u8;
510
511 for c in input.bytes() {
512 if c == b'=' {
513 break;
514 }
515
516 let value =
517 BASE64_CHARS.iter().position(|&x| x == c).ok_or_else(|| {
518 AiError::GitHub(format!("Invalid base64 character: {}", c as char))
519 })? as u32;
520
521 buffer = (buffer << 6) | value;
522 bits += 6;
523
524 if bits >= 8 {
525 bits -= 8;
526 output.push((buffer >> bits) as u8);
527 buffer &= (1 << bits) - 1;
528 }
529 }
530
531 Ok(output)
532}
533
534#[derive(Debug, Clone, Deserialize, Serialize)]
536pub struct Commit {
537 pub sha: String,
539 pub html_url: String,
541 pub commit: CommitData,
543 pub author: Option<GitHubUser>,
545 pub committer: Option<GitHubUser>,
547 pub stats: Option<CommitStats>,
549 pub files: Option<Vec<CommitFile>>,
551}
552
553#[derive(Debug, Clone, Deserialize, Serialize)]
555pub struct CommitSummary {
556 pub sha: String,
558 pub html_url: String,
560 pub commit: CommitData,
562 pub author: Option<GitHubUser>,
564 pub committer: Option<GitHubUser>,
566}
567
568#[derive(Debug, Clone, Deserialize, Serialize)]
570pub struct CommitData {
571 pub message: String,
573 pub author: GitAuthor,
575 pub committer: GitAuthor,
577}
578
579#[derive(Debug, Clone, Deserialize, Serialize)]
581pub struct GitAuthor {
582 pub name: String,
584 pub email: String,
586 pub date: String,
588}
589
590#[derive(Debug, Clone, Deserialize, Serialize)]
592pub struct CommitStats {
593 pub additions: u32,
595 pub deletions: u32,
597 pub total: u32,
599}
600
601#[derive(Debug, Clone, Deserialize, Serialize)]
603pub struct CommitFile {
604 pub filename: String,
606 pub status: String,
608 pub additions: u32,
610 pub deletions: u32,
612 pub changes: u32,
614 pub patch: Option<String>,
616}
617
618#[derive(Debug, Clone, Deserialize, Serialize)]
620pub struct GitHubUser {
621 pub login: String,
623 pub id: u64,
625 pub avatar_url: String,
627 pub html_url: String,
629}
630
631#[derive(Debug, Clone, Deserialize, Serialize)]
633pub struct PullRequest {
634 pub id: u64,
636 pub number: u64,
638 pub state: String,
640 pub title: String,
642 pub body: Option<String>,
644 pub html_url: String,
646 pub user: GitHubUser,
648 pub created_at: String,
650 pub updated_at: String,
652 pub closed_at: Option<String>,
654 pub merged_at: Option<String>,
656 pub merge_commit_sha: Option<String>,
658 pub head: PullRequestRef,
660 pub base: PullRequestRef,
662 pub additions: Option<u32>,
664 pub deletions: Option<u32>,
666 pub changed_files: Option<u32>,
668}
669
670impl PullRequest {
671 #[must_use]
673 pub fn is_merged(&self) -> bool {
674 self.merged_at.is_some()
675 }
676
677 #[must_use]
679 pub fn is_closed(&self) -> bool {
680 self.state == "closed"
681 }
682}
683
684#[derive(Debug, Clone, Deserialize, Serialize)]
686pub struct PullRequestRef {
687 pub label: String,
689 #[serde(rename = "ref")]
691 pub ref_name: String,
692 pub sha: String,
694}
695
696#[derive(Debug, Clone, Deserialize, Serialize)]
698pub struct PullRequestFile {
699 pub sha: String,
701 pub filename: String,
703 pub status: String,
705 pub additions: u32,
707 pub deletions: u32,
709 pub changes: u32,
711 pub patch: Option<String>,
713 pub raw_url: String,
715}
716
717#[derive(Debug, Clone, Deserialize, Serialize)]
719pub struct Release {
720 pub id: u64,
722 pub tag_name: String,
724 pub name: Option<String>,
726 pub body: Option<String>,
728 pub html_url: String,
730 pub draft: bool,
732 pub prerelease: bool,
734 pub created_at: String,
736 pub published_at: Option<String>,
738 pub author: GitHubUser,
740 pub assets: Vec<ReleaseAsset>,
742}
743
744#[derive(Debug, Clone, Deserialize, Serialize)]
746pub struct ReleaseAsset {
747 pub id: u64,
749 pub name: String,
751 pub size: u64,
753 pub download_count: u32,
755 pub browser_download_url: String,
757}
758
759#[derive(Debug, Clone, Deserialize, Serialize)]
761pub struct Issue {
762 pub id: u64,
764 pub number: u64,
766 pub state: String,
768 pub title: String,
770 pub body: Option<String>,
772 pub html_url: String,
774 pub user: GitHubUser,
776 pub labels: Vec<IssueLabel>,
778 pub created_at: String,
780 pub updated_at: String,
782 pub closed_at: Option<String>,
784}
785
786impl Issue {
787 #[must_use]
789 pub fn is_closed(&self) -> bool {
790 self.state == "closed"
791 }
792}
793
794#[derive(Debug, Clone, Deserialize, Serialize)]
796pub struct IssueLabel {
797 pub name: String,
799 pub color: String,
801}
802
803#[derive(Debug, Clone, Deserialize, Serialize)]
805pub struct CodeSearchResult {
806 pub total_count: u32,
808 pub incomplete_results: bool,
810 pub items: Vec<CodeSearchItem>,
812}
813
814#[derive(Debug, Clone, Deserialize, Serialize)]
816pub struct CodeSearchItem {
817 pub name: String,
819 pub path: String,
821 pub sha: String,
823 pub html_url: String,
825 pub repository: CodeSearchRepository,
827}
828
829#[derive(Debug, Clone, Deserialize, Serialize)]
831pub struct CodeSearchRepository {
832 pub id: u64,
834 pub name: String,
836 pub full_name: String,
838 pub html_url: String,
840}
841
842#[derive(Clone)]
846pub struct GitHubVerifier {
847 client: GitHubClient,
848}
849
850impl GitHubVerifier {
851 #[must_use]
853 pub fn new(client: GitHubClient) -> Self {
854 Self { client }
855 }
856
857 pub async fn verify_commit(
859 &self,
860 owner: &str,
861 repo: &str,
862 sha: &str,
863 ) -> Result<CommitVerification> {
864 let commit = self.client.get_commit(owner, repo, sha).await?;
865
866 Ok(CommitVerification {
867 exists: true,
868 sha: commit.sha,
869 message: commit.commit.message,
870 author: commit.commit.author.name,
871 date: commit.commit.author.date,
872 stats: commit.stats,
873 url: commit.html_url,
874 })
875 }
876
877 pub async fn verify_release(
879 &self,
880 owner: &str,
881 repo: &str,
882 tag: &str,
883 ) -> Result<ReleaseVerification> {
884 let release = self.client.get_release_by_tag(owner, repo, tag).await?;
885
886 Ok(ReleaseVerification {
887 exists: true,
888 tag: release.tag_name,
889 name: release.name,
890 body: release.body,
891 is_prerelease: release.prerelease,
892 is_draft: release.draft,
893 published_at: release.published_at,
894 assets_count: release.assets.len(),
895 url: release.html_url,
896 })
897 }
898
899 pub async fn verify_pr_merged(
901 &self,
902 owner: &str,
903 repo: &str,
904 pr_number: u64,
905 ) -> Result<PrVerification> {
906 let pr = self.client.get_pull_request(owner, repo, pr_number).await?;
907 let is_merged = pr.is_merged();
908
909 Ok(PrVerification {
910 exists: true,
911 number: pr.number,
912 title: pr.title,
913 state: pr.state,
914 is_merged,
915 merged_at: pr.merged_at,
916 author: pr.user.login,
917 additions: pr.additions,
918 deletions: pr.deletions,
919 url: pr.html_url,
920 })
921 }
922
923 pub async fn verify_issue_closed(
925 &self,
926 owner: &str,
927 repo: &str,
928 issue_number: u64,
929 ) -> Result<IssueVerification> {
930 let issue = self.client.get_issue(owner, repo, issue_number).await?;
931 let is_closed = issue.is_closed();
932
933 Ok(IssueVerification {
934 exists: true,
935 number: issue.number,
936 title: issue.title,
937 state: issue.state,
938 is_closed,
939 closed_at: issue.closed_at,
940 author: issue.user.login,
941 labels: issue.labels.into_iter().map(|l| l.name).collect(),
942 url: issue.html_url,
943 })
944 }
945
946 pub async fn verify_url(&self, url: &str) -> Result<GitHubVerificationResult> {
948 let parsed = parse_github_url(url)?;
949
950 match parsed {
951 ParsedGitHubUrl::Commit { owner, repo, sha } => {
952 let verification = self.verify_commit(&owner, &repo, &sha).await?;
953 Ok(GitHubVerificationResult::Commit(verification))
954 }
955 ParsedGitHubUrl::PullRequest {
956 owner,
957 repo,
958 number,
959 } => {
960 let verification = self.verify_pr_merged(&owner, &repo, number).await?;
961 Ok(GitHubVerificationResult::PullRequest(verification))
962 }
963 ParsedGitHubUrl::Release { owner, repo, tag } => {
964 let verification = self.verify_release(&owner, &repo, &tag).await?;
965 Ok(GitHubVerificationResult::Release(verification))
966 }
967 ParsedGitHubUrl::Issue {
968 owner,
969 repo,
970 number,
971 } => {
972 let verification = self.verify_issue_closed(&owner, &repo, number).await?;
973 Ok(GitHubVerificationResult::Issue(verification))
974 }
975 ParsedGitHubUrl::Repository { owner, repo } => {
976 let repository = self.client.get_repository(&owner, &repo).await?;
977 Ok(GitHubVerificationResult::Repository(repository))
978 }
979 }
980 }
981}
982
983#[derive(Debug, Clone)]
985pub enum ParsedGitHubUrl {
986 Commit {
988 owner: String,
990 repo: String,
992 sha: String,
994 },
995 PullRequest {
997 owner: String,
999 repo: String,
1001 number: u64,
1003 },
1004 Release {
1006 owner: String,
1008 repo: String,
1010 tag: String,
1012 },
1013 Issue {
1015 owner: String,
1017 repo: String,
1019 number: u64,
1021 },
1022 Repository {
1024 owner: String,
1026 repo: String,
1028 },
1029}
1030
1031pub fn parse_github_url(url: &str) -> Result<ParsedGitHubUrl> {
1033 let url = url.trim_end_matches('/');
1035
1036 if !url.contains("github.com") {
1038 return Err(AiError::GitHub("Not a GitHub URL".to_string()));
1039 }
1040
1041 let path = url
1043 .split("github.com/")
1044 .nth(1)
1045 .ok_or_else(|| AiError::GitHub("Invalid GitHub URL format".to_string()))?;
1046
1047 let parts: Vec<&str> = path.split('/').collect();
1048
1049 if parts.len() < 2 {
1050 return Err(AiError::GitHub(
1051 "Invalid GitHub URL: missing owner/repo".to_string(),
1052 ));
1053 }
1054
1055 let owner = parts[0].to_string();
1056 let repo = parts[1].to_string();
1057
1058 if parts.len() >= 4 {
1060 match parts[2] {
1061 "commit" | "commits" => {
1062 let sha = parts[3].to_string();
1063 return Ok(ParsedGitHubUrl::Commit { owner, repo, sha });
1064 }
1065 "pull" => {
1066 let number = parts[3]
1067 .parse()
1068 .map_err(|_| AiError::GitHub("Invalid PR number".to_string()))?;
1069 return Ok(ParsedGitHubUrl::PullRequest {
1070 owner,
1071 repo,
1072 number,
1073 });
1074 }
1075 "releases" if parts.len() >= 5 && parts[3] == "tag" => {
1076 let tag = parts[4].to_string();
1077 return Ok(ParsedGitHubUrl::Release { owner, repo, tag });
1078 }
1079 "issues" => {
1080 let number = parts[3]
1081 .parse()
1082 .map_err(|_| AiError::GitHub("Invalid issue number".to_string()))?;
1083 return Ok(ParsedGitHubUrl::Issue {
1084 owner,
1085 repo,
1086 number,
1087 });
1088 }
1089 _ => {}
1090 }
1091 }
1092
1093 Ok(ParsedGitHubUrl::Repository { owner, repo })
1095}
1096
1097#[derive(Debug, Clone, Serialize)]
1101pub struct CommitVerification {
1102 pub exists: bool,
1104 pub sha: String,
1106 pub message: String,
1108 pub author: String,
1110 pub date: String,
1112 pub stats: Option<CommitStats>,
1114 pub url: String,
1116}
1117
1118#[derive(Debug, Clone, Serialize)]
1120pub struct ReleaseVerification {
1121 pub exists: bool,
1123 pub tag: String,
1125 pub name: Option<String>,
1127 pub body: Option<String>,
1129 pub is_prerelease: bool,
1131 pub is_draft: bool,
1133 pub published_at: Option<String>,
1135 pub assets_count: usize,
1137 pub url: String,
1139}
1140
1141#[derive(Debug, Clone, Serialize)]
1143pub struct PrVerification {
1144 pub exists: bool,
1146 pub number: u64,
1148 pub title: String,
1150 pub state: String,
1152 pub is_merged: bool,
1154 pub merged_at: Option<String>,
1156 pub author: String,
1158 pub additions: Option<u32>,
1160 pub deletions: Option<u32>,
1162 pub url: String,
1164}
1165
1166#[derive(Debug, Clone, Serialize)]
1168pub struct IssueVerification {
1169 pub exists: bool,
1171 pub number: u64,
1173 pub title: String,
1175 pub state: String,
1177 pub is_closed: bool,
1179 pub closed_at: Option<String>,
1181 pub author: String,
1183 pub labels: Vec<String>,
1185 pub url: String,
1187}
1188
1189#[derive(Debug, Clone, Serialize)]
1191pub enum GitHubVerificationResult {
1192 Commit(CommitVerification),
1194 PullRequest(PrVerification),
1196 Release(ReleaseVerification),
1198 Issue(IssueVerification),
1200 Repository(Repository),
1202}