ricecoder_github/managers/
issue_manager.rs

1//! Issue Management
2//!
3//! Handles GitHub issue assignment, parsing, and tracking
4
5use crate::errors::{GitHubError, Result};
6use crate::models::{Issue, IssueProgressUpdate, IssueStatus};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10/// Parsed requirement from an issue
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ParsedRequirement {
13    /// Requirement ID
14    pub id: String,
15    /// Requirement description
16    pub description: String,
17    /// Acceptance criteria
18    pub acceptance_criteria: Vec<String>,
19    /// Priority level
20    pub priority: String,
21}
22
23/// Implementation plan generated from issue requirements
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ImplementationPlan {
26    /// Plan ID
27    pub id: String,
28    /// Issue number this plan is for
29    pub issue_number: u32,
30    /// List of tasks
31    pub tasks: Vec<PlanTask>,
32    /// Estimated effort (in story points)
33    pub estimated_effort: u32,
34    /// Generated timestamp
35    pub generated_at: chrono::DateTime<chrono::Utc>,
36}
37
38/// A task in an implementation plan
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PlanTask {
41    /// Task ID
42    pub id: String,
43    /// Task description
44    pub description: String,
45    /// Related requirements
46    pub related_requirements: Vec<String>,
47    /// Estimated effort
48    pub estimated_effort: u32,
49    /// Dependencies on other tasks
50    pub dependencies: Vec<String>,
51}
52
53/// Issue Manager for handling GitHub issues
54#[derive(Debug, Clone)]
55#[allow(dead_code)]
56pub struct IssueManager {
57    /// GitHub token for API access
58    token: String,
59    /// Owner of the repository
60    owner: String,
61    /// Repository name
62    repo: String,
63}
64
65impl IssueManager {
66    /// Create a new IssueManager
67    pub fn new(token: String, owner: String, repo: String) -> Self {
68        IssueManager { token, owner, repo }
69    }
70
71    /// Parse an issue input (URL or issue number)
72    ///
73    /// Accepts formats like:
74    /// - "123" (issue number)
75    /// - "https://github.com/owner/repo/issues/123"
76    /// - "owner/repo#123"
77    pub fn parse_issue_input(&self, input: &str) -> Result<u32> {
78        // Try parsing as plain number
79        if let Ok(number) = input.parse::<u32>() {
80            return Ok(number);
81        }
82
83        // Try parsing GitHub URL
84        if let Some(captures) = Regex::new(r"issues/(\d+)")
85            .ok()
86            .and_then(|re| re.captures(input))
87        {
88            if let Ok(number) = captures.get(1).unwrap().as_str().parse::<u32>() {
89                return Ok(number);
90            }
91        }
92
93        // Try parsing owner/repo#number format
94        if let Some(captures) = Regex::new(r"#(\d+)")
95            .ok()
96            .and_then(|re| re.captures(input))
97        {
98            if let Ok(number) = captures.get(1).unwrap().as_str().parse::<u32>() {
99                return Ok(number);
100            }
101        }
102
103        Err(GitHubError::invalid_input(format!(
104            "Invalid issue input format: {}. Expected issue number, GitHub URL, or owner/repo#number",
105            input
106        )))
107    }
108
109    /// Extract requirements from an issue description
110    ///
111    /// Looks for patterns like:
112    /// - "## Requirement 1: ..."
113    /// - "### Acceptance Criteria"
114    /// - "- [ ] ..." (checkboxes)
115    pub fn extract_requirements(&self, issue_body: &str) -> Result<Vec<ParsedRequirement>> {
116        let mut requirements = Vec::new();
117
118        // Split by requirement headers
119        let requirement_pattern = Regex::new(r"##\s+Requirement\s+(\d+):\s*(.+)")
120            .map_err(|e| GitHubError::invalid_input(format!("Regex error: {}", e)))?;
121
122        // Compile criteria pattern once outside the loop
123        let criteria_pattern = Regex::new(r"- \[.\]\s+(.+)")
124            .map_err(|e| GitHubError::invalid_input(format!("Regex error: {}", e)))?;
125
126        let matches: Vec<_> = requirement_pattern.captures_iter(issue_body).collect();
127
128        for (idx, req_match) in matches.iter().enumerate() {
129            let req_id = req_match.get(1).map(|m| m.as_str()).unwrap_or("0");
130            let req_start = req_match.get(2).unwrap().start();
131
132            // Get content until next requirement or end of string
133            let req_end = if idx + 1 < matches.len() {
134                matches[idx + 1].get(0).unwrap().start()
135            } else {
136                issue_body.len()
137            };
138
139            let req_content = &issue_body[req_start..req_end];
140
141            // Extract description (first line after header)
142            let description = req_content
143                .lines()
144                .next()
145                .unwrap_or("")
146                .trim()
147                .to_string();
148
149            // Extract acceptance criteria (lines starting with "- ")
150            let acceptance_criteria: Vec<String> = criteria_pattern
151                .captures_iter(req_content)
152                .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
153                .collect();
154
155            // Extract priority if present
156            let priority = if req_content.contains("Priority: HIGH") {
157                "HIGH".to_string()
158            } else {
159                "MEDIUM".to_string()
160            };
161
162            requirements.push(ParsedRequirement {
163                id: format!("REQ-{}", req_id),
164                description,
165                acceptance_criteria,
166                priority,
167            });
168        }
169
170        // If no structured requirements found, treat entire body as one requirement
171        if requirements.is_empty() && !issue_body.is_empty() {
172            requirements.push(ParsedRequirement {
173                id: "REQ-1".to_string(),
174                description: issue_body.lines().next().unwrap_or("").to_string(),
175                acceptance_criteria: vec![],
176                priority: "MEDIUM".to_string(),
177            });
178        }
179
180        Ok(requirements)
181    }
182
183    /// Create an implementation plan from parsed requirements
184    pub fn create_implementation_plan(
185        &self,
186        issue_number: u32,
187        requirements: Vec<ParsedRequirement>,
188    ) -> Result<ImplementationPlan> {
189        let mut tasks = Vec::new();
190        let mut total_effort = 0u32;
191
192        for (idx, req) in requirements.iter().enumerate() {
193            let task_id = format!("TASK-{}-{}", issue_number, idx + 1);
194            let estimated_effort = match req.priority.as_str() {
195                "HIGH" => 8,
196                "MEDIUM" => 5,
197                "LOW" => 3,
198                _ => 5,
199            };
200
201            total_effort += estimated_effort;
202
203            tasks.push(PlanTask {
204                id: task_id,
205                description: req.description.clone(),
206                related_requirements: vec![req.id.clone()],
207                estimated_effort,
208                dependencies: if idx > 0 {
209                    vec![format!("TASK-{}-{}", issue_number, idx)]
210                } else {
211                    vec![]
212                },
213            });
214        }
215
216        Ok(ImplementationPlan {
217            id: format!("PLAN-{}", issue_number),
218            issue_number,
219            tasks,
220            estimated_effort: total_effort,
221            generated_at: chrono::Utc::now(),
222        })
223    }
224
225    /// Format a progress update message for posting to an issue
226    pub fn format_progress_update(&self, update: &IssueProgressUpdate) -> String {
227        let status_emoji = match update.status {
228            IssueStatus::Open => "🔴",
229            IssueStatus::InProgress => "🟡",
230            IssueStatus::Closed => "🟢",
231        };
232
233        let progress_bar = self.create_progress_bar(update.progress_percentage);
234        let status_str = format!("{:?}", update.status);
235        let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
236
237        format!(
238            "{} **Progress Update**\n\n\
239             Status: {}\n\
240             Progress: {}\n\n\
241             {}\n\n\
242             _Updated at: {}_",
243            status_emoji,
244            status_str,
245            progress_bar,
246            update.message,
247            timestamp
248        )
249    }
250
251    /// Create a progress bar string
252    fn create_progress_bar(&self, percentage: u32) -> String {
253        let filled = (percentage / 10) as usize;
254        let empty = 10 - filled;
255        let bar = format!(
256            "[{}{}] {}%",
257            "â–ˆ".repeat(filled),
258            "â–‘".repeat(empty),
259            percentage
260        );
261        bar
262    }
263
264    /// Format a PR closure message for an issue
265    pub fn format_pr_closure_message(&self, pr_number: u32, pr_title: &str) -> String {
266        format!(
267            "Closes #{} - {}\n\nThis PR implements the requirements from this issue.",
268            pr_number, pr_title
269        )
270    }
271
272    /// Validate that an issue has required fields
273    pub fn validate_issue(&self, issue: &Issue) -> Result<()> {
274        if issue.title.is_empty() {
275            return Err(GitHubError::invalid_input("Issue title cannot be empty"));
276        }
277
278        if issue.body.is_empty() {
279            return Err(GitHubError::invalid_input("Issue body cannot be empty"));
280        }
281
282        Ok(())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn create_test_manager() -> IssueManager {
291        IssueManager::new(
292            "test_token".to_string(),
293            "test_owner".to_string(),
294            "test_repo".to_string(),
295        )
296    }
297
298    #[test]
299    fn test_parse_issue_input_number() {
300        let manager = create_test_manager();
301        assert_eq!(manager.parse_issue_input("123").unwrap(), 123);
302    }
303
304    #[test]
305    fn test_parse_issue_input_url() {
306        let manager = create_test_manager();
307        let url = "https://github.com/owner/repo/issues/456";
308        assert_eq!(manager.parse_issue_input(url).unwrap(), 456);
309    }
310
311    #[test]
312    fn test_parse_issue_input_hash_format() {
313        let manager = create_test_manager();
314        let input = "owner/repo#789";
315        assert_eq!(manager.parse_issue_input(input).unwrap(), 789);
316    }
317
318    #[test]
319    fn test_parse_issue_input_invalid() {
320        let manager = create_test_manager();
321        assert!(manager.parse_issue_input("invalid").is_err());
322    }
323
324    #[test]
325    fn test_extract_requirements_empty() {
326        let manager = create_test_manager();
327        let requirements = manager.extract_requirements("").unwrap();
328        assert!(requirements.is_empty());
329    }
330
331    #[test]
332    fn test_extract_requirements_simple() {
333        let manager = create_test_manager();
334        let body = "This is a simple requirement";
335        let requirements = manager.extract_requirements(body).unwrap();
336        assert_eq!(requirements.len(), 1);
337        assert_eq!(requirements[0].description, "This is a simple requirement");
338    }
339
340    #[test]
341    fn test_create_implementation_plan() {
342        let manager = create_test_manager();
343        let requirements = vec![
344            ParsedRequirement {
345                id: "REQ-1".to_string(),
346                description: "Implement feature X".to_string(),
347                acceptance_criteria: vec!["Criterion 1".to_string()],
348                priority: "HIGH".to_string(),
349            },
350            ParsedRequirement {
351                id: "REQ-2".to_string(),
352                description: "Add tests".to_string(),
353                acceptance_criteria: vec![],
354                priority: "MEDIUM".to_string(),
355            },
356        ];
357
358        let plan = manager.create_implementation_plan(123, requirements).unwrap();
359        assert_eq!(plan.issue_number, 123);
360        assert_eq!(plan.tasks.len(), 2);
361        assert!(plan.estimated_effort > 0);
362    }
363
364    #[test]
365    fn test_format_progress_update() {
366        let manager = create_test_manager();
367        let update = IssueProgressUpdate {
368            issue_number: 123,
369            message: "Working on implementation".to_string(),
370            status: IssueStatus::InProgress,
371            progress_percentage: 50,
372        };
373
374        let formatted = manager.format_progress_update(&update);
375        assert!(formatted.contains("Progress Update"));
376        assert!(formatted.contains("50%"));
377    }
378
379    #[test]
380    fn test_validate_issue_valid() {
381        let manager = create_test_manager();
382        let issue = Issue {
383            id: 1,
384            number: 123,
385            title: "Test Issue".to_string(),
386            body: "Test body".to_string(),
387            labels: vec![],
388            assignees: vec![],
389            status: IssueStatus::Open,
390            created_at: chrono::Utc::now(),
391            updated_at: chrono::Utc::now(),
392        };
393
394        assert!(manager.validate_issue(&issue).is_ok());
395    }
396
397    #[test]
398    fn test_validate_issue_empty_title() {
399        let manager = create_test_manager();
400        let issue = Issue {
401            id: 1,
402            number: 123,
403            title: "".to_string(),
404            body: "Test body".to_string(),
405            labels: vec![],
406            assignees: vec![],
407            status: IssueStatus::Open,
408            created_at: chrono::Utc::now(),
409            updated_at: chrono::Utc::now(),
410        };
411
412        assert!(manager.validate_issue(&issue).is_err());
413    }
414}