kaccy_ai/
github.rs

1//! GitHub API integration for code verification
2//!
3//! Provides functionality to:
4//! - Fetch code from repositories
5//! - Analyze commit diffs
6//! - Verify releases, commits, and PRs
7//! - Automate PR reviews
8
9use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
10use serde::{Deserialize, Serialize};
11
12use crate::error::{AiError, Result};
13
14/// GitHub API client
15#[derive(Clone)]
16pub struct GitHubClient {
17    client: reqwest::Client,
18    config: GitHubConfig,
19}
20
21/// GitHub client configuration
22#[derive(Debug, Clone)]
23pub struct GitHubConfig {
24    /// Personal access token for authentication
25    pub token: Option<String>,
26    /// API base URL (default: <https://api.github.com>)
27    pub api_base: String,
28    /// Request timeout in seconds
29    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    /// Create from environment variables
44    #[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    /// Set token
53    #[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    /// Create a new GitHub client
62    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    /// Build full API URL
88    fn api_url(&self, path: &str) -> String {
89        format!("{}{}", self.config.api_base, path)
90    }
91
92    /// Get repository information
93    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    /// Get file contents from a repository
117    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    /// Get a specific commit
151    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    /// Get commit diff
175    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    /// Get pull request details
199    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    /// Get pull request diff
228    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    /// Get pull request files
257    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    /// Get a release by tag
285    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    /// Get latest release
309    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    /// Get issue details
333    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    /// List recent commits
357    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    /// Search code in repositories
397    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// ============== Data Types ==============
430
431/// Repository information
432#[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/// File contents from repository
450#[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    /// Decode base64 content
467    pub fn decode_content(&self) -> Result<String> {
468        if let Some(ref content) = self.content {
469            // Remove newlines from base64 content
470            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    // Simple base64 decoder
482    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/// Commit information
512#[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/// Commit summary (from list endpoint)
524#[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/// Commit data
534#[derive(Debug, Clone, Deserialize, Serialize)]
535pub struct CommitData {
536    pub message: String,
537    pub author: GitAuthor,
538    pub committer: GitAuthor,
539}
540
541/// Git author
542#[derive(Debug, Clone, Deserialize, Serialize)]
543pub struct GitAuthor {
544    pub name: String,
545    pub email: String,
546    pub date: String,
547}
548
549/// Commit statistics
550#[derive(Debug, Clone, Deserialize, Serialize)]
551pub struct CommitStats {
552    pub additions: u32,
553    pub deletions: u32,
554    pub total: u32,
555}
556
557/// File changed in a commit
558#[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/// GitHub user
569#[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/// Pull request information
578#[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/// Pull request reference (head/base)
612#[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/// File in a pull request
621#[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/// Release information
634#[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/// Release asset
650#[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/// Issue information
660#[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/// Issue label
683#[derive(Debug, Clone, Deserialize, Serialize)]
684pub struct IssueLabel {
685    pub name: String,
686    pub color: String,
687}
688
689/// Code search result
690#[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/// Code search item
698#[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/// Repository in code search
708#[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// ============== Verification Helpers ==============
717
718/// GitHub verification service
719#[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    /// Verify a commit exists and get details
731    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    /// Verify a release exists
751    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    /// Verify a PR is merged
773    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    /// Verify an issue is closed
797    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    /// Parse a GitHub URL and verify the resource
820    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/// Parsed GitHub URL
857#[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
885/// Parse a GitHub URL into its components
886pub fn parse_github_url(url: &str) -> Result<ParsedGitHubUrl> {
887    // Remove trailing slash and normalize
888    let url = url.trim_end_matches('/');
889
890    // Check for github.com
891    if !url.contains("github.com") {
892        return Err(AiError::GitHub("Not a GitHub URL".to_string()));
893    }
894
895    // Extract path parts
896    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    // Parse specific resource type
913    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    // Default to repository
948    Ok(ParsedGitHubUrl::Repository { owner, repo })
949}
950
951// ============== Verification Results ==============
952
953/// Commit verification result
954#[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/// Release verification result
966#[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/// PR verification result
980#[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/// Issue verification result
995#[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/// GitHub verification result enum
1009#[derive(Debug, Clone, Serialize)]
1010pub enum GitHubVerificationResult {
1011    Commit(CommitVerification),
1012    PullRequest(PrVerification),
1013    Release(ReleaseVerification),
1014    Issue(IssueVerification),
1015    Repository(Repository),
1016}