ricecoder_github/managers/
issue_manager.rs1use crate::errors::{GitHubError, Result};
6use crate::models::{Issue, IssueProgressUpdate, IssueStatus};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ParsedRequirement {
13 pub id: String,
15 pub description: String,
17 pub acceptance_criteria: Vec<String>,
19 pub priority: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ImplementationPlan {
26 pub id: String,
28 pub issue_number: u32,
30 pub tasks: Vec<PlanTask>,
32 pub estimated_effort: u32,
34 pub generated_at: chrono::DateTime<chrono::Utc>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PlanTask {
41 pub id: String,
43 pub description: String,
45 pub related_requirements: Vec<String>,
47 pub estimated_effort: u32,
49 pub dependencies: Vec<String>,
51}
52
53#[derive(Debug, Clone)]
55#[allow(dead_code)]
56pub struct IssueManager {
57 token: String,
59 owner: String,
61 repo: String,
63}
64
65impl IssueManager {
66 pub fn new(token: String, owner: String, repo: String) -> Self {
68 IssueManager { token, owner, repo }
69 }
70
71 pub fn parse_issue_input(&self, input: &str) -> Result<u32> {
78 if let Ok(number) = input.parse::<u32>() {
80 return Ok(number);
81 }
82
83 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 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 pub fn extract_requirements(&self, issue_body: &str) -> Result<Vec<ParsedRequirement>> {
116 let mut requirements = Vec::new();
117
118 let requirement_pattern = Regex::new(r"##\s+Requirement\s+(\d+):\s*(.+)")
120 .map_err(|e| GitHubError::invalid_input(format!("Regex error: {}", e)))?;
121
122 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 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 let description = req_content
143 .lines()
144 .next()
145 .unwrap_or("")
146 .trim()
147 .to_string();
148
149 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 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 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 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 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 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 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 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}