gh_labeler/
github.rs

1//! GitHub API Client
2//!
3//! Module for managing interactions with the GitHub API
4
5use octocrab::Octocrab;
6use serde::{Deserialize, Serialize};
7
8use crate::config::LabelConfig;
9use crate::error::{Error, Result};
10
11/// Encode a string for use in URL path segments (RFC 3986 with UTF-8 support)
12///
13/// This function properly encodes UTF-8 characters including Japanese text.
14/// Only unreserved characters (A-Z, a-z, 0-9, -, ., _, ~) are left unencoded.
15///
16/// # Arguments
17/// - `input`: The string to encode
18///
19/// # Returns
20/// URL-encoded string safe for use in path segments
21fn encode_path_segment(input: &str) -> String {
22    input
23        .chars()
24        .map(|c| match c {
25            // RFC 3986 unreserved characters
26            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '.' | '_' | '~' => c.to_string(),
27            // Everything else gets percent-encoded as UTF-8 bytes
28            _ => c
29                .to_string()
30                .bytes()
31                .map(|b| format!("%{:02X}", b))
32                .collect::<String>(),
33        })
34        .collect()
35}
36
37/// GitHub Label Information
38///
39/// Represents label information retrieved from the GitHub API
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41pub struct GitHubLabel {
42    /// Label ID
43    pub id: u64,
44
45    /// Label name
46    pub name: String,
47
48    /// Label color (6-digit hexadecimal, without #)
49    pub color: String,
50
51    /// Label description
52    pub description: Option<String>,
53
54    /// Whether this is a default label
55    pub default: bool,
56
57    /// Label URL
58    pub url: String,
59}
60
61impl From<GitHubLabel> for LabelConfig {
62    fn from(github_label: GitHubLabel) -> Self {
63        LabelConfig {
64            name: github_label.name,
65            color: github_label.color,
66            description: github_label.description,
67            aliases: Vec::new(),
68            delete: false,
69        }
70    }
71}
72
73/// GitHub API Client
74///
75/// Client responsible for interactions with the GitHub API
76pub struct GitHubClient {
77    octocrab: Octocrab,
78    owner: String,
79    repo: String,
80}
81
82impl GitHubClient {
83    /// Create a new GitHub client
84    ///
85    /// # Arguments
86    /// - `access_token`: GitHub access token
87    /// - `owner`: Repository owner
88    /// - `repo`: Repository name
89    ///
90    /// # Errors
91    /// Returns an error if client initialization fails
92    pub async fn new(access_token: &str, owner: &str, repo: &str) -> Result<Self> {
93        let octocrab = Octocrab::builder()
94            .personal_token(access_token.to_string())
95            .build()
96            .map_err(|e| Error::generic(format!("Failed to create GitHub client: {}", e)))?;
97
98        // Authentication test
99        let _user = octocrab
100            .current()
101            .user()
102            .await
103            .map_err(|_| Error::AuthenticationFailed)?;
104
105        Ok(Self {
106            octocrab,
107            owner: owner.to_string(),
108            repo: repo.to_string(),
109        })
110    }
111
112    /// Get all labels from the repository
113    ///
114    /// # Returns
115    /// List of all labels in the repository
116    ///
117    /// # Errors
118    /// Returns an error if GitHub API fails or repository is not found
119    pub async fn get_all_labels(&self) -> Result<Vec<GitHubLabel>> {
120        let mut labels = Vec::new();
121        let mut page = 1u32;
122
123        loop {
124            let response = self
125                .octocrab
126                .issues(&self.owner, &self.repo)
127                .list_labels_for_repo()
128                .page(page)
129                .per_page(100)
130                .send()
131                .await
132                .map_err(|e| {
133                    if e.to_string().contains("Not Found") {
134                        Error::RepositoryNotFound(format!("{}/{}", self.owner, self.repo))
135                    } else {
136                        Error::GitHubApi(e)
137                    }
138                })?;
139
140            if response.items.is_empty() {
141                break;
142            }
143
144            for label in response.items {
145                labels.push(GitHubLabel {
146                    id: label.id.0,
147                    name: label.name,
148                    color: label.color,
149                    description: label.description,
150                    default: label.default,
151                    url: label.url.to_string(),
152                });
153            }
154
155            page += 1;
156        }
157
158        Ok(labels)
159    }
160
161    /// Create a new label
162    ///
163    /// # Arguments
164    /// - `label`: Label configuration to create
165    ///
166    /// # Returns
167    /// Information about the created label
168    ///
169    /// # Errors
170    /// Returns an error if GitHub API fails or label creation fails
171    pub async fn create_label(&self, label: &LabelConfig) -> Result<GitHubLabel> {
172        let normalized_color = crate::config::LabelConfig::normalize_color(&label.color);
173        let response = self
174            .octocrab
175            .issues(&self.owner, &self.repo)
176            .create_label(
177                &label.name,
178                &normalized_color,
179                label.description.as_deref().unwrap_or(""),
180            )
181            .await
182            .map_err(Error::GitHubApi)?;
183
184        Ok(GitHubLabel {
185            id: response.id.0,
186            name: response.name,
187            color: response.color,
188            description: response.description,
189            default: response.default,
190            url: response.url.to_string(),
191        })
192    }
193
194    /// Update an existing label
195    ///
196    /// # Arguments
197    /// - `current_name`: Current label name
198    /// - `label`: Updated label configuration
199    ///
200    /// # Returns  
201    /// Information about the updated label
202    ///
203    /// # Errors
204    /// Returns an error if GitHub API fails or label update fails
205    pub async fn update_label(
206        &self,
207        current_name: &str,
208        label: &LabelConfig,
209    ) -> Result<GitHubLabel> {
210        // Since octocrab v0.38 doesn't have a direct update_label method,
211        // we use the approach of deleting and recreating
212        self.delete_label(current_name).await?;
213        self.create_label(label).await
214    }
215
216    /// Delete a label
217    ///
218    /// # Arguments
219    /// - `label_name`: Name of the label to delete
220    ///
221    /// # Errors
222    /// Returns an error if GitHub API fails or label deletion fails
223    pub async fn delete_label(&self, label_name: &str) -> Result<()> {
224        // URL encode the label name to handle spaces, special characters, and UTF-8 (Japanese, etc.)
225        let encoded_name = encode_path_segment(label_name);
226        self.octocrab
227            .issues(&self.owner, &self.repo)
228            .delete_label(&encoded_name)
229            .await
230            .map_err(Error::GitHubApi)?;
231
232        Ok(())
233    }
234
235    /// Check if the repository exists
236    ///
237    /// # Returns
238    /// True if the repository exists
239    pub async fn repository_exists(&self) -> bool {
240        self.octocrab
241            .repos(&self.owner, &self.repo)
242            .get()
243            .await
244            .is_ok()
245    }
246
247    /// Get rate limit information
248    ///
249    /// # Returns
250    /// Rate limit status
251    pub async fn get_rate_limit(&self) -> Result<RateLimitInfo> {
252        let rate_limit = self
253            .octocrab
254            .ratelimit()
255            .get()
256            .await
257            .map_err(Error::GitHubApi)?;
258
259        Ok(RateLimitInfo {
260            limit: rate_limit.resources.core.limit as u32,
261            remaining: rate_limit.resources.core.remaining as u32,
262            reset_at: chrono::DateTime::from_timestamp(rate_limit.resources.core.reset as i64, 0)
263                .unwrap_or_else(chrono::Utc::now),
264        })
265    }
266}
267
268/// Rate Limit Information
269///
270/// Represents GitHub API rate limit status
271#[derive(Debug, Clone)]
272pub struct RateLimitInfo {
273    /// Hourly limit
274    pub limit: u32,
275
276    /// Remaining usage count
277    pub remaining: u32,
278
279    /// Reset time
280    pub reset_at: chrono::DateTime<chrono::Utc>,
281}
282
283/// Calculate label similarity
284///
285/// Calculate the similarity between two label names using Levenshtein distance
286///
287/// # Arguments
288/// - `a`: First label name for comparison
289/// - `b`: Second label name for comparison
290///
291/// # Returns
292/// Similarity score (0.0-1.0, where 1.0 is perfect match)
293pub fn calculate_label_similarity(a: &str, b: &str) -> f64 {
294    let a = a.to_lowercase();
295    let b = b.to_lowercase();
296
297    if a == b {
298        return 1.0;
299    }
300
301    let distance = levenshtein_distance(&a, &b);
302    let max_len = a.len().max(b.len()) as f64;
303
304    if max_len == 0.0 {
305        1.0
306    } else {
307        1.0 - (distance as f64 / max_len)
308    }
309}
310
311/// Calculate Levenshtein distance
312///
313/// # Arguments
314/// - `a`: First string
315/// - `b`: Second string
316///
317/// # Returns
318/// Levenshtein distance
319fn levenshtein_distance(a: &str, b: &str) -> usize {
320    let a_chars: Vec<char> = a.chars().collect();
321    let b_chars: Vec<char> = b.chars().collect();
322    let a_len = a_chars.len();
323    let b_len = b_chars.len();
324
325    if a_len == 0 {
326        return b_len;
327    }
328    if b_len == 0 {
329        return a_len;
330    }
331
332    let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
333
334    // Initialize
335    for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
336        row[0] = i;
337    }
338    for j in 0..=b_len {
339        matrix[0][j] = j;
340    }
341
342    // Calculate
343    for i in 1..=a_len {
344        for j in 1..=b_len {
345            let cost = if a_chars[i - 1] == b_chars[j - 1] {
346                0
347            } else {
348                1
349            };
350
351            matrix[i][j] = (matrix[i - 1][j] + 1)
352                .min(matrix[i][j - 1] + 1)
353                .min(matrix[i - 1][j - 1] + cost);
354        }
355    }
356
357    matrix[a_len][b_len]
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_label_similarity() {
366        assert_eq!(calculate_label_similarity("bug", "bug"), 1.0);
367
368        // Different labels should have low similarity
369        let similarity = calculate_label_similarity("enhancement", "feature");
370        assert!(similarity < 0.5);
371
372        // Partial similarity
373        let similarity = calculate_label_similarity("bug-report", "bug");
374        assert!(similarity > 0.0 && similarity < 1.0);
375    }
376
377    #[test]
378    fn test_levenshtein_distance() {
379        assert_eq!(levenshtein_distance("", ""), 0);
380        assert_eq!(levenshtein_distance("abc", ""), 3);
381        assert_eq!(levenshtein_distance("", "abc"), 3);
382        assert_eq!(levenshtein_distance("abc", "abc"), 0);
383        assert_eq!(levenshtein_distance("abc", "ab"), 1);
384        assert_eq!(levenshtein_distance("abc", "axc"), 1);
385    }
386
387    #[test]
388    fn test_encode_path_segment() {
389        // Basic ASCII characters
390        assert_eq!(encode_path_segment("bug"), "bug");
391        assert_eq!(encode_path_segment("feature-request"), "feature-request");
392
393        // Spaces and special characters
394        assert_eq!(
395            encode_path_segment("good first issue"),
396            "good%20first%20issue"
397        );
398        assert_eq!(encode_path_segment("help wanted"), "help%20wanted");
399
400        // Japanese characters (UTF-8)
401        assert_eq!(encode_path_segment("バグ"), "%E3%83%90%E3%82%B0");
402        assert_eq!(
403            encode_path_segment("機能追加"),
404            "%E6%A9%9F%E8%83%BD%E8%BF%BD%E5%8A%A0"
405        );
406
407        // Mixed ASCII and Japanese
408        assert_eq!(encode_path_segment("bug バグ"), "bug%20%E3%83%90%E3%82%B0");
409
410        // RFC 3986 unreserved characters should remain unchanged
411        assert_eq!(
412            encode_path_segment("test-label_v1.2~alpha"),
413            "test-label_v1.2~alpha"
414        );
415
416        // Special characters that need encoding
417        assert_eq!(encode_path_segment("test/label"), "test%2Flabel");
418        assert_eq!(encode_path_segment("test@label"), "test%40label");
419    }
420
421    #[test]
422    fn test_github_label_conversion() {
423        let github_label = GitHubLabel {
424            id: 1,
425            name: "bug".to_string(),
426            color: "d73a4a".to_string(),
427            description: Some("Something isn't working".to_string()),
428            default: true,
429            url: "https://api.github.com/repos/owner/repo/labels/bug".to_string(),
430        };
431
432        let label_config: LabelConfig = github_label.into();
433        assert_eq!(label_config.name, "bug");
434        assert_eq!(label_config.color, "d73a4a");
435        assert_eq!(
436            label_config.description,
437            Some("Something isn't working".to_string())
438        );
439    }
440}