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