1use crate::errors::{GitHubError, Result};
4use crate::models::{Issue, ProjectCard, PullRequest};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::{debug, info};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ColumnStatus {
13 Todo,
15 InProgress,
17 InReview,
19 Done,
21}
22
23impl ColumnStatus {
24 pub fn as_str(&self) -> &'static str {
26 match self {
27 ColumnStatus::Todo => "Todo",
28 ColumnStatus::InProgress => "In Progress",
29 ColumnStatus::InReview => "In Review",
30 ColumnStatus::Done => "Done",
31 }
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ProjectMetrics {
38 pub total_cards: u32,
40 pub todo_count: u32,
42 pub in_progress_count: u32,
44 pub in_review_count: u32,
46 pub done_count: u32,
48 pub progress_percentage: u32,
50}
51
52impl ProjectMetrics {
53 pub fn calculate_progress(&self) -> u32 {
55 if self.total_cards == 0 {
56 return 0;
57 }
58 ((self.done_count as f64 / self.total_cards as f64) * 100.0) as u32
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct AutomationRule {
65 pub name: String,
67 pub trigger: String,
69 pub target_column: ColumnStatus,
71 pub filter: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ProjectStatusReport {
78 pub project_name: String,
80 pub timestamp: chrono::DateTime<chrono::Utc>,
82 pub metrics: ProjectMetrics,
84 pub cards_by_column: HashMap<String, Vec<ProjectCard>>,
86 pub recent_activity: Vec<String>,
88}
89
90#[derive(Debug, Clone)]
92pub struct ProjectManager {
93 project_id: u64,
95 project_name: String,
97 column_mappings: HashMap<ColumnStatus, u64>,
99 automation_rules: Vec<AutomationRule>,
101 cards_cache: HashMap<u64, ProjectCard>,
103}
104
105impl ProjectManager {
106 pub fn new(project_id: u64, project_name: impl Into<String>) -> Self {
108 Self {
109 project_id,
110 project_name: project_name.into(),
111 column_mappings: HashMap::new(),
112 automation_rules: Vec::new(),
113 cards_cache: HashMap::new(),
114 }
115 }
116
117 pub fn set_column_mapping(&mut self, status: ColumnStatus, column_id: u64) {
119 self.column_mappings.insert(status, column_id);
120 debug!("Set column mapping: {:?} -> {}", status, column_id);
121 }
122
123 pub fn add_automation_rule(&mut self, rule: AutomationRule) {
125 info!("Adding automation rule: {}", rule.name);
126 self.automation_rules.push(rule);
127 }
128
129 pub fn create_card_from_issue(&mut self, issue: &Issue) -> Result<ProjectCard> {
131 let card = ProjectCard {
132 id: issue.id,
133 content_id: issue.id,
134 content_type: "Issue".to_string(),
135 column_id: self
136 .column_mappings
137 .get(&ColumnStatus::Todo)
138 .copied()
139 .ok_or_else(|| {
140 GitHubError::config_error("Todo column not configured")
141 })?,
142 note: Some(format!("Issue #{}: {}", issue.number, issue.title)),
143 };
144
145 self.cards_cache.insert(card.id, card.clone());
146 info!(
147 "Created project card from issue #{}: {}",
148 issue.number, issue.title
149 );
150
151 Ok(card)
152 }
153
154 pub fn create_card_from_pr(&mut self, pr: &PullRequest) -> Result<ProjectCard> {
156 let card = ProjectCard {
157 id: pr.id,
158 content_id: pr.id,
159 content_type: "PullRequest".to_string(),
160 column_id: self
161 .column_mappings
162 .get(&ColumnStatus::InReview)
163 .copied()
164 .ok_or_else(|| {
165 GitHubError::config_error("In Review column not configured")
166 })?,
167 note: Some(format!("PR #{}: {}", pr.number, pr.title)),
168 };
169
170 self.cards_cache.insert(card.id, card.clone());
171 info!(
172 "Created project card from PR #{}: {}",
173 pr.number, pr.title
174 );
175
176 Ok(card)
177 }
178
179 pub fn move_card_to_column(
181 &mut self,
182 card_id: u64,
183 target_status: ColumnStatus,
184 ) -> Result<ProjectCard> {
185 let target_column_id = self
186 .column_mappings
187 .get(&target_status)
188 .copied()
189 .ok_or_else(|| {
190 GitHubError::config_error(format!(
191 "{} column not configured",
192 target_status.as_str()
193 ))
194 })?;
195
196 let mut card = self
197 .cards_cache
198 .get(&card_id)
199 .cloned()
200 .ok_or_else(|| GitHubError::not_found(format!("Card {} not found", card_id)))?;
201
202 card.column_id = target_column_id;
203 self.cards_cache.insert(card_id, card.clone());
204
205 info!(
206 "Moved card {} to column {} ({})",
207 card_id,
208 target_column_id,
209 target_status.as_str()
210 );
211
212 Ok(card)
213 }
214
215 pub fn get_card(&self, card_id: u64) -> Result<ProjectCard> {
217 self.cards_cache
218 .get(&card_id)
219 .cloned()
220 .ok_or_else(|| GitHubError::not_found(format!("Card {} not found", card_id)))
221 }
222
223 pub fn get_all_cards(&self) -> Vec<ProjectCard> {
225 self.cards_cache.values().cloned().collect()
226 }
227
228 pub fn calculate_metrics(&self) -> ProjectMetrics {
230 let total_cards = self.cards_cache.len() as u32;
231 let mut metrics = ProjectMetrics {
232 total_cards,
233 todo_count: 0,
234 in_progress_count: 0,
235 in_review_count: 0,
236 done_count: 0,
237 progress_percentage: 0,
238 };
239
240 let todo_col = self.column_mappings.get(&ColumnStatus::Todo);
241 let in_progress_col = self.column_mappings.get(&ColumnStatus::InProgress);
242 let in_review_col = self.column_mappings.get(&ColumnStatus::InReview);
243 let done_col = self.column_mappings.get(&ColumnStatus::Done);
244
245 for card in self.cards_cache.values() {
246 if Some(&card.column_id) == todo_col {
247 metrics.todo_count += 1;
248 } else if Some(&card.column_id) == in_progress_col {
249 metrics.in_progress_count += 1;
250 } else if Some(&card.column_id) == in_review_col {
251 metrics.in_review_count += 1;
252 } else if Some(&card.column_id) == done_col {
253 metrics.done_count += 1;
254 }
255 }
256
257 metrics.progress_percentage = metrics.calculate_progress();
258 metrics
259 }
260
261 pub fn apply_automation_rules(&mut self, card_id: u64, trigger: &str) -> Result<()> {
263 let matching_rules: Vec<_> = self
264 .automation_rules
265 .iter()
266 .filter(|rule| rule.trigger == trigger)
267 .cloned()
268 .collect();
269
270 for rule in matching_rules {
271 debug!("Applying automation rule: {}", rule.name);
272 self.move_card_to_column(card_id, rule.target_column)?;
273 }
274
275 Ok(())
276 }
277
278 pub fn generate_status_report(&self) -> ProjectStatusReport {
280 let metrics = self.calculate_metrics();
281 let mut cards_by_column: HashMap<String, Vec<ProjectCard>> = HashMap::new();
282
283 for (status, column_id) in &self.column_mappings {
284 let cards: Vec<_> = self
285 .cards_cache
286 .values()
287 .filter(|card| card.column_id == *column_id)
288 .cloned()
289 .collect();
290 cards_by_column.insert(status.as_str().to_string(), cards);
291 }
292
293 let recent_activity = vec![
294 format!("Total cards: {}", metrics.total_cards),
295 format!("Todo: {}", metrics.todo_count),
296 format!("In Progress: {}", metrics.in_progress_count),
297 format!("In Review: {}", metrics.in_review_count),
298 format!("Done: {}", metrics.done_count),
299 format!("Progress: {}%", metrics.progress_percentage),
300 ];
301
302 ProjectStatusReport {
303 project_name: self.project_name.clone(),
304 timestamp: chrono::Utc::now(),
305 metrics,
306 cards_by_column,
307 recent_activity,
308 }
309 }
310
311 pub fn project_id(&self) -> u64 {
313 self.project_id
314 }
315
316 pub fn project_name(&self) -> &str {
318 &self.project_name
319 }
320
321 pub fn column_mappings(&self) -> &HashMap<ColumnStatus, u64> {
323 &self.column_mappings
324 }
325
326 pub fn automation_rules(&self) -> &[AutomationRule] {
328 &self.automation_rules
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn test_create_project_manager() {
338 let manager = ProjectManager::new(1, "Test Project");
339 assert_eq!(manager.project_id(), 1);
340 assert_eq!(manager.project_name(), "Test Project");
341 }
342
343 #[test]
344 fn test_set_column_mapping() {
345 let mut manager = ProjectManager::new(1, "Test Project");
346 manager.set_column_mapping(ColumnStatus::Todo, 100);
347 assert_eq!(manager.column_mappings().get(&ColumnStatus::Todo), Some(&100));
348 }
349
350 #[test]
351 fn test_add_automation_rule() {
352 let mut manager = ProjectManager::new(1, "Test Project");
353 let rule = AutomationRule {
354 name: "Test Rule".to_string(),
355 trigger: "pr_opened".to_string(),
356 target_column: ColumnStatus::InReview,
357 filter: None,
358 };
359 manager.add_automation_rule(rule);
360 assert_eq!(manager.automation_rules().len(), 1);
361 }
362
363 #[test]
364 fn test_create_card_from_issue() {
365 let mut manager = ProjectManager::new(1, "Test Project");
366 manager.set_column_mapping(ColumnStatus::Todo, 100);
367
368 let issue = Issue {
369 id: 1,
370 number: 1,
371 title: "Test Issue".to_string(),
372 body: "Test body".to_string(),
373 labels: vec![],
374 assignees: vec![],
375 status: crate::models::IssueStatus::Open,
376 created_at: chrono::Utc::now(),
377 updated_at: chrono::Utc::now(),
378 };
379
380 let card = manager.create_card_from_issue(&issue).unwrap();
381 assert_eq!(card.content_id, 1);
382 assert_eq!(card.content_type, "Issue");
383 assert_eq!(card.column_id, 100);
384 }
385
386 #[test]
387 fn test_move_card_to_column() {
388 let mut manager = ProjectManager::new(1, "Test Project");
389 manager.set_column_mapping(ColumnStatus::Todo, 100);
390 manager.set_column_mapping(ColumnStatus::InProgress, 101);
391
392 let issue = Issue {
393 id: 1,
394 number: 1,
395 title: "Test Issue".to_string(),
396 body: "Test body".to_string(),
397 labels: vec![],
398 assignees: vec![],
399 status: crate::models::IssueStatus::Open,
400 created_at: chrono::Utc::now(),
401 updated_at: chrono::Utc::now(),
402 };
403
404 let card = manager.create_card_from_issue(&issue).unwrap();
405 assert_eq!(card.column_id, 100);
406
407 let moved_card = manager
408 .move_card_to_column(card.id, ColumnStatus::InProgress)
409 .unwrap();
410 assert_eq!(moved_card.column_id, 101);
411 }
412
413 #[test]
414 fn test_calculate_metrics() {
415 let mut manager = ProjectManager::new(1, "Test Project");
416 manager.set_column_mapping(ColumnStatus::Todo, 100);
417 manager.set_column_mapping(ColumnStatus::Done, 103);
418
419 let issue1 = Issue {
420 id: 1,
421 number: 1,
422 title: "Issue 1".to_string(),
423 body: "Body 1".to_string(),
424 labels: vec![],
425 assignees: vec![],
426 status: crate::models::IssueStatus::Open,
427 created_at: chrono::Utc::now(),
428 updated_at: chrono::Utc::now(),
429 };
430
431 let issue2 = Issue {
432 id: 2,
433 number: 2,
434 title: "Issue 2".to_string(),
435 body: "Body 2".to_string(),
436 labels: vec![],
437 assignees: vec![],
438 status: crate::models::IssueStatus::Closed,
439 created_at: chrono::Utc::now(),
440 updated_at: chrono::Utc::now(),
441 };
442
443 manager.create_card_from_issue(&issue1).unwrap();
444 let card2 = manager.create_card_from_issue(&issue2).unwrap();
445 manager
446 .move_card_to_column(card2.id, ColumnStatus::Done)
447 .unwrap();
448
449 let metrics = manager.calculate_metrics();
450 assert_eq!(metrics.total_cards, 2);
451 assert_eq!(metrics.todo_count, 1);
452 assert_eq!(metrics.done_count, 1);
453 assert_eq!(metrics.progress_percentage, 50);
454 }
455
456 #[test]
457 fn test_generate_status_report() {
458 let mut manager = ProjectManager::new(1, "Test Project");
459 manager.set_column_mapping(ColumnStatus::Todo, 100);
460
461 let issue = Issue {
462 id: 1,
463 number: 1,
464 title: "Test Issue".to_string(),
465 body: "Test body".to_string(),
466 labels: vec![],
467 assignees: vec![],
468 status: crate::models::IssueStatus::Open,
469 created_at: chrono::Utc::now(),
470 updated_at: chrono::Utc::now(),
471 };
472
473 manager.create_card_from_issue(&issue).unwrap();
474
475 let report = manager.generate_status_report();
476 assert_eq!(report.project_name, "Test Project");
477 assert_eq!(report.metrics.total_cards, 1);
478 assert!(!report.recent_activity.is_empty());
479 }
480}