miyabi_github/
labels.rs

1//! GitHub Labels API wrapper
2//!
3//! Provides high-level interface for repository label management
4
5use crate::client::GitHubClient;
6use miyabi_types::error::{MiyabiError, Result};
7use serde::{Deserialize, Serialize};
8
9/// Label definition
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Label {
12    pub name: String,
13    pub color: String,
14    pub description: Option<String>,
15}
16
17impl GitHubClient {
18    /// List all labels in the repository
19    pub async fn list_labels(&self) -> Result<Vec<Label>> {
20        let page = self
21            .client
22            .issues(&self.owner, &self.repo)
23            .list_labels_for_repo()
24            .send()
25            .await
26            .map_err(|e| {
27                MiyabiError::GitHub(format!(
28                    "Failed to list labels for {}/{}: {}",
29                    self.owner, self.repo, e
30                ))
31            })?;
32
33        Ok(page
34            .items
35            .into_iter()
36            .map(|l| Label {
37                name: l.name,
38                color: l.color,
39                description: l.description,
40            })
41            .collect())
42    }
43
44    /// Get a single label by name
45    pub async fn get_label(&self, name: &str) -> Result<Label> {
46        let label = self
47            .client
48            .issues(&self.owner, &self.repo)
49            .get_label(name)
50            .await
51            .map_err(|e| {
52                MiyabiError::GitHub(format!(
53                    "Failed to get label '{}' from {}/{}: {}",
54                    name, self.owner, self.repo, e
55                ))
56            })?;
57
58        Ok(Label {
59            name: label.name,
60            color: label.color,
61            description: label.description,
62        })
63    }
64
65    /// Create a new label
66    ///
67    /// # Arguments
68    /// * `name` - Label name
69    /// * `color` - Label color (hex without #, e.g., "ff0000")
70    /// * `description` - Label description (optional)
71    pub async fn create_label(
72        &self,
73        name: &str,
74        color: &str,
75        description: Option<&str>,
76    ) -> Result<Label> {
77        let label = self
78            .client
79            .issues(&self.owner, &self.repo)
80            .create_label(name, color, description.unwrap_or(""))
81            .await
82            .map_err(|e| {
83                MiyabiError::GitHub(format!(
84                    "Failed to create label '{}' in {}/{}: {}",
85                    name, self.owner, self.repo, e
86                ))
87            })?;
88
89        Ok(Label {
90            name: label.name,
91            color: label.color,
92            description: label.description,
93        })
94    }
95
96    /// Update an existing label
97    ///
98    /// # Arguments
99    /// * `name` - Current label name
100    /// * `new_name` - New label name (optional)
101    /// * `color` - New color (optional)
102    /// * `description` - New description (optional)
103    pub async fn update_label(
104        &self,
105        name: &str,
106        new_name: Option<&str>,
107        color: Option<&str>,
108        description: Option<&str>,
109    ) -> Result<Label> {
110        // Delete and recreate approach (octocrab doesn't expose update_label directly)
111        // First, get the current label to preserve values
112        let current = self.get_label(name).await?;
113
114        let final_name = new_name.unwrap_or(&current.name);
115        let final_color = color.unwrap_or(&current.color);
116        let final_desc = description.or(current.description.as_deref());
117
118        // If name changed, delete old and create new
119        if new_name.is_some() && new_name.unwrap() != name {
120            self.delete_label(name).await?;
121        }
122
123        // Create the updated label
124        self.create_label(final_name, final_color, final_desc).await
125    }
126
127    /// Delete a label
128    pub async fn delete_label(&self, name: &str) -> Result<()> {
129        self.client
130            .issues(&self.owner, &self.repo)
131            .delete_label(name)
132            .await
133            .map_err(|e| {
134                MiyabiError::GitHub(format!(
135                    "Failed to delete label '{}' from {}/{}: {}",
136                    name, self.owner, self.repo, e
137                ))
138            })
139    }
140
141    /// Bulk create labels from a list
142    ///
143    /// # Arguments
144    /// * `labels` - Vector of labels to create
145    ///
146    /// # Returns
147    /// Vector of created labels (some may fail, errors are logged)
148    pub async fn bulk_create_labels(&self, labels: Vec<Label>) -> Result<Vec<Label>> {
149        let mut created = Vec::new();
150
151        for label in labels {
152            match self
153                .create_label(&label.name, &label.color, label.description.as_deref())
154                .await
155            {
156                Ok(l) => created.push(l),
157                Err(e) => {
158                    eprintln!("Warning: Failed to create label '{}': {}", label.name, e);
159                    // Continue with next label instead of aborting
160                }
161            }
162        }
163
164        Ok(created)
165    }
166
167    /// Check if a label exists
168    pub async fn label_exists(&self, name: &str) -> Result<bool> {
169        match self.get_label(name).await {
170            Ok(_) => Ok(true),
171            Err(MiyabiError::GitHub(ref msg)) if msg.contains("404") => Ok(false),
172            Err(e) => Err(e),
173        }
174    }
175
176    /// Sync labels from a YAML/JSON definition file
177    /// (Useful for setting up the 53-label system)
178    ///
179    /// # Arguments
180    /// * `labels` - Labels to sync
181    ///
182    /// # Returns
183    /// Number of labels created/updated
184    pub async fn sync_labels(&self, labels: Vec<Label>) -> Result<usize> {
185        let mut synced = 0;
186
187        for label in labels {
188            match self.label_exists(&label.name).await? {
189                true => {
190                    // Update existing label
191                    self.update_label(
192                        &label.name,
193                        None,
194                        Some(&label.color),
195                        label.description.as_deref(),
196                    )
197                    .await?;
198                    synced += 1;
199                }
200                false => {
201                    // Create new label
202                    self.create_label(&label.name, &label.color, label.description.as_deref())
203                        .await?;
204                    synced += 1;
205                }
206            }
207        }
208
209        Ok(synced)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_label_struct() {
219        let label = Label {
220            name: "bug".to_string(),
221            color: "d73a4a".to_string(),
222            description: Some("Something isn't working".to_string()),
223        };
224
225        assert_eq!(label.name, "bug");
226        assert_eq!(label.color, "d73a4a");
227        assert!(label.description.is_some());
228    }
229
230    #[test]
231    fn test_label_serialization() {
232        let label = Label {
233            name: "enhancement".to_string(),
234            color: "a2eeef".to_string(),
235            description: Some("New feature or request".to_string()),
236        };
237
238        let json = serde_json::to_string(&label).unwrap();
239        let deserialized: Label = serde_json::from_str(&json).unwrap();
240
241        assert_eq!(label.name, deserialized.name);
242        assert_eq!(label.color, deserialized.color);
243    }
244
245    // Integration tests are in tests/ directory
246}