Skip to main content

meritocrab_github/
api.rs

1use crate::{
2    error::{GithubError, GithubResult},
3    types::CollaboratorRole,
4};
5use octocrab::{models::CommentId, Octocrab};
6
7/// GitHub API client for repository operations
8pub struct GithubApiClient {
9    client: Octocrab,
10}
11
12impl GithubApiClient {
13    /// Create new GitHub API client with authentication token
14    pub fn new(token: String) -> GithubResult<Self> {
15        let client = Octocrab::builder()
16            .personal_token(token)
17            .build()
18            .map_err(|e| GithubError::ApiError(format!("Failed to create octocrab client: {}", e)))?;
19
20        Ok(Self { client })
21    }
22
23    /// Create client from existing octocrab instance
24    pub fn from_octocrab(client: Octocrab) -> Self {
25        Self { client }
26    }
27
28    /// Close a pull request
29    ///
30    /// # Arguments
31    /// * `owner` - Repository owner username
32    /// * `repo` - Repository name
33    /// * `pr_number` - Pull request number
34    pub async fn close_pull_request(
35        &self,
36        owner: &str,
37        repo: &str,
38        pr_number: u64,
39    ) -> GithubResult<()> {
40        self.client
41            .pulls(owner, repo)
42            .update(pr_number)
43            .state(octocrab::params::pulls::State::Closed)
44            .send()
45            .await
46            .map_err(|e| {
47                GithubError::ApiError(format!("Failed to close PR #{}: {}", pr_number, e))
48            })?;
49
50        Ok(())
51    }
52
53    /// Add a comment to an issue or pull request
54    ///
55    /// # Arguments
56    /// * `owner` - Repository owner username
57    /// * `repo` - Repository name
58    /// * `issue_number` - Issue or PR number
59    /// * `body` - Comment body text
60    pub async fn add_comment(
61        &self,
62        owner: &str,
63        repo: &str,
64        issue_number: u64,
65        body: &str,
66    ) -> GithubResult<CommentId> {
67        let comment = self
68            .client
69            .issues(owner, repo)
70            .create_comment(issue_number, body)
71            .await
72            .map_err(|e| {
73                GithubError::ApiError(format!("Failed to add comment to #{}: {}", issue_number, e))
74            })?;
75
76        Ok(comment.id)
77    }
78
79    /// Check the collaborator role/permission level for a user
80    ///
81    /// # Arguments
82    /// * `owner` - Repository owner username
83    /// * `repo` - Repository name
84    /// * `username` - User to check permissions for
85    ///
86    /// # Returns
87    /// The user's permission level in the repository
88    pub async fn check_collaborator_role(
89        &self,
90        owner: &str,
91        repo: &str,
92        username: &str,
93    ) -> GithubResult<CollaboratorRole> {
94        // Try to get collaborator permission
95        // GitHub API returns 404 if user is not a collaborator
96        let result = self
97            .client
98            .repos(owner, repo)
99            .get_contributor_permission(username)
100            .send()
101            .await;
102
103        match result {
104            Ok(permission) => {
105                // Parse permission level from octocrab's Permission enum
106                // Convert to string to match against known permission levels
107                let perm_str = format!("{:?}", permission.permission).to_lowercase();
108                let role = match perm_str.as_str() {
109                    "admin" => CollaboratorRole::Admin,
110                    "maintain" => CollaboratorRole::Maintain,
111                    "write" | "push" => CollaboratorRole::Write,
112                    "triage" => CollaboratorRole::Triage,
113                    "read" | "pull" => CollaboratorRole::Read,
114                    _ => CollaboratorRole::None,
115                };
116                Ok(role)
117            }
118            Err(octocrab::Error::GitHub { source, .. })
119                if source.message.contains("404") || source.message.contains("Not Found") =>
120            {
121                // User is not a collaborator
122                Ok(CollaboratorRole::None)
123            }
124            Err(e) => Err(GithubError::ApiError(format!(
125                "Failed to check collaborator role for {}: {}",
126                username, e
127            ))),
128        }
129    }
130
131    /// Get file content from repository
132    ///
133    /// # Arguments
134    /// * `owner` - Repository owner username
135    /// * `repo` - Repository name
136    /// * `path` - File path in repository (e.g., ".meritocrab.toml")
137    ///
138    /// # Returns
139    /// Decoded file content as UTF-8 string
140    pub async fn get_file_content(
141        &self,
142        owner: &str,
143        repo: &str,
144        path: &str,
145    ) -> GithubResult<String> {
146        // Fetch file content from GitHub
147        let content = self
148            .client
149            .repos(owner, repo)
150            .get_content()
151            .path(path)
152            .send()
153            .await
154            .map_err(|e| {
155                GithubError::ApiError(format!("Failed to fetch file {} from {}/{}: {}", path, owner, repo, e))
156            })?;
157
158        // GitHub returns content as base64-encoded
159        // Octocrab's ContentItems can be a file or directory
160        if let Some(file) = content.items.first() {
161            if let Some(encoded_content) = &file.content {
162                // Decode base64
163                let decoded = base64::Engine::decode(
164                    &base64::engine::general_purpose::STANDARD,
165                    encoded_content.replace('\n', "").as_bytes()
166                ).map_err(|e| {
167                    GithubError::ApiError(format!("Failed to decode base64 content: {}", e))
168                })?;
169
170                // Convert to UTF-8 string
171                let content_str = String::from_utf8(decoded).map_err(|e| {
172                    GithubError::ApiError(format!("Failed to decode UTF-8 content: {}", e))
173                })?;
174
175                return Ok(content_str);
176            }
177        }
178
179        Err(GithubError::ApiError(format!(
180            "File {} not found in {}/{}",
181            path, owner, repo
182        )))
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    // Note: These tests require GitHub API access and would normally use mocking.
191    // For now, they verify the API structure without making actual requests.
192
193    #[tokio::test]
194    async fn test_create_api_client() {
195        // Initialize rustls crypto provider for tests
196        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
197
198        let result = GithubApiClient::new("test-token".to_string());
199        assert!(result.is_ok());
200    }
201
202    #[test]
203    fn test_collaborator_role_parsing() {
204        // Test role determination logic
205        let admin_str = "admin";
206        let role = match admin_str {
207            "admin" => CollaboratorRole::Admin,
208            "maintain" => CollaboratorRole::Maintain,
209            "write" => CollaboratorRole::Write,
210            "triage" => CollaboratorRole::Triage,
211            "read" => CollaboratorRole::Read,
212            _ => CollaboratorRole::None,
213        };
214        assert_eq!(role, CollaboratorRole::Admin);
215    }
216
217    // Integration tests would be added here with wiremock or similar
218    // to mock GitHub API responses without making actual HTTP calls
219}