1use crate::errors::{GitHubError, Result};
4use crate::models::{PullRequest, PrStatus};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::{debug, info};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct PrComment {
12 pub id: u64,
14 pub body: String,
16 pub author: String,
18 pub created_at: chrono::DateTime<chrono::Utc>,
20 pub updated_at: chrono::DateTime<chrono::Utc>,
22}
23
24impl PrComment {
25 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "UPPERCASE")]
40pub enum ReviewState {
41 Approved,
43 ChangesRequested,
45 Commented,
47 Dismissed,
49 Pending,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct PrReview {
56 pub id: u64,
58 pub reviewer: String,
60 pub state: ReviewState,
62 pub body: String,
64 pub created_at: chrono::DateTime<chrono::Utc>,
66}
67
68impl PrReview {
69 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 pub fn approval(reviewer: impl Into<String>) -> Self {
82 Self::new(reviewer, ReviewState::Approved, "Approved")
83 }
84
85 pub fn changes_requested(reviewer: impl Into<String>, body: impl Into<String>) -> Self {
87 Self::new(reviewer, ReviewState::ChangesRequested, body)
88 }
89
90 pub fn comment(reviewer: impl Into<String>, body: impl Into<String>) -> Self {
92 Self::new(reviewer, ReviewState::Commented, body)
93 }
94}
95
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct PrUpdateOptions {
99 pub title: Option<String>,
101 pub body: Option<String>,
103 pub state: Option<PrStatus>,
105 pub draft: Option<bool>,
107}
108
109impl PrUpdateOptions {
110 pub fn new() -> Self {
112 Self::default()
113 }
114
115 pub fn with_title(mut self, title: impl Into<String>) -> Self {
117 self.title = Some(title.into());
118 self
119 }
120
121 pub fn with_body(mut self, body: impl Into<String>) -> Self {
123 self.body = Some(body.into());
124 self
125 }
126
127 pub fn with_state(mut self, state: PrStatus) -> Self {
129 self.state = Some(state);
130 self
131 }
132
133 pub fn with_draft(mut self, draft: bool) -> Self {
135 self.draft = Some(draft);
136 self
137 }
138
139 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#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ProgressUpdate {
148 pub title: String,
150 pub description: String,
152 pub status: String,
154 pub progress_percent: u32,
156 pub metadata: HashMap<String, String>,
158}
159
160impl ProgressUpdate {
161 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 pub fn with_description(mut self, description: impl Into<String>) -> Self {
174 self.description = description.into();
175 self
176 }
177
178 pub fn with_progress(mut self, percent: u32) -> Self {
180 self.progress_percent = percent.min(100);
181 self
182 }
183
184 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 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
211pub struct PrOperations;
213
214impl PrOperations {
215 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 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 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 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 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 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 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 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 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 pub fn can_approve(pr: &PullRequest) -> bool {
372 matches!(pr.status, PrStatus::Open | PrStatus::Draft)
373 }
374
375 pub fn can_merge(pr: &PullRequest) -> bool {
377 matches!(pr.status, PrStatus::Open)
378 }
379
380 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}