1use std::fs;
8use std::io::{BufRead, BufReader, Write};
9use std::path::Path;
10use std::process::{Command, Stdio};
11
12use crate::claude::extract_text_from_stream_line;
13use crate::claude::ClaudeErrorInfo;
14use crate::error::{Autom8Error, Result};
15use crate::prompts::PR_TEMPLATE_PROMPT;
16use crate::spec::Spec;
17
18const PR_TEMPLATE_PATHS: &[&str] = &[
20 ".github/pull_request_template.md",
21 ".github/PULL_REQUEST_TEMPLATE.md",
22 "pull_request_template.md",
23];
24
25pub fn detect_pr_template(repo_root: &Path) -> Option<String> {
50 for template_path in PR_TEMPLATE_PATHS {
51 let full_path = repo_root.join(template_path);
52 if full_path.is_file() {
53 match fs::read_to_string(&full_path) {
54 Ok(content) => return Some(content),
55 Err(_) => continue, }
57 }
58 }
59 None
60}
61
62#[derive(Debug, Clone, PartialEq)]
64pub enum TemplateAgentResult {
65 Success(String),
67 Error(ClaudeErrorInfo),
69}
70
71pub fn format_spec_for_template(spec: &Spec) -> String {
78 let mut output = String::new();
79
80 output.push_str(&format!("**Project:** {}\n\n", spec.project));
81 output.push_str(&format!("**Description:**\n{}\n\n", spec.description));
82 output.push_str("**User Stories:**\n\n");
83
84 for story in &spec.user_stories {
85 let status = if story.passes { "[x]" } else { "[ ]" };
86 output.push_str(&format!("- {} **{}**: {}\n", status, story.id, story.title));
87 output.push_str(&format!(" {}\n", story.description));
88
89 if !story.acceptance_criteria.is_empty() {
90 output.push_str(" - Acceptance Criteria:\n");
91 for criterion in &story.acceptance_criteria {
92 let criterion_status = if story.passes { "[x]" } else { "[ ]" };
93 output.push_str(&format!(" - {} {}\n", criterion_status, criterion));
94 }
95 }
96 output.push('\n');
97 }
98
99 output.trim_end().to_string()
100}
101
102pub fn build_gh_command(title: &str, pr_number: Option<u32>, draft: bool) -> String {
110 match pr_number {
111 Some(num) => format!("gh pr edit {} --body \"<filled template>\"", num),
112 None => {
113 let draft_flag = if draft { " --draft" } else { "" };
114 format!(
115 "gh pr create --title \"{}\" --body \"<filled template>\"{}",
116 title, draft_flag
117 )
118 }
119 }
120}
121
122pub fn extract_pr_url(output: &str) -> Option<String> {
129 for line in output.lines().rev() {
131 let line = line.trim();
132 if line.starts_with("https://github.com/") && line.contains("/pull/") {
133 return Some(line.to_string());
134 }
135 }
136
137 for word in output.split_whitespace().rev() {
139 if word.starts_with("https://github.com/") && word.contains("/pull/") {
140 let url = word.trim_end_matches(|c: char| !c.is_alphanumeric());
142 return Some(url.to_string());
143 }
144 }
145
146 None
147}
148
149pub fn run_template_agent<F>(
170 spec: &Spec,
171 template_content: &str,
172 title: &str,
173 pr_number: Option<u32>,
174 draft: bool,
175 mut on_output: F,
176) -> Result<TemplateAgentResult>
177where
178 F: FnMut(&str),
179{
180 let spec_data = format_spec_for_template(spec);
181 let gh_command = build_gh_command(title, pr_number, draft);
182
183 let prompt = PR_TEMPLATE_PROMPT
184 .replace("{spec_data}", &spec_data)
185 .replace("{template_content}", template_content)
186 .replace("{gh_command}", &gh_command);
187
188 let mut child = Command::new("claude")
189 .args([
190 "--dangerously-skip-permissions",
191 "--print",
192 "--output-format",
193 "stream-json",
194 "--verbose",
195 ])
196 .stdin(Stdio::piped())
197 .stdout(Stdio::piped())
198 .stderr(Stdio::piped())
199 .spawn()
200 .map_err(|e| Autom8Error::ClaudeError(format!("Failed to spawn claude: {}", e)))?;
201
202 if let Some(mut stdin) = child.stdin.take() {
204 stdin
205 .write_all(prompt.as_bytes())
206 .map_err(|e| Autom8Error::ClaudeError(format!("Failed to write to stdin: {}", e)))?;
207 }
208
209 let stderr = child.stderr.take();
211
212 let stdout = child
214 .stdout
215 .take()
216 .ok_or_else(|| Autom8Error::ClaudeError("Failed to capture stdout".into()))?;
217
218 let reader = BufReader::new(stdout);
219 let mut accumulated_text = String::new();
220
221 for line in reader.lines() {
222 let line = line.map_err(|e| Autom8Error::ClaudeError(format!("Read error: {}", e)))?;
223
224 if let Some(text) = extract_text_from_stream_line(&line) {
226 on_output(&text);
227 accumulated_text.push_str(&text);
228 }
229 }
230
231 let status = child
233 .wait()
234 .map_err(|e| Autom8Error::ClaudeError(format!("Wait error: {}", e)))?;
235
236 if !status.success() {
237 let stderr_content = stderr
238 .map(|s| std::io::read_to_string(s).unwrap_or_default())
239 .unwrap_or_default();
240 let error_info = ClaudeErrorInfo::from_process_failure(
241 status,
242 if stderr_content.is_empty() {
243 None
244 } else {
245 Some(stderr_content)
246 },
247 );
248 return Ok(TemplateAgentResult::Error(error_info));
249 }
250
251 match extract_pr_url(&accumulated_text) {
253 Some(url) => Ok(TemplateAgentResult::Success(url)),
254 None => {
255 Ok(TemplateAgentResult::Error(ClaudeErrorInfo::new(
257 "Agent completed but no PR URL found in output",
258 )))
259 }
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::spec::UserStory;
267 use std::fs::{self, File};
268 use std::io::Write;
269 use tempfile::TempDir;
270
271 fn create_template(dir: &Path, relative_path: &str, content: &str) {
272 let full_path = dir.join(relative_path);
273 if let Some(parent) = full_path.parent() {
274 fs::create_dir_all(parent).unwrap();
275 }
276 let mut file = File::create(full_path).unwrap();
277 writeln!(file, "{}", content).unwrap();
278 }
279
280 #[test]
281 fn test_no_template_returns_none() {
282 let temp_dir = TempDir::new().unwrap();
283 let result = detect_pr_template(temp_dir.path());
284 assert!(result.is_none());
285 }
286
287 #[test]
288 fn test_detects_lowercase_github_template() {
289 let temp_dir = TempDir::new().unwrap();
290 let expected_content = "## Description\nPlease describe your changes";
291 create_template(
292 temp_dir.path(),
293 ".github/pull_request_template.md",
294 expected_content,
295 );
296
297 let result = detect_pr_template(temp_dir.path());
298 assert!(result.is_some());
299 assert!(result.unwrap().contains(expected_content));
300 }
301
302 #[test]
303 fn test_detects_uppercase_github_template() {
304 let temp_dir = TempDir::new().unwrap();
305 let expected_content = "## Summary\nDescribe what this PR does";
306 create_template(
307 temp_dir.path(),
308 ".github/PULL_REQUEST_TEMPLATE.md",
309 expected_content,
310 );
311
312 let result = detect_pr_template(temp_dir.path());
313 assert!(result.is_some());
314 assert!(result.unwrap().contains(expected_content));
315 }
316
317 #[test]
318 fn test_detects_root_template() {
319 let temp_dir = TempDir::new().unwrap();
320 let expected_content = "## Changes\nList your changes here";
321 create_template(
322 temp_dir.path(),
323 "pull_request_template.md",
324 expected_content,
325 );
326
327 let result = detect_pr_template(temp_dir.path());
328 assert!(result.is_some());
329 assert!(result.unwrap().contains(expected_content));
330 }
331
332 #[test]
333 fn test_precedence_lowercase_github_over_uppercase() {
334 let temp_dir = TempDir::new().unwrap();
335 let lowercase_content = "LOWERCASE TEMPLATE";
336 let uppercase_content = "UPPERCASE TEMPLATE";
337
338 create_template(
339 temp_dir.path(),
340 ".github/pull_request_template.md",
341 lowercase_content,
342 );
343 create_template(
344 temp_dir.path(),
345 ".github/PULL_REQUEST_TEMPLATE.md",
346 uppercase_content,
347 );
348
349 let result = detect_pr_template(temp_dir.path());
350 assert!(result.is_some());
351
352 let content = result.unwrap();
357 let is_case_sensitive_fs = temp_dir
358 .path()
359 .join(".github/pull_request_template.md")
360 .exists()
361 && temp_dir
362 .path()
363 .join(".github/PULL_REQUEST_TEMPLATE.md")
364 .exists()
365 && fs::read_to_string(temp_dir.path().join(".github/pull_request_template.md"))
366 .unwrap()
367 != fs::read_to_string(temp_dir.path().join(".github/PULL_REQUEST_TEMPLATE.md"))
368 .unwrap();
369
370 if is_case_sensitive_fs {
371 assert!(content.contains(lowercase_content));
373 }
374 }
376
377 #[test]
378 fn test_precedence_github_over_root() {
379 let temp_dir = TempDir::new().unwrap();
380 let github_content = "GITHUB DIRECTORY TEMPLATE";
381 let root_content = "ROOT TEMPLATE";
382
383 create_template(
384 temp_dir.path(),
385 ".github/pull_request_template.md",
386 github_content,
387 );
388 create_template(temp_dir.path(), "pull_request_template.md", root_content);
389
390 let result = detect_pr_template(temp_dir.path());
391 assert!(result.is_some());
392 assert!(result.unwrap().contains(github_content));
393 }
394
395 #[test]
396 fn test_precedence_uppercase_github_over_root() {
397 let temp_dir = TempDir::new().unwrap();
398 let github_content = "UPPERCASE GITHUB TEMPLATE";
399 let root_content = "ROOT TEMPLATE";
400
401 create_template(
402 temp_dir.path(),
403 ".github/PULL_REQUEST_TEMPLATE.md",
404 github_content,
405 );
406 create_template(temp_dir.path(), "pull_request_template.md", root_content);
407
408 let result = detect_pr_template(temp_dir.path());
409 assert!(result.is_some());
410 assert!(result.unwrap().contains(github_content));
411 }
412
413 #[test]
414 fn test_falls_back_to_root_when_github_missing() {
415 let temp_dir = TempDir::new().unwrap();
416 let root_content = "ROOT ONLY TEMPLATE";
417 create_template(temp_dir.path(), "pull_request_template.md", root_content);
418
419 let result = detect_pr_template(temp_dir.path());
420 assert!(result.is_some());
421 assert!(result.unwrap().contains(root_content));
422 }
423
424 #[test]
425 fn test_nonexistent_repo_path_returns_none() {
426 let result = detect_pr_template(Path::new("/nonexistent/path/to/repo"));
427 assert!(result.is_none());
428 }
429
430 #[test]
431 fn test_empty_template_returns_content() {
432 let temp_dir = TempDir::new().unwrap();
433 let template_path = temp_dir.path().join(".github/pull_request_template.md");
435 fs::create_dir_all(template_path.parent().unwrap()).unwrap();
436 File::create(&template_path).unwrap();
437
438 let result = detect_pr_template(temp_dir.path());
439 assert!(result.is_some());
441 }
442
443 fn make_test_story(id: &str, title: &str, passes: bool) -> UserStory {
448 UserStory {
449 id: id.to_string(),
450 title: title.to_string(),
451 description: format!("Description for {}", id),
452 acceptance_criteria: vec!["Criterion 1".to_string(), "Criterion 2".to_string()],
453 priority: 1,
454 passes,
455 notes: String::new(),
456 }
457 }
458
459 fn make_test_spec() -> Spec {
460 Spec {
461 project: "TestProject".to_string(),
462 branch_name: "feature/test".to_string(),
463 description: "This is a test feature description.".to_string(),
464 user_stories: vec![
465 make_test_story("US-001", "First Story", true),
466 make_test_story("US-002", "Second Story", false),
467 ],
468 }
469 }
470
471 #[test]
472 fn test_format_spec_includes_project_name() {
473 let spec = make_test_spec();
474 let formatted = format_spec_for_template(&spec);
475 assert!(formatted.contains("**Project:** TestProject"));
476 }
477
478 #[test]
479 fn test_format_spec_includes_description() {
480 let spec = make_test_spec();
481 let formatted = format_spec_for_template(&spec);
482 assert!(formatted.contains("**Description:**"));
483 assert!(formatted.contains("This is a test feature description."));
484 }
485
486 #[test]
487 fn test_format_spec_includes_user_stories_header() {
488 let spec = make_test_spec();
489 let formatted = format_spec_for_template(&spec);
490 assert!(formatted.contains("**User Stories:**"));
491 }
492
493 #[test]
494 fn test_format_spec_includes_story_ids_and_titles() {
495 let spec = make_test_spec();
496 let formatted = format_spec_for_template(&spec);
497 assert!(formatted.contains("**US-001**: First Story"));
498 assert!(formatted.contains("**US-002**: Second Story"));
499 }
500
501 #[test]
502 fn test_format_spec_shows_completed_story_with_checkbox() {
503 let spec = make_test_spec();
504 let formatted = format_spec_for_template(&spec);
505 assert!(formatted.contains("[x] **US-001**: First Story"));
506 }
507
508 #[test]
509 fn test_format_spec_shows_incomplete_story_without_checkbox() {
510 let spec = make_test_spec();
511 let formatted = format_spec_for_template(&spec);
512 assert!(formatted.contains("[ ] **US-002**: Second Story"));
513 }
514
515 #[test]
516 fn test_format_spec_includes_acceptance_criteria() {
517 let spec = make_test_spec();
518 let formatted = format_spec_for_template(&spec);
519 assert!(formatted.contains("Acceptance Criteria:"));
520 assert!(formatted.contains("Criterion 1"));
521 assert!(formatted.contains("Criterion 2"));
522 }
523
524 #[test]
525 fn test_format_spec_includes_story_descriptions() {
526 let spec = make_test_spec();
527 let formatted = format_spec_for_template(&spec);
528 assert!(formatted.contains("Description for US-001"));
529 assert!(formatted.contains("Description for US-002"));
530 }
531
532 #[test]
537 fn test_build_gh_command_for_new_pr() {
538 let command = build_gh_command("Add feature X", None, false);
539 assert!(command.contains("gh pr create"));
540 assert!(command.contains("--title \"Add feature X\""));
541 assert!(command.contains("--body"));
542 assert!(!command.contains("--draft"));
543 }
544
545 #[test]
546 fn test_build_gh_command_for_new_pr_with_draft() {
547 let command = build_gh_command("Add feature X", None, true);
548 assert!(command.contains("gh pr create"));
549 assert!(command.contains("--title \"Add feature X\""));
550 assert!(command.contains("--body"));
551 assert!(command.contains("--draft"));
552 }
553
554 #[test]
555 fn test_build_gh_command_for_existing_pr() {
556 let command = build_gh_command("Add feature X", Some(42), false);
557 assert!(command.contains("gh pr edit 42"));
558 assert!(command.contains("--body"));
559 assert!(!command.contains("--title"));
560 assert!(!command.contains("--draft"));
561 }
562
563 #[test]
564 fn test_build_gh_command_for_existing_pr_ignores_draft() {
565 let command = build_gh_command("Add feature X", Some(42), true);
567 assert!(command.contains("gh pr edit 42"));
568 assert!(command.contains("--body"));
569 assert!(!command.contains("--draft"));
570 }
571
572 #[test]
573 fn test_build_gh_command_escapes_title_quotes() {
574 let command = build_gh_command("Fix \"special\" case", None, false);
575 assert!(command.contains("Fix \"special\" case"));
577 }
578
579 #[test]
584 fn test_extract_pr_url_from_simple_output() {
585 let output = "https://github.com/owner/repo/pull/123";
586 let url = extract_pr_url(output);
587 assert_eq!(
588 url,
589 Some("https://github.com/owner/repo/pull/123".to_string())
590 );
591 }
592
593 #[test]
594 fn test_extract_pr_url_from_multiline_output() {
595 let output = r#"Creating pull request...
596Done!
597https://github.com/owner/repo/pull/456"#;
598 let url = extract_pr_url(output);
599 assert_eq!(
600 url,
601 Some("https://github.com/owner/repo/pull/456".to_string())
602 );
603 }
604
605 #[test]
606 fn test_extract_pr_url_from_embedded_text() {
607 let output = "PR created at https://github.com/owner/repo/pull/789 successfully";
608 let url = extract_pr_url(output);
609 assert_eq!(
610 url,
611 Some("https://github.com/owner/repo/pull/789".to_string())
612 );
613 }
614
615 #[test]
616 fn test_extract_pr_url_returns_none_when_no_url() {
617 let output = "No URL here, just some text";
618 let url = extract_pr_url(output);
619 assert!(url.is_none());
620 }
621
622 #[test]
623 fn test_extract_pr_url_returns_none_for_non_pr_github_url() {
624 let output = "https://github.com/owner/repo/issues/123";
625 let url = extract_pr_url(output);
626 assert!(url.is_none());
627 }
628
629 #[test]
630 fn test_extract_pr_url_handles_trailing_punctuation() {
631 let output = "Created: https://github.com/owner/repo/pull/100.";
632 let url = extract_pr_url(output);
633 assert_eq!(
634 url,
635 Some("https://github.com/owner/repo/pull/100".to_string())
636 );
637 }
638
639 #[test]
640 fn test_extract_pr_url_prefers_last_url_in_output() {
641 let output = r#"Opening https://github.com/owner/repo/pull/1
643Updated https://github.com/owner/repo/pull/2"#;
644 let url = extract_pr_url(output);
645 assert_eq!(
646 url,
647 Some("https://github.com/owner/repo/pull/2".to_string())
648 );
649 }
650
651 #[test]
656 fn test_template_agent_result_success_equality() {
657 let result1 = TemplateAgentResult::Success("https://github.com/o/r/pull/1".to_string());
658 let result2 = TemplateAgentResult::Success("https://github.com/o/r/pull/1".to_string());
659 assert_eq!(result1, result2);
660 }
661
662 #[test]
663 fn test_template_agent_result_error_equality() {
664 let error1 = ClaudeErrorInfo::new("test error");
665 let error2 = ClaudeErrorInfo::new("test error");
666 let result1 = TemplateAgentResult::Error(error1);
667 let result2 = TemplateAgentResult::Error(error2);
668 assert_eq!(result1, result2);
669 }
670
671 #[test]
672 fn test_template_agent_result_clone() {
673 let result = TemplateAgentResult::Success("https://github.com/o/r/pull/42".to_string());
674 let cloned = result.clone();
675 assert_eq!(result, cloned);
676 }
677}