ricecoder_github/managers/
project_manager.rs

1//! Project Manager - Handles GitHub Projects management
2
3use crate::errors::{GitHubError, Result};
4use crate::models::{Issue, ProjectCard, PullRequest};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::{debug, info};
8
9/// Project column status
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ColumnStatus {
13    /// Todo column
14    Todo,
15    /// In Progress column
16    InProgress,
17    /// In Review column
18    InReview,
19    /// Done column
20    Done,
21}
22
23impl ColumnStatus {
24    /// Get the string representation
25    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/// Project metrics
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ProjectMetrics {
38    /// Total cards
39    pub total_cards: u32,
40    /// Cards in todo
41    pub todo_count: u32,
42    /// Cards in progress
43    pub in_progress_count: u32,
44    /// Cards in review
45    pub in_review_count: u32,
46    /// Cards done
47    pub done_count: u32,
48    /// Progress percentage (0-100)
49    pub progress_percentage: u32,
50}
51
52impl ProjectMetrics {
53    /// Calculate progress percentage
54    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/// Automation rule for project cards
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct AutomationRule {
65    /// Rule name
66    pub name: String,
67    /// Trigger condition (e.g., "pr_opened", "issue_labeled")
68    pub trigger: String,
69    /// Target column status
70    pub target_column: ColumnStatus,
71    /// Optional filter (e.g., label name)
72    pub filter: Option<String>,
73}
74
75/// Project status report
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ProjectStatusReport {
78    /// Project name
79    pub project_name: String,
80    /// Report timestamp
81    pub timestamp: chrono::DateTime<chrono::Utc>,
82    /// Current metrics
83    pub metrics: ProjectMetrics,
84    /// Cards by column
85    pub cards_by_column: HashMap<String, Vec<ProjectCard>>,
86    /// Recent activity
87    pub recent_activity: Vec<String>,
88}
89
90/// Project Manager
91#[derive(Debug, Clone)]
92pub struct ProjectManager {
93    /// Project ID
94    project_id: u64,
95    /// Project name
96    project_name: String,
97    /// Column mappings (status -> column_id)
98    column_mappings: HashMap<ColumnStatus, u64>,
99    /// Automation rules
100    automation_rules: Vec<AutomationRule>,
101    /// Cards cache
102    cards_cache: HashMap<u64, ProjectCard>,
103}
104
105impl ProjectManager {
106    /// Create a new ProjectManager
107    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    /// Set column mapping
118    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    /// Add automation rule
124    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    /// Create a project card from an issue
130    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    /// Create a project card from a PR
155    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    /// Move card to a column
180    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    /// Get card by ID
216    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    /// Get all cards
224    pub fn get_all_cards(&self) -> Vec<ProjectCard> {
225        self.cards_cache.values().cloned().collect()
226    }
227
228    /// Calculate project metrics
229    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    /// Apply automation rules to a card
262    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    /// Generate project status report
279    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    /// Get project ID
312    pub fn project_id(&self) -> u64 {
313        self.project_id
314    }
315
316    /// Get project name
317    pub fn project_name(&self) -> &str {
318        &self.project_name
319    }
320
321    /// Get column mappings
322    pub fn column_mappings(&self) -> &HashMap<ColumnStatus, u64> {
323        &self.column_mappings
324    }
325
326    /// Get automation rules
327    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}