Skip to main content

autom8/gh/
template.rs

1//! PR template detection and agent-based population for GitHub repositories.
2//!
3//! This module provides functionality to:
4//! - Detect and read PR templates from standard GitHub locations
5//! - Run a Claude agent to populate templates and execute PR commands
6
7use 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
18/// Standard locations for GitHub PR templates, in order of precedence.
19const PR_TEMPLATE_PATHS: &[&str] = &[
20    ".github/pull_request_template.md",
21    ".github/PULL_REQUEST_TEMPLATE.md",
22    "pull_request_template.md",
23];
24
25/// Detects and returns the content of a PR template if one exists in the repository.
26///
27/// Checks standard GitHub template locations in order of precedence:
28/// 1. `.github/pull_request_template.md` (lowercase)
29/// 2. `.github/PULL_REQUEST_TEMPLATE.md` (uppercase)
30/// 3. `pull_request_template.md` (repo root)
31///
32/// Returns `Some(content)` if a template is found, `None` otherwise.
33///
34/// # Arguments
35///
36/// * `repo_root` - Path to the repository root directory
37///
38/// # Examples
39///
40/// ```no_run
41/// use std::path::Path;
42/// use autom8::gh::detect_pr_template;
43///
44/// let template = detect_pr_template(Path::new("/path/to/repo"));
45/// if let Some(content) = template {
46///     println!("Found template:\n{}", content);
47/// }
48/// ```
49pub 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, // Try next location if read fails
56            }
57        }
58    }
59    None
60}
61
62/// Result from running the PR template agent.
63#[derive(Debug, Clone, PartialEq)]
64pub enum TemplateAgentResult {
65    /// Agent succeeded, PR URL extracted from output
66    Success(String),
67    /// Agent failed with error details
68    Error(ClaudeErrorInfo),
69}
70
71/// Formats spec data for inclusion in the PR template prompt.
72///
73/// Serializes the spec into a human-readable format including:
74/// - Project name
75/// - Feature description
76/// - User stories with their completion status
77pub 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
102/// Builds the `gh pr create` or `gh pr edit` command string.
103///
104/// # Arguments
105///
106/// * `title` - The PR title
107/// * `pr_number` - If Some, builds an edit command; if None, builds a create command
108/// * `draft` - If true and creating a new PR, includes the `--draft` flag (ignored for edits)
109pub 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
122/// Extracts a PR URL from the agent's output.
123///
124/// Looks for GitHub PR URLs in the format:
125/// - `https://github.com/<owner>/<repo>/pull/<number>`
126///
127/// Returns the first URL found, or None if no URL is present.
128pub fn extract_pr_url(output: &str) -> Option<String> {
129    // Look for GitHub PR URLs
130    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    // Also check for PR URLs that might be embedded in text
138    for word in output.split_whitespace().rev() {
139        if word.starts_with("https://github.com/") && word.contains("/pull/") {
140            // Clean up any trailing punctuation
141            let url = word.trim_end_matches(|c: char| !c.is_alphanumeric());
142            return Some(url.to_string());
143        }
144    }
145
146    None
147}
148
149/// Run a Claude agent to populate a PR template and execute the gh command.
150///
151/// The agent receives:
152/// - Serialized spec data (project, description, user stories with status)
153/// - Raw PR template content
154/// - The exact `gh pr create` or `gh pr edit` command to run
155///
156/// # Arguments
157///
158/// * `spec` - The spec containing feature data
159/// * `template_content` - The raw PR template content
160/// * `title` - The PR title
161/// * `pr_number` - If Some, updates existing PR; if None, creates new PR
162/// * `draft` - If true and creating a new PR, includes the `--draft` flag
163/// * `on_output` - Callback for streaming output
164///
165/// # Returns
166///
167/// `TemplateAgentResult::Success(url)` if the agent successfully created/updated the PR,
168/// `TemplateAgentResult::Error(info)` if the agent failed.
169pub 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    // Write prompt to stdin
203    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    // Take stderr handle before consuming stdout
210    let stderr = child.stderr.take();
211
212    // Stream stdout
213    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        // Parse stream-json output and extract text content
225        if let Some(text) = extract_text_from_stream_line(&line) {
226            on_output(&text);
227            accumulated_text.push_str(&text);
228        }
229    }
230
231    // Wait for process to complete
232    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    // Extract PR URL from output
252    match extract_pr_url(&accumulated_text) {
253        Some(url) => Ok(TemplateAgentResult::Success(url)),
254        None => {
255            // Agent succeeded but we couldn't find a URL - this is an error
256            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        // On case-insensitive filesystems (macOS APFS, Windows NTFS), both filenames
353        // refer to the same file, so the second write overwrites the first.
354        // The test verifies that we find *a* template; the precedence between
355        // lowercase and uppercase is only meaningful on case-sensitive filesystems.
356        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            // On case-sensitive filesystems, lowercase takes precedence
372            assert!(content.contains(lowercase_content));
373        }
374        // On case-insensitive filesystems, just verify we got a template
375    }
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        // Create an empty template file
434        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        // Empty file should still be detected
440        assert!(result.is_some());
441    }
442
443    // ========================================================================
444    // format_spec_for_template tests
445    // ========================================================================
446
447    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    // ========================================================================
533    // build_gh_command tests
534    // ========================================================================
535
536    #[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        // Draft flag should be ignored when editing existing PRs
566        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        // Title should be included (escape handling is agent's responsibility)
576        assert!(command.contains("Fix \"special\" case"));
577    }
578
579    // ========================================================================
580    // extract_pr_url tests
581    // ========================================================================
582
583    #[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        // The function searches from the end, expecting the final URL to be the result
642        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    // ========================================================================
652    // TemplateAgentResult tests
653    // ========================================================================
654
655    #[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}