miyabi_github/
issues.rs

1//! GitHub Issues API wrapper
2//!
3//! Provides high-level interface for Issue CRUD operations and label management
4
5use crate::client::GitHubClient;
6use miyabi_types::error::{MiyabiError, Result};
7use miyabi_types::issue::{Issue, IssueState, IssueStateGithub};
8use octocrab::models::issues::Issue as OctoIssue;
9use octocrab::params::State;
10
11impl GitHubClient {
12    /// Get a single issue by number
13    ///
14    /// # Arguments
15    /// * `number` - Issue number
16    ///
17    /// # Returns
18    /// `Issue` struct with all metadata
19    pub async fn get_issue(&self, number: u64) -> Result<Issue> {
20        let issue = self.client.issues(&self.owner, &self.repo).get(number).await.map_err(|e| {
21            MiyabiError::GitHub(format!(
22                "Failed to get issue #{} from {}/{}: {}",
23                number, self.owner, self.repo, e
24            ))
25        })?;
26
27        convert_issue(issue)
28    }
29
30    /// List issues with optional filtering
31    ///
32    /// # Arguments
33    /// * `state` - Filter by state (Open/Closed/All)
34    /// * `labels` - Filter by labels (empty = all)
35    ///
36    /// # Returns
37    /// Vector of `Issue` structs
38    pub async fn list_issues(
39        &self,
40        state: Option<State>,
41        labels: Vec<String>,
42    ) -> Result<Vec<Issue>> {
43        let issues = self.client.issues(&self.owner, &self.repo);
44        let mut handler = issues.list();
45
46        // Apply filters
47        if let Some(s) = state {
48            handler = handler.state(s);
49        }
50
51        if !labels.is_empty() {
52            handler = handler.labels(&labels);
53        }
54
55        let page = handler.send().await.map_err(|e| {
56            MiyabiError::GitHub(format!(
57                "Failed to list issues for {}/{}: {}",
58                self.owner, self.repo, e
59            ))
60        })?;
61
62        page.items.into_iter().map(convert_issue).collect()
63    }
64
65    /// Create a new issue
66    ///
67    /// # Arguments
68    /// * `title` - Issue title
69    /// * `body` - Issue body (optional)
70    ///
71    /// # Returns
72    /// Created `Issue` struct
73    pub async fn create_issue(&self, title: &str, body: Option<&str>) -> Result<Issue> {
74        let issues = self.client.issues(&self.owner, &self.repo);
75        let mut handler = issues.create(title);
76
77        if let Some(b) = body {
78            handler = handler.body(b);
79        }
80
81        let issue = handler.send().await.map_err(|e| {
82            MiyabiError::GitHub(format!(
83                "Failed to create issue in {}/{}: {}",
84                self.owner, self.repo, e
85            ))
86        })?;
87
88        convert_issue(issue)
89    }
90
91    /// Update an existing issue
92    ///
93    /// # Arguments
94    /// * `number` - Issue number to update
95    /// * `title` - New title (optional)
96    /// * `body` - New body (optional)
97    /// * `state` - New state (optional)
98    ///
99    /// # Returns
100    /// Updated `Issue` struct
101    pub async fn update_issue(
102        &self,
103        number: u64,
104        title: Option<&str>,
105        body: Option<&str>,
106        state: Option<State>,
107    ) -> Result<Issue> {
108        use octocrab::models::IssueState as OctoState;
109
110        let issues = self.client.issues(&self.owner, &self.repo);
111        let mut handler = issues.update(number);
112
113        if let Some(t) = title {
114            handler = handler.title(t);
115        }
116
117        if let Some(b) = body {
118            handler = handler.body(b);
119        }
120
121        if let Some(s) = state {
122            let issue_state = match s {
123                State::Open => OctoState::Open,
124                State::Closed => OctoState::Closed,
125                State::All => {
126                    return Err(MiyabiError::GitHub(
127                        "Cannot update issue to 'All' state".to_string(),
128                    ))
129                },
130                _ => return Err(MiyabiError::GitHub(format!("Unknown state: {:?}", s))),
131            };
132            handler = handler.state(issue_state);
133        }
134
135        let issue = handler.send().await.map_err(|e| {
136            MiyabiError::GitHub(format!(
137                "Failed to update issue #{} in {}/{}: {}",
138                number, self.owner, self.repo, e
139            ))
140        })?;
141
142        convert_issue(issue)
143    }
144
145    /// Close an issue
146    pub async fn close_issue(&self, number: u64) -> Result<Issue> {
147        self.update_issue(number, None, None, Some(State::Closed)).await
148    }
149
150    /// Reopen an issue
151    pub async fn reopen_issue(&self, number: u64) -> Result<Issue> {
152        self.update_issue(number, None, None, Some(State::Open)).await
153    }
154
155    /// Add labels to an issue
156    ///
157    /// # Arguments
158    /// * `number` - Issue number
159    /// * `labels` - Labels to add
160    pub async fn add_labels(&self, number: u64, labels: &[String]) -> Result<Vec<String>> {
161        let labels_result = self
162            .client
163            .issues(&self.owner, &self.repo)
164            .add_labels(number, labels)
165            .await
166            .map_err(|e| {
167                MiyabiError::GitHub(format!(
168                    "Failed to add labels to issue #{} in {}/{}: {}",
169                    number, self.owner, self.repo, e
170                ))
171            })?;
172
173        Ok(labels_result.into_iter().map(|l| l.name).collect())
174    }
175
176    /// Remove a label from an issue
177    ///
178    /// # Arguments
179    /// * `number` - Issue number
180    /// * `label` - Label to remove
181    pub async fn remove_label(&self, number: u64, label: &str) -> Result<()> {
182        self.client
183            .issues(&self.owner, &self.repo)
184            .remove_label(number, label)
185            .await
186            .map_err(|e| {
187                MiyabiError::GitHub(format!(
188                    "Failed to remove label '{}' from issue #{} in {}/{}: {}",
189                    label, number, self.owner, self.repo, e
190                ))
191            })?;
192
193        Ok(())
194    }
195
196    /// Replace all labels on an issue
197    ///
198    /// # Arguments
199    /// * `number` - Issue number
200    /// * `labels` - New set of labels
201    pub async fn replace_labels(&self, number: u64, labels: &[String]) -> Result<Vec<String>> {
202        let labels_result = self
203            .client
204            .issues(&self.owner, &self.repo)
205            .replace_all_labels(number, labels)
206            .await
207            .map_err(|e| {
208                MiyabiError::GitHub(format!(
209                    "Failed to replace labels on issue #{} in {}/{}: {}",
210                    number, self.owner, self.repo, e
211                ))
212            })?;
213
214        Ok(labels_result.into_iter().map(|l| l.name).collect())
215    }
216
217    /// Get issues by state label (e.g., "state:pending")
218    ///
219    /// # Arguments
220    /// * `state` - IssueState enum value
221    ///
222    /// # Returns
223    /// Vector of issues with that state label
224    pub async fn get_issues_by_state(&self, state: IssueState) -> Result<Vec<Issue>> {
225        let label = state.to_label().to_string();
226        self.list_issues(Some(State::Open), vec![label]).await
227    }
228}
229
230/// Convert octocrab Issue to miyabi-types Issue
231fn convert_issue(issue: OctoIssue) -> Result<Issue> {
232    use octocrab::models::IssueState as OctoState;
233
234    let state = match issue.state {
235        OctoState::Open => IssueStateGithub::Open,
236        OctoState::Closed => IssueStateGithub::Closed,
237        _ => return Err(MiyabiError::GitHub(format!("Unknown issue state: {:?}", issue.state))),
238    };
239
240    let assignee = issue.assignee.map(|a| a.login);
241
242    let labels = issue.labels.into_iter().map(|l| l.name).collect::<Vec<String>>();
243
244    Ok(Issue {
245        number: issue.number,
246        title: issue.title,
247        body: issue.body.unwrap_or_default(),
248        state,
249        labels,
250        assignee,
251        created_at: issue.created_at,
252        updated_at: issue.updated_at,
253        url: issue.html_url.to_string(),
254    })
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    // Unit tests for conversion functions
262
263    #[test]
264    fn test_issue_state_conversion() {
265        // Test IssueState::to_label() is correct
266        assert_eq!(IssueState::Pending.to_label(), "📥 state:pending");
267        assert_eq!(IssueState::Analyzing.to_label(), "🔍 state:analyzing");
268        assert_eq!(IssueState::Implementing.to_label(), "🏗️ state:implementing");
269        assert_eq!(IssueState::Reviewing.to_label(), "👀 state:reviewing");
270        assert_eq!(IssueState::Deploying.to_label(), "🚀 state:deploying");
271        assert_eq!(IssueState::Done.to_label(), "✅ state:done");
272        assert_eq!(IssueState::Blocked.to_label(), "🚫 state:blocked");
273        assert_eq!(IssueState::Failed.to_label(), "❌ state:failed");
274    }
275
276    // Integration tests are in tests/ directory
277}