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