1use octocrab::Octocrab;
6use serde::{Deserialize, Serialize};
7
8use crate::config::LabelConfig;
9use crate::error::{Error, Result};
10
11fn encode_path_segment(input: &str) -> String {
22 input
23 .chars()
24 .map(|c| match c {
25 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '.' | '_' | '~' => c.to_string(),
27 _ => c
29 .to_string()
30 .bytes()
31 .map(|b| format!("%{:02X}", b))
32 .collect::<String>(),
33 })
34 .collect()
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41pub struct GitHubLabel {
42 pub id: u64,
44
45 pub name: String,
47
48 pub color: String,
50
51 pub description: Option<String>,
53
54 pub default: bool,
56
57 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
73pub struct GitHubClient {
77 octocrab: Octocrab,
78 owner: String,
79 repo: String,
80}
81
82impl GitHubClient {
83 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 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 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 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 pub async fn update_label(
206 &self,
207 current_name: &str,
208 label: &LabelConfig,
209 ) -> Result<GitHubLabel> {
210 self.delete_label(current_name).await?;
213 self.create_label(label).await
214 }
215
216 pub async fn delete_label(&self, label_name: &str) -> Result<()> {
224 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 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 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#[derive(Debug, Clone)]
272pub struct RateLimitInfo {
273 pub limit: u32,
275
276 pub remaining: u32,
278
279 pub reset_at: chrono::DateTime<chrono::Utc>,
281}
282
283pub 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
311fn 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 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 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 let similarity = calculate_label_similarity("enhancement", "feature");
370 assert!(similarity < 0.5);
371
372 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 assert_eq!(encode_path_segment("bug"), "bug");
391 assert_eq!(encode_path_segment("feature-request"), "feature-request");
392
393 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 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 assert_eq!(encode_path_segment("bug バグ"), "bug%20%E3%83%90%E3%82%B0");
409
410 assert_eq!(
412 encode_path_segment("test-label_v1.2~alpha"),
413 "test-label_v1.2~alpha"
414 );
415
416 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}