ricecoder_github/managers/
pr_manager.rs1use crate::errors::{GitHubError, Result};
4use crate::models::{FileChange, PullRequest, PrStatus};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::{debug, info};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct PrTemplate {
12 pub title_template: String,
14 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#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TaskContext {
30 pub title: String,
32 pub description: String,
34 pub related_issues: Vec<u32>,
36 pub files: Vec<FileChange>,
38 pub metadata: HashMap<String, String>,
40}
41
42impl TaskContext {
43 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 pub fn with_issue(mut self, issue_number: u32) -> Self {
56 self.related_issues.push(issue_number);
57 self
58 }
59
60 pub fn with_issues(mut self, issues: Vec<u32>) -> Self {
62 self.related_issues.extend(issues);
63 self
64 }
65
66 pub fn with_file(mut self, file: FileChange) -> Self {
68 self.files.push(file);
69 self
70 }
71
72 pub fn with_files(mut self, files: Vec<FileChange>) -> Self {
74 self.files.extend(files);
75 self
76 }
77
78 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#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PrOptions {
88 pub branch: String,
90 pub base_branch: String,
92 pub draft: bool,
94 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 pub fn new(branch: impl Into<String>) -> Self {
112 Self {
113 branch: branch.into(),
114 ..Default::default()
115 }
116 }
117
118 pub fn as_draft(mut self) -> Self {
120 self.draft = true;
121 self
122 }
123
124 pub fn with_base_branch(mut self, base: impl Into<String>) -> Self {
126 self.base_branch = base.into();
127 self
128 }
129
130 pub fn with_template(mut self, template: PrTemplate) -> Self {
132 self.template = Some(template);
133 self
134 }
135}
136
137pub struct PrManager {
139 default_template: PrTemplate,
141}
142
143impl PrManager {
144 pub fn new() -> Self {
146 Self {
147 default_template: PrTemplate::default(),
148 }
149 }
150
151 pub fn with_template(template: PrTemplate) -> Self {
153 Self {
154 default_template: template,
155 }
156 }
157
158 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 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 fn apply_template(&self, template: &str, context: &TaskContext) -> Result<String> {
188 let mut result = template.to_string();
189
190 result = result.replace("{{title}}", &context.title);
192 result = result.replace("{{description}}", &context.description);
193
194 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 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 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 pub fn validate_pr_creation(&self, context: &TaskContext, options: &PrOptions) -> Result<()> {
235 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 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 pub fn create_pr_from_context(
266 &self,
267 context: TaskContext,
268 options: PrOptions,
269 ) -> Result<PullRequest> {
270 self.validate_pr_creation(&context, &options)?;
272
273 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 let pr = PullRequest {
286 id: 0, number: 0, 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 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 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 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}