ricecoder_github/managers/
pr_manager.rs

1//! PR Manager - Handles pull request creation and management
2
3use crate::errors::{GitHubError, Result};
4use crate::models::{FileChange, PullRequest, PrStatus};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::{debug, info};
8
9/// PR template configuration
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct PrTemplate {
12    /// Template title (supports placeholders like {{title}}, {{issue_number}})
13    pub title_template: String,
14    /// Template body (supports placeholders)
15    pub body_template: String,
16}
17
18impl Default for PrTemplate {
19    fn default() -> Self {
20        Self {
21            title_template: "{{title}}".to_string(),
22            body_template: "## Description\n\n{{description}}\n\n## Related Issues\n\n{{related_issues}}".to_string(),
23        }
24    }
25}
26
27/// Task context for PR creation
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TaskContext {
30    /// Task title
31    pub title: String,
32    /// Task description
33    pub description: String,
34    /// Related issue numbers
35    pub related_issues: Vec<u32>,
36    /// Files changed
37    pub files: Vec<FileChange>,
38    /// Additional metadata
39    pub metadata: HashMap<String, String>,
40}
41
42impl TaskContext {
43    /// Create a new task context
44    pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
45        Self {
46            title: title.into(),
47            description: description.into(),
48            related_issues: Vec::new(),
49            files: Vec::new(),
50            metadata: HashMap::new(),
51        }
52    }
53
54    /// Add a related issue
55    pub fn with_issue(mut self, issue_number: u32) -> Self {
56        self.related_issues.push(issue_number);
57        self
58    }
59
60    /// Add related issues
61    pub fn with_issues(mut self, issues: Vec<u32>) -> Self {
62        self.related_issues.extend(issues);
63        self
64    }
65
66    /// Add a file change
67    pub fn with_file(mut self, file: FileChange) -> Self {
68        self.files.push(file);
69        self
70    }
71
72    /// Add files
73    pub fn with_files(mut self, files: Vec<FileChange>) -> Self {
74        self.files.extend(files);
75        self
76    }
77
78    /// Add metadata
79    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
80        self.metadata.insert(key.into(), value.into());
81        self
82    }
83}
84
85/// PR creation options
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PrOptions {
88    /// Branch name for the PR
89    pub branch: String,
90    /// Base branch (default: main)
91    pub base_branch: String,
92    /// Is draft PR
93    pub draft: bool,
94    /// PR template to use
95    pub template: Option<PrTemplate>,
96}
97
98impl Default for PrOptions {
99    fn default() -> Self {
100        Self {
101            branch: "feature/auto-pr".to_string(),
102            base_branch: "main".to_string(),
103            draft: false,
104            template: None,
105        }
106    }
107}
108
109impl PrOptions {
110    /// Create new PR options
111    pub fn new(branch: impl Into<String>) -> Self {
112        Self {
113            branch: branch.into(),
114            ..Default::default()
115        }
116    }
117
118    /// Set as draft
119    pub fn as_draft(mut self) -> Self {
120        self.draft = true;
121        self
122    }
123
124    /// Set base branch
125    pub fn with_base_branch(mut self, base: impl Into<String>) -> Self {
126        self.base_branch = base.into();
127        self
128    }
129
130    /// Set template
131    pub fn with_template(mut self, template: PrTemplate) -> Self {
132        self.template = Some(template);
133        self
134    }
135}
136
137/// PR Manager - Handles pull request creation and management
138pub struct PrManager {
139    /// Default PR template
140    default_template: PrTemplate,
141}
142
143impl PrManager {
144    /// Create a new PR manager
145    pub fn new() -> Self {
146        Self {
147            default_template: PrTemplate::default(),
148        }
149    }
150
151    /// Create a new PR manager with custom template
152    pub fn with_template(template: PrTemplate) -> Self {
153        Self {
154            default_template: template,
155        }
156    }
157
158    /// Generate PR title from task context
159    pub fn generate_title(&self, context: &TaskContext, template: Option<&PrTemplate>) -> Result<String> {
160        let template = template.unwrap_or(&self.default_template);
161        let title = self.apply_template(&template.title_template, context)?;
162
163        if title.is_empty() {
164            return Err(GitHubError::invalid_input(
165                "Generated PR title is empty",
166            ));
167        }
168
169        Ok(title)
170    }
171
172    /// Generate PR body from task context
173    pub fn generate_body(&self, context: &TaskContext, template: Option<&PrTemplate>) -> Result<String> {
174        let template = template.unwrap_or(&self.default_template);
175        let body = self.apply_template(&template.body_template, context)?;
176
177        if body.is_empty() {
178            return Err(GitHubError::invalid_input(
179                "Generated PR body is empty",
180            ));
181        }
182
183        Ok(body)
184    }
185
186    /// Apply template with context variables
187    fn apply_template(&self, template: &str, context: &TaskContext) -> Result<String> {
188        let mut result = template.to_string();
189
190        // Replace basic placeholders
191        result = result.replace("{{title}}", &context.title);
192        result = result.replace("{{description}}", &context.description);
193
194        // Replace related issues
195        let issues_str = if context.related_issues.is_empty() {
196            "None".to_string()
197        } else {
198            context
199                .related_issues
200                .iter()
201                .map(|n| format!("Closes #{}", n))
202                .collect::<Vec<_>>()
203                .join("\n")
204        };
205        result = result.replace("{{related_issues}}", &issues_str);
206
207        // Replace files summary
208        let files_summary = if context.files.is_empty() {
209            "No files changed".to_string()
210        } else {
211            format!(
212                "{} files changed: {}",
213                context.files.len(),
214                context
215                    .files
216                    .iter()
217                    .map(|f| f.path.as_str())
218                    .collect::<Vec<_>>()
219                    .join(", ")
220            )
221        };
222        result = result.replace("{{files_summary}}", &files_summary);
223
224        // Replace metadata placeholders
225        for (key, value) in &context.metadata {
226            let placeholder = format!("{{{{{}}}}}", key);
227            result = result.replace(&placeholder, value);
228        }
229
230        Ok(result)
231    }
232
233    /// Validate PR creation inputs
234    pub fn validate_pr_creation(&self, context: &TaskContext, options: &PrOptions) -> Result<()> {
235        // Validate context
236        if context.title.is_empty() {
237            return Err(GitHubError::invalid_input("PR title cannot be empty"));
238        }
239
240        if context.title.len() > 256 {
241            return Err(GitHubError::invalid_input(
242                "PR title cannot exceed 256 characters",
243            ));
244        }
245
246        // Validate options
247        if options.branch.is_empty() {
248            return Err(GitHubError::invalid_input("Branch name cannot be empty"));
249        }
250
251        if options.base_branch.is_empty() {
252            return Err(GitHubError::invalid_input("Base branch cannot be empty"));
253        }
254
255        if options.branch == options.base_branch {
256            return Err(GitHubError::invalid_input(
257                "Branch and base branch cannot be the same",
258            ));
259        }
260
261        Ok(())
262    }
263
264    /// Create a PR from task context
265    pub fn create_pr_from_context(
266        &self,
267        context: TaskContext,
268        options: PrOptions,
269    ) -> Result<PullRequest> {
270        // Validate inputs
271        self.validate_pr_creation(&context, &options)?;
272
273        // Generate title and body
274        let title = self.generate_title(&context, options.template.as_ref())?;
275        let body = self.generate_body(&context, options.template.as_ref())?;
276
277        debug!(
278            title = %title,
279            branch = %options.branch,
280            draft = options.draft,
281            "Creating PR from context"
282        );
283
284        // Create PR object
285        let pr = PullRequest {
286            id: 0, // Will be assigned by GitHub
287            number: 0, // Will be assigned by GitHub
288            title,
289            body,
290            branch: options.branch,
291            base: options.base_branch,
292            status: if options.draft { PrStatus::Draft } else { PrStatus::Open },
293            files: context.files,
294            created_at: chrono::Utc::now(),
295            updated_at: chrono::Utc::now(),
296        };
297
298        info!(
299            pr_title = %pr.title,
300            pr_branch = %pr.branch,
301            "PR created from context"
302        );
303
304        Ok(pr)
305    }
306
307    /// Link PR to related issues
308    pub fn link_to_issues(&self, pr: &mut PullRequest, issue_numbers: Vec<u32>) -> Result<()> {
309        if issue_numbers.is_empty() {
310            return Ok(());
311        }
312
313        debug!(
314            issue_count = issue_numbers.len(),
315            "Linking PR to issues"
316        );
317
318        // Add close keywords to PR body
319        let close_keywords = issue_numbers
320            .iter()
321            .map(|n| format!("Closes #{}", n))
322            .collect::<Vec<_>>()
323            .join("\n");
324
325        if !pr.body.contains("Closes #") {
326            pr.body.push_str("\n\n");
327            pr.body.push_str(&close_keywords);
328        }
329
330        info!(
331            issue_count = issue_numbers.len(),
332            "PR linked to issues"
333        );
334
335        Ok(())
336    }
337
338    /// Validate PR content
339    pub fn validate_pr_content(&self, pr: &PullRequest) -> Result<()> {
340        if pr.title.is_empty() {
341            return Err(GitHubError::invalid_input("PR title cannot be empty"));
342        }
343
344        if pr.body.is_empty() {
345            return Err(GitHubError::invalid_input("PR body cannot be empty"));
346        }
347
348        if pr.branch.is_empty() {
349            return Err(GitHubError::invalid_input("PR branch cannot be empty"));
350        }
351
352        if pr.base.is_empty() {
353            return Err(GitHubError::invalid_input("PR base branch cannot be empty"));
354        }
355
356        Ok(())
357    }
358}
359
360impl Default for PrManager {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_task_context_creation() {
372        let context = TaskContext::new("Test PR", "This is a test PR");
373        assert_eq!(context.title, "Test PR");
374        assert_eq!(context.description, "This is a test PR");
375        assert!(context.related_issues.is_empty());
376        assert!(context.files.is_empty());
377    }
378
379    #[test]
380    fn test_task_context_with_issues() {
381        let context = TaskContext::new("Test PR", "Description")
382            .with_issue(123)
383            .with_issue(456);
384        assert_eq!(context.related_issues.len(), 2);
385        assert!(context.related_issues.contains(&123));
386        assert!(context.related_issues.contains(&456));
387    }
388
389    #[test]
390    fn test_pr_options_creation() {
391        let options = PrOptions::new("feature/test");
392        assert_eq!(options.branch, "feature/test");
393        assert_eq!(options.base_branch, "main");
394        assert!(!options.draft);
395    }
396
397    #[test]
398    fn test_pr_options_as_draft() {
399        let options = PrOptions::new("feature/test").as_draft();
400        assert!(options.draft);
401    }
402
403    #[test]
404    fn test_pr_manager_creation() {
405        let manager = PrManager::new();
406        assert_eq!(manager.default_template.title_template, "{{title}}");
407    }
408
409    #[test]
410    fn test_generate_title_simple() {
411        let manager = PrManager::new();
412        let context = TaskContext::new("Fix bug", "Fixed a critical bug");
413        let title = manager.generate_title(&context, None).unwrap();
414        assert_eq!(title, "Fix bug");
415    }
416
417    #[test]
418    fn test_generate_title_empty() {
419        let manager = PrManager::new();
420        let context = TaskContext::new("", "Description");
421        let result = manager.generate_title(&context, None);
422        assert!(result.is_err());
423    }
424
425    #[test]
426    fn test_generate_body_with_issues() {
427        let manager = PrManager::new();
428        let context = TaskContext::new("Test", "Description").with_issue(123);
429        let body = manager.generate_body(&context, None).unwrap();
430        assert!(body.contains("Closes #123"));
431    }
432
433    #[test]
434    fn test_generate_body_no_issues() {
435        let manager = PrManager::new();
436        let context = TaskContext::new("Test", "Description");
437        let body = manager.generate_body(&context, None).unwrap();
438        assert!(body.contains("None"));
439    }
440
441    #[test]
442    fn test_apply_template_with_metadata() {
443        let manager = PrManager::new();
444        let context = TaskContext::new("Test", "Description")
445            .with_metadata("custom_field", "custom_value");
446        let template = "Title: {{title}}, Custom: {{custom_field}}";
447        let result = manager.apply_template(template, &context).unwrap();
448        assert_eq!(result, "Title: Test, Custom: custom_value");
449    }
450
451    #[test]
452    fn test_validate_pr_creation_success() {
453        let manager = PrManager::new();
454        let context = TaskContext::new("Test PR", "Description");
455        let options = PrOptions::new("feature/test");
456        assert!(manager.validate_pr_creation(&context, &options).is_ok());
457    }
458
459    #[test]
460    fn test_validate_pr_creation_empty_title() {
461        let manager = PrManager::new();
462        let context = TaskContext::new("", "Description");
463        let options = PrOptions::new("feature/test");
464        assert!(manager.validate_pr_creation(&context, &options).is_err());
465    }
466
467    #[test]
468    fn test_validate_pr_creation_same_branch() {
469        let manager = PrManager::new();
470        let context = TaskContext::new("Test", "Description");
471        let options = PrOptions::new("main").with_base_branch("main");
472        assert!(manager.validate_pr_creation(&context, &options).is_err());
473    }
474
475    #[test]
476    fn test_create_pr_from_context() {
477        let manager = PrManager::new();
478        let context = TaskContext::new("Test PR", "This is a test")
479            .with_issue(123);
480        let options = PrOptions::new("feature/test");
481        let pr = manager.create_pr_from_context(context, options).unwrap();
482        assert_eq!(pr.title, "Test PR");
483        assert_eq!(pr.branch, "feature/test");
484        assert_eq!(pr.base, "main");
485        assert!(!pr.body.is_empty());
486    }
487
488    #[test]
489    fn test_create_pr_draft() {
490        let manager = PrManager::new();
491        let context = TaskContext::new("Test PR", "Description");
492        let options = PrOptions::new("feature/test").as_draft();
493        let pr = manager.create_pr_from_context(context, options).unwrap();
494        assert_eq!(pr.status, PrStatus::Draft);
495    }
496
497    #[test]
498    fn test_link_to_issues() {
499        let manager = PrManager::new();
500        let context = TaskContext::new("Test", "Description");
501        let options = PrOptions::new("feature/test");
502        let mut pr = manager.create_pr_from_context(context, options).unwrap();
503        let original_body = pr.body.clone();
504        manager.link_to_issues(&mut pr, vec![123, 456]).unwrap();
505        assert!(pr.body.contains("Closes #123"));
506        assert!(pr.body.contains("Closes #456"));
507        assert!(pr.body.len() > original_body.len());
508    }
509
510    #[test]
511    fn test_validate_pr_content_success() {
512        let manager = PrManager::new();
513        let context = TaskContext::new("Test", "Description");
514        let options = PrOptions::new("feature/test");
515        let pr = manager.create_pr_from_context(context, options).unwrap();
516        assert!(manager.validate_pr_content(&pr).is_ok());
517    }
518
519    #[test]
520    fn test_validate_pr_content_empty_title() {
521        let pr = PullRequest {
522            id: 0,
523            number: 0,
524            title: String::new(),
525            body: "Body".to_string(),
526            branch: "feature/test".to_string(),
527            base: "main".to_string(),
528            status: PrStatus::Open,
529            files: Vec::new(),
530            created_at: chrono::Utc::now(),
531            updated_at: chrono::Utc::now(),
532        };
533        let manager = PrManager::new();
534        assert!(manager.validate_pr_content(&pr).is_err());
535    }
536
537    #[test]
538    fn test_custom_template() {
539        let template = PrTemplate {
540            title_template: "CUSTOM: {{title}}".to_string(),
541            body_template: "CUSTOM BODY: {{description}}".to_string(),
542        };
543        let manager = PrManager::with_template(template);
544        let context = TaskContext::new("Test", "Description");
545        let title = manager.generate_title(&context, None).unwrap();
546        assert_eq!(title, "CUSTOM: Test");
547    }
548}