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