Skip to main content

meritocrab_github/
api.rs

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