ricecoder_github/managers/
pr_operations.rs

1//! PR Operations - Handles PR updates, comments, and reviews
2
3use crate::errors::{GitHubError, Result};
4use crate::models::{PullRequest, PrStatus};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::{debug, info};
8
9/// PR comment
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct PrComment {
12    /// Comment ID
13    pub id: u64,
14    /// Comment body
15    pub body: String,
16    /// Author
17    pub author: String,
18    /// Created at timestamp
19    pub created_at: chrono::DateTime<chrono::Utc>,
20    /// Updated at timestamp
21    pub updated_at: chrono::DateTime<chrono::Utc>,
22}
23
24impl PrComment {
25    /// Create a new PR comment
26    pub fn new(body: impl Into<String>, author: impl Into<String>) -> Self {
27        Self {
28            id: 0,
29            body: body.into(),
30            author: author.into(),
31            created_at: chrono::Utc::now(),
32            updated_at: chrono::Utc::now(),
33        }
34    }
35}
36
37/// PR review state
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "UPPERCASE")]
40pub enum ReviewState {
41    /// Approved
42    Approved,
43    /// Changes requested
44    ChangesRequested,
45    /// Commented
46    Commented,
47    /// Dismissed
48    Dismissed,
49    /// Pending
50    Pending,
51}
52
53/// PR review
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct PrReview {
56    /// Review ID
57    pub id: u64,
58    /// Reviewer
59    pub reviewer: String,
60    /// Review state
61    pub state: ReviewState,
62    /// Review body
63    pub body: String,
64    /// Created at timestamp
65    pub created_at: chrono::DateTime<chrono::Utc>,
66}
67
68impl PrReview {
69    /// Create a new PR review
70    pub fn new(reviewer: impl Into<String>, state: ReviewState, body: impl Into<String>) -> Self {
71        Self {
72            id: 0,
73            reviewer: reviewer.into(),
74            state,
75            body: body.into(),
76            created_at: chrono::Utc::now(),
77        }
78    }
79
80    /// Create an approval review
81    pub fn approval(reviewer: impl Into<String>) -> Self {
82        Self::new(reviewer, ReviewState::Approved, "Approved")
83    }
84
85    /// Create a changes requested review
86    pub fn changes_requested(reviewer: impl Into<String>, body: impl Into<String>) -> Self {
87        Self::new(reviewer, ReviewState::ChangesRequested, body)
88    }
89
90    /// Create a comment review
91    pub fn comment(reviewer: impl Into<String>, body: impl Into<String>) -> Self {
92        Self::new(reviewer, ReviewState::Commented, body)
93    }
94}
95
96/// PR update options
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct PrUpdateOptions {
99    /// New title (optional)
100    pub title: Option<String>,
101    /// New body (optional)
102    pub body: Option<String>,
103    /// New state (optional)
104    pub state: Option<PrStatus>,
105    /// Draft status (optional)
106    pub draft: Option<bool>,
107}
108
109impl PrUpdateOptions {
110    /// Create new update options
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /// Set title
116    pub fn with_title(mut self, title: impl Into<String>) -> Self {
117        self.title = Some(title.into());
118        self
119    }
120
121    /// Set body
122    pub fn with_body(mut self, body: impl Into<String>) -> Self {
123        self.body = Some(body.into());
124        self
125    }
126
127    /// Set state
128    pub fn with_state(mut self, state: PrStatus) -> Self {
129        self.state = Some(state);
130        self
131    }
132
133    /// Set draft status
134    pub fn with_draft(mut self, draft: bool) -> Self {
135        self.draft = Some(draft);
136        self
137    }
138
139    /// Check if any updates are specified
140    pub fn has_updates(&self) -> bool {
141        self.title.is_some() || self.body.is_some() || self.state.is_some() || self.draft.is_some()
142    }
143}
144
145/// Progress update for PR
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ProgressUpdate {
148    /// Update title
149    pub title: String,
150    /// Update description
151    pub description: String,
152    /// Status (e.g., "In Progress", "Completed", "Blocked")
153    pub status: String,
154    /// Percentage complete (0-100)
155    pub progress_percent: u32,
156    /// Additional metadata
157    pub metadata: HashMap<String, String>,
158}
159
160impl ProgressUpdate {
161    /// Create a new progress update
162    pub fn new(title: impl Into<String>, status: impl Into<String>) -> Self {
163        Self {
164            title: title.into(),
165            description: String::new(),
166            status: status.into(),
167            progress_percent: 0,
168            metadata: HashMap::new(),
169        }
170    }
171
172    /// Set description
173    pub fn with_description(mut self, description: impl Into<String>) -> Self {
174        self.description = description.into();
175        self
176    }
177
178    /// Set progress percentage
179    pub fn with_progress(mut self, percent: u32) -> Self {
180        self.progress_percent = percent.min(100);
181        self
182    }
183
184    /// Add metadata
185    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
186        self.metadata.insert(key.into(), value.into());
187        self
188    }
189
190    /// Format as comment body
191    pub fn format_as_comment(&self) -> String {
192        let mut comment = format!("## {}\n\n", self.title);
193        comment.push_str(&format!("**Status**: {}\n", self.status));
194        comment.push_str(&format!("**Progress**: {}%\n\n", self.progress_percent));
195
196        if !self.description.is_empty() {
197            comment.push_str(&format!("{}\n\n", self.description));
198        }
199
200        if !self.metadata.is_empty() {
201            comment.push_str("### Details\n\n");
202            for (key, value) in &self.metadata {
203                comment.push_str(&format!("- **{}**: {}\n", key, value));
204            }
205        }
206
207        comment
208    }
209}
210
211/// PR Operations Manager
212pub struct PrOperations;
213
214impl PrOperations {
215    /// Update PR with new information
216    pub fn update_pr(pr: &mut PullRequest, options: PrUpdateOptions) -> Result<()> {
217        if !options.has_updates() {
218            return Ok(());
219        }
220
221        debug!("Updating PR with new information");
222
223        if let Some(title) = options.title {
224            if title.is_empty() {
225                return Err(GitHubError::invalid_input("PR title cannot be empty"));
226            }
227            pr.title = title;
228        }
229
230        if let Some(body) = options.body {
231            if body.is_empty() {
232                return Err(GitHubError::invalid_input("PR body cannot be empty"));
233            }
234            pr.body = body;
235        }
236
237        if let Some(state) = options.state {
238            pr.status = state;
239        }
240
241        if let Some(draft) = options.draft {
242            pr.status = if draft { PrStatus::Draft } else { PrStatus::Open };
243        }
244
245        pr.updated_at = chrono::Utc::now();
246
247        info!(
248            pr_number = pr.number,
249            "PR updated successfully"
250        );
251
252        Ok(())
253    }
254
255    /// Add a comment to PR
256    pub fn add_comment(pr: &mut PullRequest, comment: PrComment) -> Result<()> {
257        if comment.body.is_empty() {
258            return Err(GitHubError::invalid_input("Comment body cannot be empty"));
259        }
260
261        debug!(
262            pr_number = pr.number,
263            comment_author = %comment.author,
264            "Adding comment to PR"
265        );
266
267        // Append comment to PR body (in real implementation, this would be a separate API call)
268        pr.body.push_str("\n\n---\n");
269        pr.body.push_str(&format!("**Comment from {}**:\n\n{}", comment.author, comment.body));
270
271        info!(
272            pr_number = pr.number,
273            "Comment added to PR"
274        );
275
276        Ok(())
277    }
278
279    /// Add a progress update comment to PR
280    pub fn add_progress_update(pr: &mut PullRequest, update: ProgressUpdate) -> Result<()> {
281        let comment_body = update.format_as_comment();
282        let comment = PrComment::new(comment_body, "ricecoder-bot");
283        Self::add_comment(pr, comment)
284    }
285
286    /// Add a review to PR
287    pub fn add_review(pr: &mut PullRequest, review: PrReview) -> Result<()> {
288        if review.body.is_empty() && review.state != ReviewState::Approved {
289            return Err(GitHubError::invalid_input("Review body cannot be empty"));
290        }
291
292        debug!(
293            pr_number = pr.number,
294            reviewer = %review.reviewer,
295            state = ?review.state,
296            "Adding review to PR"
297        );
298
299        // Append review to PR body (in real implementation, this would be a separate API call)
300        let state_str = match review.state {
301            ReviewState::Approved => "✅ Approved",
302            ReviewState::ChangesRequested => "❌ Changes Requested",
303            ReviewState::Commented => "💬 Commented",
304            ReviewState::Dismissed => "🚫 Dismissed",
305            ReviewState::Pending => "⏳ Pending",
306        };
307
308        pr.body.push_str("\n\n---\n");
309        pr.body.push_str(&format!(
310            "**Review from {} ({})**:\n\n{}",
311            review.reviewer, state_str, review.body
312        ));
313
314        info!(
315            pr_number = pr.number,
316            "Review added to PR"
317        );
318
319        Ok(())
320    }
321
322    /// Validate PR update options
323    pub fn validate_update_options(options: &PrUpdateOptions) -> Result<()> {
324        if let Some(title) = &options.title {
325            if title.is_empty() {
326                return Err(GitHubError::invalid_input("PR title cannot be empty"));
327            }
328            if title.len() > 256 {
329                return Err(GitHubError::invalid_input(
330                    "PR title cannot exceed 256 characters",
331                ));
332            }
333        }
334
335        if let Some(body) = &options.body {
336            if body.is_empty() {
337                return Err(GitHubError::invalid_input("PR body cannot be empty"));
338            }
339        }
340
341        Ok(())
342    }
343
344    /// Validate comment
345    pub fn validate_comment(comment: &PrComment) -> Result<()> {
346        if comment.body.is_empty() {
347            return Err(GitHubError::invalid_input("Comment body cannot be empty"));
348        }
349
350        if comment.author.is_empty() {
351            return Err(GitHubError::invalid_input("Comment author cannot be empty"));
352        }
353
354        Ok(())
355    }
356
357    /// Validate review
358    pub fn validate_review(review: &PrReview) -> Result<()> {
359        if review.reviewer.is_empty() {
360            return Err(GitHubError::invalid_input("Reviewer cannot be empty"));
361        }
362
363        if review.body.is_empty() && review.state != ReviewState::Approved {
364            return Err(GitHubError::invalid_input("Review body cannot be empty"));
365        }
366
367        Ok(())
368    }
369
370    /// Check if PR can be approved
371    pub fn can_approve(pr: &PullRequest) -> bool {
372        matches!(pr.status, PrStatus::Open | PrStatus::Draft)
373    }
374
375    /// Check if PR can be merged
376    pub fn can_merge(pr: &PullRequest) -> bool {
377        matches!(pr.status, PrStatus::Open)
378    }
379
380    /// Check if PR can be closed
381    pub fn can_close(pr: &PullRequest) -> bool {
382        matches!(pr.status, PrStatus::Open | PrStatus::Draft)
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn test_pr_comment_creation() {
392        let comment = PrComment::new("This is a comment", "user123");
393        assert_eq!(comment.body, "This is a comment");
394        assert_eq!(comment.author, "user123");
395    }
396
397    #[test]
398    fn test_pr_review_approval() {
399        let review = PrReview::approval("reviewer1");
400        assert_eq!(review.reviewer, "reviewer1");
401        assert_eq!(review.state, ReviewState::Approved);
402    }
403
404    #[test]
405    fn test_pr_review_changes_requested() {
406        let review = PrReview::changes_requested("reviewer1", "Please fix this");
407        assert_eq!(review.state, ReviewState::ChangesRequested);
408        assert_eq!(review.body, "Please fix this");
409    }
410
411    #[test]
412    fn test_pr_update_options_creation() {
413        let options = PrUpdateOptions::new();
414        assert!(!options.has_updates());
415    }
416
417    #[test]
418    fn test_pr_update_options_with_title() {
419        let options = PrUpdateOptions::new().with_title("New Title");
420        assert!(options.has_updates());
421        assert_eq!(options.title, Some("New Title".to_string()));
422    }
423
424    #[test]
425    fn test_pr_update_options_with_draft() {
426        let options = PrUpdateOptions::new().with_draft(true);
427        assert!(options.has_updates());
428        assert_eq!(options.draft, Some(true));
429    }
430
431    #[test]
432    fn test_progress_update_creation() {
433        let update = ProgressUpdate::new("Task 1", "In Progress");
434        assert_eq!(update.title, "Task 1");
435        assert_eq!(update.status, "In Progress");
436        assert_eq!(update.progress_percent, 0);
437    }
438
439    #[test]
440    fn test_progress_update_with_progress() {
441        let update = ProgressUpdate::new("Task 1", "In Progress")
442            .with_progress(50);
443        assert_eq!(update.progress_percent, 50);
444    }
445
446    #[test]
447    fn test_progress_update_with_progress_capped() {
448        let update = ProgressUpdate::new("Task 1", "In Progress")
449            .with_progress(150);
450        assert_eq!(update.progress_percent, 100);
451    }
452
453    #[test]
454    fn test_progress_update_format_as_comment() {
455        let update = ProgressUpdate::new("Task 1", "In Progress")
456            .with_progress(50)
457            .with_description("Working on implementation");
458        let comment = update.format_as_comment();
459        assert!(comment.contains("Task 1"));
460        assert!(comment.contains("In Progress"));
461        assert!(comment.contains("50%"));
462        assert!(comment.contains("Working on implementation"));
463    }
464
465    #[test]
466    fn test_update_pr_title() {
467        let mut pr = PullRequest {
468            id: 1,
469            number: 1,
470            title: "Old Title".to_string(),
471            body: "Body".to_string(),
472            branch: "feature/test".to_string(),
473            base: "main".to_string(),
474            status: PrStatus::Open,
475            files: Vec::new(),
476            created_at: chrono::Utc::now(),
477            updated_at: chrono::Utc::now(),
478        };
479
480        let options = PrUpdateOptions::new().with_title("New Title");
481        PrOperations::update_pr(&mut pr, options).unwrap();
482        assert_eq!(pr.title, "New Title");
483    }
484
485    #[test]
486    fn test_update_pr_body() {
487        let mut pr = PullRequest {
488            id: 1,
489            number: 1,
490            title: "Title".to_string(),
491            body: "Old Body".to_string(),
492            branch: "feature/test".to_string(),
493            base: "main".to_string(),
494            status: PrStatus::Open,
495            files: Vec::new(),
496            created_at: chrono::Utc::now(),
497            updated_at: chrono::Utc::now(),
498        };
499
500        let options = PrUpdateOptions::new().with_body("New Body");
501        PrOperations::update_pr(&mut pr, options).unwrap();
502        assert_eq!(pr.body, "New Body");
503    }
504
505    #[test]
506    fn test_update_pr_state() {
507        let mut pr = PullRequest {
508            id: 1,
509            number: 1,
510            title: "Title".to_string(),
511            body: "Body".to_string(),
512            branch: "feature/test".to_string(),
513            base: "main".to_string(),
514            status: PrStatus::Open,
515            files: Vec::new(),
516            created_at: chrono::Utc::now(),
517            updated_at: chrono::Utc::now(),
518        };
519
520        let options = PrUpdateOptions::new().with_state(PrStatus::Closed);
521        PrOperations::update_pr(&mut pr, options).unwrap();
522        assert_eq!(pr.status, PrStatus::Closed);
523    }
524
525    #[test]
526    fn test_add_comment() {
527        let mut pr = PullRequest {
528            id: 1,
529            number: 1,
530            title: "Title".to_string(),
531            body: "Body".to_string(),
532            branch: "feature/test".to_string(),
533            base: "main".to_string(),
534            status: PrStatus::Open,
535            files: Vec::new(),
536            created_at: chrono::Utc::now(),
537            updated_at: chrono::Utc::now(),
538        };
539
540        let comment = PrComment::new("This is a comment", "user1");
541        PrOperations::add_comment(&mut pr, comment).unwrap();
542        assert!(pr.body.contains("This is a comment"));
543        assert!(pr.body.contains("user1"));
544    }
545
546    #[test]
547    fn test_add_progress_update() {
548        let mut pr = PullRequest {
549            id: 1,
550            number: 1,
551            title: "Title".to_string(),
552            body: "Body".to_string(),
553            branch: "feature/test".to_string(),
554            base: "main".to_string(),
555            status: PrStatus::Open,
556            files: Vec::new(),
557            created_at: chrono::Utc::now(),
558            updated_at: chrono::Utc::now(),
559        };
560
561        let update = ProgressUpdate::new("Task 1", "In Progress")
562            .with_progress(50);
563        PrOperations::add_progress_update(&mut pr, update).unwrap();
564        assert!(pr.body.contains("Task 1"));
565        assert!(pr.body.contains("50%"));
566    }
567
568    #[test]
569    fn test_add_review() {
570        let mut pr = PullRequest {
571            id: 1,
572            number: 1,
573            title: "Title".to_string(),
574            body: "Body".to_string(),
575            branch: "feature/test".to_string(),
576            base: "main".to_string(),
577            status: PrStatus::Open,
578            files: Vec::new(),
579            created_at: chrono::Utc::now(),
580            updated_at: chrono::Utc::now(),
581        };
582
583        let review = PrReview::approval("reviewer1");
584        PrOperations::add_review(&mut pr, review).unwrap();
585        assert!(pr.body.contains("reviewer1"));
586        assert!(pr.body.contains("Approved"));
587    }
588
589    #[test]
590    fn test_validate_update_options_empty_title() {
591        let options = PrUpdateOptions::new().with_title("");
592        assert!(PrOperations::validate_update_options(&options).is_err());
593    }
594
595    #[test]
596    fn test_validate_update_options_title_too_long() {
597        let long_title = "a".repeat(300);
598        let options = PrUpdateOptions::new().with_title(long_title);
599        assert!(PrOperations::validate_update_options(&options).is_err());
600    }
601
602    #[test]
603    fn test_validate_comment_empty_body() {
604        let comment = PrComment {
605            id: 0,
606            body: String::new(),
607            author: "user1".to_string(),
608            created_at: chrono::Utc::now(),
609            updated_at: chrono::Utc::now(),
610        };
611        assert!(PrOperations::validate_comment(&comment).is_err());
612    }
613
614    #[test]
615    fn test_validate_review_empty_body_with_changes_requested() {
616        let review = PrReview {
617            id: 0,
618            reviewer: "reviewer1".to_string(),
619            state: ReviewState::ChangesRequested,
620            body: String::new(),
621            created_at: chrono::Utc::now(),
622        };
623        assert!(PrOperations::validate_review(&review).is_err());
624    }
625
626    #[test]
627    fn test_validate_review_empty_body_with_approval() {
628        let review = PrReview {
629            id: 0,
630            reviewer: "reviewer1".to_string(),
631            state: ReviewState::Approved,
632            body: String::new(),
633            created_at: chrono::Utc::now(),
634        };
635        assert!(PrOperations::validate_review(&review).is_ok());
636    }
637
638    #[test]
639    fn test_can_approve_open_pr() {
640        let pr = PullRequest {
641            id: 1,
642            number: 1,
643            title: "Title".to_string(),
644            body: "Body".to_string(),
645            branch: "feature/test".to_string(),
646            base: "main".to_string(),
647            status: PrStatus::Open,
648            files: Vec::new(),
649            created_at: chrono::Utc::now(),
650            updated_at: chrono::Utc::now(),
651        };
652        assert!(PrOperations::can_approve(&pr));
653    }
654
655    #[test]
656    fn test_can_approve_merged_pr() {
657        let pr = PullRequest {
658            id: 1,
659            number: 1,
660            title: "Title".to_string(),
661            body: "Body".to_string(),
662            branch: "feature/test".to_string(),
663            base: "main".to_string(),
664            status: PrStatus::Merged,
665            files: Vec::new(),
666            created_at: chrono::Utc::now(),
667            updated_at: chrono::Utc::now(),
668        };
669        assert!(!PrOperations::can_approve(&pr));
670    }
671
672    #[test]
673    fn test_can_merge_open_pr() {
674        let pr = PullRequest {
675            id: 1,
676            number: 1,
677            title: "Title".to_string(),
678            body: "Body".to_string(),
679            branch: "feature/test".to_string(),
680            base: "main".to_string(),
681            status: PrStatus::Open,
682            files: Vec::new(),
683            created_at: chrono::Utc::now(),
684            updated_at: chrono::Utc::now(),
685        };
686        assert!(PrOperations::can_merge(&pr));
687    }
688
689    #[test]
690    fn test_can_merge_draft_pr() {
691        let pr = PullRequest {
692            id: 1,
693            number: 1,
694            title: "Title".to_string(),
695            body: "Body".to_string(),
696            branch: "feature/test".to_string(),
697            base: "main".to_string(),
698            status: PrStatus::Draft,
699            files: Vec::new(),
700            created_at: chrono::Utc::now(),
701            updated_at: chrono::Utc::now(),
702        };
703        assert!(!PrOperations::can_merge(&pr));
704    }
705
706    #[test]
707    fn test_can_close_open_pr() {
708        let pr = PullRequest {
709            id: 1,
710            number: 1,
711            title: "Title".to_string(),
712            body: "Body".to_string(),
713            branch: "feature/test".to_string(),
714            base: "main".to_string(),
715            status: PrStatus::Open,
716            files: Vec::new(),
717            created_at: chrono::Utc::now(),
718            updated_at: chrono::Utc::now(),
719        };
720        assert!(PrOperations::can_close(&pr));
721    }
722
723    #[test]
724    fn test_can_close_merged_pr() {
725        let pr = PullRequest {
726            id: 1,
727            number: 1,
728            title: "Title".to_string(),
729            body: "Body".to_string(),
730            branch: "feature/test".to_string(),
731            base: "main".to_string(),
732            status: PrStatus::Merged,
733            files: Vec::new(),
734            created_at: chrono::Utc::now(),
735            updated_at: chrono::Utc::now(),
736        };
737        assert!(!PrOperations::can_close(&pr));
738    }
739}