Skip to main content

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    /// Numeric repository ID.
435    pub id: u64,
436    /// Short repository name.
437    pub name: String,
438    /// Full `owner/name` identifier.
439    pub full_name: String,
440    /// Optional repository description.
441    pub description: Option<String>,
442    /// URL to view the repository on GitHub.
443    pub html_url: String,
444    /// Git clone URL.
445    pub clone_url: String,
446    /// Default branch name.
447    pub default_branch: String,
448    /// Number of stars.
449    pub stargazers_count: u32,
450    /// Number of forks.
451    pub forks_count: u32,
452    /// Primary programming language.
453    pub language: Option<String>,
454    /// ISO 8601 creation timestamp.
455    pub created_at: String,
456    /// ISO 8601 last-updated timestamp.
457    pub updated_at: String,
458    /// ISO 8601 last-push timestamp.
459    pub pushed_at: Option<String>,
460}
461
462/// File contents from repository
463#[derive(Debug, Clone, Deserialize, Serialize)]
464pub struct FileContents {
465    /// File name.
466    pub name: String,
467    /// Path within the repository.
468    pub path: String,
469    /// Git blob SHA.
470    pub sha: String,
471    /// File size in bytes.
472    pub size: u64,
473    /// API URL for this file.
474    pub url: String,
475    /// HTML URL to view the file.
476    pub html_url: String,
477    /// Direct download URL.
478    pub download_url: Option<String>,
479    /// Base64-encoded file content (if available).
480    pub content: Option<String>,
481    /// Content encoding (usually `"base64"`).
482    pub encoding: Option<String>,
483    /// Object type (e.g., `"file"` or `"dir"`).
484    #[serde(rename = "type")]
485    pub file_type: String,
486}
487
488impl FileContents {
489    /// Decode base64 content
490    pub fn decode_content(&self) -> Result<String> {
491        if let Some(ref content) = self.content {
492            // Remove newlines from base64 content
493            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    // Simple base64 decoder
505    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/// Commit information
535#[derive(Debug, Clone, Deserialize, Serialize)]
536pub struct Commit {
537    /// Full commit SHA.
538    pub sha: String,
539    /// HTML URL to view the commit.
540    pub html_url: String,
541    /// Core commit metadata.
542    pub commit: CommitData,
543    /// GitHub user who authored the commit.
544    pub author: Option<GitHubUser>,
545    /// GitHub user who committed.
546    pub committer: Option<GitHubUser>,
547    /// Line-change statistics.
548    pub stats: Option<CommitStats>,
549    /// Files changed in this commit.
550    pub files: Option<Vec<CommitFile>>,
551}
552
553/// Commit summary (from list endpoint)
554#[derive(Debug, Clone, Deserialize, Serialize)]
555pub struct CommitSummary {
556    /// Full commit SHA.
557    pub sha: String,
558    /// HTML URL to view the commit.
559    pub html_url: String,
560    /// Core commit metadata.
561    pub commit: CommitData,
562    /// GitHub user who authored the commit.
563    pub author: Option<GitHubUser>,
564    /// GitHub user who committed.
565    pub committer: Option<GitHubUser>,
566}
567
568/// Commit data
569#[derive(Debug, Clone, Deserialize, Serialize)]
570pub struct CommitData {
571    /// Commit message.
572    pub message: String,
573    /// Git author information.
574    pub author: GitAuthor,
575    /// Git committer information.
576    pub committer: GitAuthor,
577}
578
579/// Git author
580#[derive(Debug, Clone, Deserialize, Serialize)]
581pub struct GitAuthor {
582    /// Author name.
583    pub name: String,
584    /// Author email.
585    pub email: String,
586    /// ISO 8601 timestamp.
587    pub date: String,
588}
589
590/// Commit statistics
591#[derive(Debug, Clone, Deserialize, Serialize)]
592pub struct CommitStats {
593    /// Lines added.
594    pub additions: u32,
595    /// Lines deleted.
596    pub deletions: u32,
597    /// Total lines changed.
598    pub total: u32,
599}
600
601/// File changed in a commit
602#[derive(Debug, Clone, Deserialize, Serialize)]
603pub struct CommitFile {
604    /// File path within the repository.
605    pub filename: String,
606    /// Change status (e.g., `"added"`, `"modified"`, `"removed"`).
607    pub status: String,
608    /// Lines added.
609    pub additions: u32,
610    /// Lines deleted.
611    pub deletions: u32,
612    /// Total lines changed.
613    pub changes: u32,
614    /// Unified diff patch.
615    pub patch: Option<String>,
616}
617
618/// GitHub user
619#[derive(Debug, Clone, Deserialize, Serialize)]
620pub struct GitHubUser {
621    /// GitHub username.
622    pub login: String,
623    /// Numeric user ID.
624    pub id: u64,
625    /// Avatar image URL.
626    pub avatar_url: String,
627    /// Profile page URL.
628    pub html_url: String,
629}
630
631/// Pull request information
632#[derive(Debug, Clone, Deserialize, Serialize)]
633pub struct PullRequest {
634    /// Numeric PR ID.
635    pub id: u64,
636    /// PR number within the repository.
637    pub number: u64,
638    /// PR state (`"open"` or `"closed"`).
639    pub state: String,
640    /// PR title.
641    pub title: String,
642    /// PR body text.
643    pub body: Option<String>,
644    /// HTML URL to view the PR.
645    pub html_url: String,
646    /// User who opened the PR.
647    pub user: GitHubUser,
648    /// ISO 8601 creation timestamp.
649    pub created_at: String,
650    /// ISO 8601 last-update timestamp.
651    pub updated_at: String,
652    /// ISO 8601 close timestamp.
653    pub closed_at: Option<String>,
654    /// ISO 8601 merge timestamp.
655    pub merged_at: Option<String>,
656    /// Merge commit SHA.
657    pub merge_commit_sha: Option<String>,
658    /// Head branch reference.
659    pub head: PullRequestRef,
660    /// Base branch reference.
661    pub base: PullRequestRef,
662    /// Lines added.
663    pub additions: Option<u32>,
664    /// Lines deleted.
665    pub deletions: Option<u32>,
666    /// Number of changed files.
667    pub changed_files: Option<u32>,
668}
669
670impl PullRequest {
671    /// Returns `true` if the PR has been merged.
672    #[must_use]
673    pub fn is_merged(&self) -> bool {
674        self.merged_at.is_some()
675    }
676
677    /// Returns `true` if the PR is closed (merged or rejected).
678    #[must_use]
679    pub fn is_closed(&self) -> bool {
680        self.state == "closed"
681    }
682}
683
684/// Pull request reference (head/base)
685#[derive(Debug, Clone, Deserialize, Serialize)]
686pub struct PullRequestRef {
687    /// Full label (`owner:branch`).
688    pub label: String,
689    /// Branch name.
690    #[serde(rename = "ref")]
691    pub ref_name: String,
692    /// Tip commit SHA.
693    pub sha: String,
694}
695
696/// File in a pull request
697#[derive(Debug, Clone, Deserialize, Serialize)]
698pub struct PullRequestFile {
699    /// Blob SHA.
700    pub sha: String,
701    /// File path within the repository.
702    pub filename: String,
703    /// Change status (e.g., `"added"`, `"modified"`, `"removed"`).
704    pub status: String,
705    /// Lines added.
706    pub additions: u32,
707    /// Lines deleted.
708    pub deletions: u32,
709    /// Total lines changed.
710    pub changes: u32,
711    /// Unified diff patch.
712    pub patch: Option<String>,
713    /// Raw file URL.
714    pub raw_url: String,
715}
716
717/// Release information
718#[derive(Debug, Clone, Deserialize, Serialize)]
719pub struct Release {
720    /// Numeric release ID.
721    pub id: u64,
722    /// Git tag name.
723    pub tag_name: String,
724    /// Human-readable release name.
725    pub name: Option<String>,
726    /// Release body (changelog).
727    pub body: Option<String>,
728    /// HTML URL to view the release.
729    pub html_url: String,
730    /// Whether this is a draft release.
731    pub draft: bool,
732    /// Whether this is a pre-release.
733    pub prerelease: bool,
734    /// ISO 8601 creation timestamp.
735    pub created_at: String,
736    /// ISO 8601 publication timestamp.
737    pub published_at: Option<String>,
738    /// User who created the release.
739    pub author: GitHubUser,
740    /// Attached release assets.
741    pub assets: Vec<ReleaseAsset>,
742}
743
744/// Release asset
745#[derive(Debug, Clone, Deserialize, Serialize)]
746pub struct ReleaseAsset {
747    /// Numeric asset ID.
748    pub id: u64,
749    /// Asset file name.
750    pub name: String,
751    /// Asset size in bytes.
752    pub size: u64,
753    /// Number of times the asset was downloaded.
754    pub download_count: u32,
755    /// Direct browser download URL.
756    pub browser_download_url: String,
757}
758
759/// Issue information
760#[derive(Debug, Clone, Deserialize, Serialize)]
761pub struct Issue {
762    /// Numeric issue ID.
763    pub id: u64,
764    /// Issue number within the repository.
765    pub number: u64,
766    /// Issue state (`"open"` or `"closed"`).
767    pub state: String,
768    /// Issue title.
769    pub title: String,
770    /// Issue body text.
771    pub body: Option<String>,
772    /// HTML URL to view the issue.
773    pub html_url: String,
774    /// User who opened the issue.
775    pub user: GitHubUser,
776    /// Labels applied to the issue.
777    pub labels: Vec<IssueLabel>,
778    /// ISO 8601 creation timestamp.
779    pub created_at: String,
780    /// ISO 8601 last-update timestamp.
781    pub updated_at: String,
782    /// ISO 8601 close timestamp.
783    pub closed_at: Option<String>,
784}
785
786impl Issue {
787    /// Returns `true` if the issue is closed.
788    #[must_use]
789    pub fn is_closed(&self) -> bool {
790        self.state == "closed"
791    }
792}
793
794/// Issue label
795#[derive(Debug, Clone, Deserialize, Serialize)]
796pub struct IssueLabel {
797    /// Label name.
798    pub name: String,
799    /// Hex colour code (without `#`).
800    pub color: String,
801}
802
803/// Code search result
804#[derive(Debug, Clone, Deserialize, Serialize)]
805pub struct CodeSearchResult {
806    /// Total number of matching results.
807    pub total_count: u32,
808    /// Whether the result set is incomplete.
809    pub incomplete_results: bool,
810    /// Matching items.
811    pub items: Vec<CodeSearchItem>,
812}
813
814/// Code search item
815#[derive(Debug, Clone, Deserialize, Serialize)]
816pub struct CodeSearchItem {
817    /// File name.
818    pub name: String,
819    /// File path within the repository.
820    pub path: String,
821    /// Blob SHA.
822    pub sha: String,
823    /// HTML URL to view the file.
824    pub html_url: String,
825    /// Repository containing the file.
826    pub repository: CodeSearchRepository,
827}
828
829/// Repository in code search
830#[derive(Debug, Clone, Deserialize, Serialize)]
831pub struct CodeSearchRepository {
832    /// Numeric repository ID.
833    pub id: u64,
834    /// Short repository name.
835    pub name: String,
836    /// Full `owner/name` identifier.
837    pub full_name: String,
838    /// HTML URL to view the repository.
839    pub html_url: String,
840}
841
842// ============== Verification Helpers ==============
843
844/// GitHub verification service
845#[derive(Clone)]
846pub struct GitHubVerifier {
847    client: GitHubClient,
848}
849
850impl GitHubVerifier {
851    /// Create a new `GitHubVerifier` backed by the given client.
852    #[must_use]
853    pub fn new(client: GitHubClient) -> Self {
854        Self { client }
855    }
856
857    /// Verify a commit exists and get details
858    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    /// Verify a release exists
878    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    /// Verify a PR is merged
900    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    /// Verify an issue is closed
924    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    /// Parse a GitHub URL and verify the resource
947    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/// Parsed GitHub URL
984#[derive(Debug, Clone)]
985pub enum ParsedGitHubUrl {
986    /// A commit URL.
987    Commit {
988        /// Repository owner.
989        owner: String,
990        /// Repository name.
991        repo: String,
992        /// Commit SHA.
993        sha: String,
994    },
995    /// A pull request URL.
996    PullRequest {
997        /// Repository owner.
998        owner: String,
999        /// Repository name.
1000        repo: String,
1001        /// Pull request number.
1002        number: u64,
1003    },
1004    /// A release URL.
1005    Release {
1006        /// Repository owner.
1007        owner: String,
1008        /// Repository name.
1009        repo: String,
1010        /// Release tag.
1011        tag: String,
1012    },
1013    /// An issue URL.
1014    Issue {
1015        /// Repository owner.
1016        owner: String,
1017        /// Repository name.
1018        repo: String,
1019        /// Issue number.
1020        number: u64,
1021    },
1022    /// A repository URL.
1023    Repository {
1024        /// Repository owner.
1025        owner: String,
1026        /// Repository name.
1027        repo: String,
1028    },
1029}
1030
1031/// Parse a GitHub URL into its components
1032pub fn parse_github_url(url: &str) -> Result<ParsedGitHubUrl> {
1033    // Remove trailing slash and normalize
1034    let url = url.trim_end_matches('/');
1035
1036    // Check for github.com
1037    if !url.contains("github.com") {
1038        return Err(AiError::GitHub("Not a GitHub URL".to_string()));
1039    }
1040
1041    // Extract path parts
1042    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    // Parse specific resource type
1059    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    // Default to repository
1094    Ok(ParsedGitHubUrl::Repository { owner, repo })
1095}
1096
1097// ============== Verification Results ==============
1098
1099/// Commit verification result
1100#[derive(Debug, Clone, Serialize)]
1101pub struct CommitVerification {
1102    /// Whether the commit was found.
1103    pub exists: bool,
1104    /// Full commit SHA.
1105    pub sha: String,
1106    /// Commit message.
1107    pub message: String,
1108    /// Commit author name.
1109    pub author: String,
1110    /// ISO 8601 commit date.
1111    pub date: String,
1112    /// Line-change statistics.
1113    pub stats: Option<CommitStats>,
1114    /// HTML URL to view the commit.
1115    pub url: String,
1116}
1117
1118/// Release verification result
1119#[derive(Debug, Clone, Serialize)]
1120pub struct ReleaseVerification {
1121    /// Whether the release was found.
1122    pub exists: bool,
1123    /// Release tag name.
1124    pub tag: String,
1125    /// Human-readable release name.
1126    pub name: Option<String>,
1127    /// Release body text.
1128    pub body: Option<String>,
1129    /// Whether this is a pre-release.
1130    pub is_prerelease: bool,
1131    /// Whether this is a draft.
1132    pub is_draft: bool,
1133    /// ISO 8601 publication timestamp.
1134    pub published_at: Option<String>,
1135    /// Number of release assets.
1136    pub assets_count: usize,
1137    /// HTML URL to view the release.
1138    pub url: String,
1139}
1140
1141/// PR verification result
1142#[derive(Debug, Clone, Serialize)]
1143pub struct PrVerification {
1144    /// Whether the PR was found.
1145    pub exists: bool,
1146    /// PR number within the repository.
1147    pub number: u64,
1148    /// PR title.
1149    pub title: String,
1150    /// PR state (`"open"` or `"closed"`).
1151    pub state: String,
1152    /// Whether the PR has been merged.
1153    pub is_merged: bool,
1154    /// ISO 8601 merge timestamp.
1155    pub merged_at: Option<String>,
1156    /// GitHub login of the PR author.
1157    pub author: String,
1158    /// Lines added.
1159    pub additions: Option<u32>,
1160    /// Lines deleted.
1161    pub deletions: Option<u32>,
1162    /// HTML URL to view the PR.
1163    pub url: String,
1164}
1165
1166/// Issue verification result
1167#[derive(Debug, Clone, Serialize)]
1168pub struct IssueVerification {
1169    /// Whether the issue was found.
1170    pub exists: bool,
1171    /// Issue number within the repository.
1172    pub number: u64,
1173    /// Issue title.
1174    pub title: String,
1175    /// Issue state (`"open"` or `"closed"`).
1176    pub state: String,
1177    /// Whether the issue is closed.
1178    pub is_closed: bool,
1179    /// ISO 8601 close timestamp.
1180    pub closed_at: Option<String>,
1181    /// GitHub login of the issue author.
1182    pub author: String,
1183    /// Label names applied to the issue.
1184    pub labels: Vec<String>,
1185    /// HTML URL to view the issue.
1186    pub url: String,
1187}
1188
1189/// GitHub verification result enum
1190#[derive(Debug, Clone, Serialize)]
1191pub enum GitHubVerificationResult {
1192    /// Result for a commit URL.
1193    Commit(CommitVerification),
1194    /// Result for a pull request URL.
1195    PullRequest(PrVerification),
1196    /// Result for a release URL.
1197    Release(ReleaseVerification),
1198    /// Result for an issue URL.
1199    Issue(IssueVerification),
1200    /// Result for a repository URL.
1201    Repository(Repository),
1202}