Skip to main content

oven_cli/agents/
merger.rs

1use anyhow::{Context, Result};
2use askama::Template;
3
4use super::AgentContext;
5
6#[derive(Template)]
7#[template(path = "merger.txt")]
8struct MergerPrompt<'a> {
9    ctx: &'a AgentContext,
10    auto_merge: bool,
11    pr_number: u32,
12    safe_title: String,
13}
14
15/// Escape shell metacharacters in a string for safe embedding in double-quoted
16/// shell commands. Also strips control characters (newlines, tabs, etc.) that
17/// could break command structure.
18fn shell_escape(s: &str) -> String {
19    s.chars()
20        .filter_map(|c| match c {
21            '"' | '\\' | '$' | '`' | '!' => {
22                let mut escaped = String::with_capacity(2);
23                escaped.push('\\');
24                escaped.push(c);
25                Some(escaped)
26            }
27            '\n' | '\r' | '\0' => None,
28            c if c.is_control() => None,
29            _ => Some(c.to_string()),
30        })
31        .collect()
32}
33
34pub fn build_prompt(ctx: &AgentContext, auto_merge: bool) -> Result<String> {
35    let pr_number = ctx.pr_number.context("merger requires a PR number")?;
36    let safe_title = shell_escape(&ctx.issue_title);
37    let tmpl = MergerPrompt { ctx, auto_merge, pr_number, safe_title };
38    tmpl.render().context("rendering merger template")
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44
45    fn sample_context() -> AgentContext {
46        AgentContext {
47            issue_number: 42,
48            issue_title: "Fix auth bug".to_string(),
49            issue_body: "details".to_string(),
50            branch: "oven/issue-42-abc".to_string(),
51            pr_number: Some(99),
52            test_command: None,
53            lint_command: None,
54            review_findings: None,
55            cycle: 1,
56            target_repo: None,
57            issue_source: "github".to_string(),
58            base_branch: "main".to_string(),
59        }
60    }
61
62    #[test]
63    fn prompt_references_pr_number() {
64        let prompt = build_prompt(&sample_context(), false).unwrap();
65        assert!(prompt.contains("PR #99"));
66        assert!(prompt.contains("gh pr edit 99"));
67        assert!(prompt.contains("#42"));
68    }
69
70    #[test]
71    fn prompt_without_merge() {
72        let prompt = build_prompt(&sample_context(), false).unwrap();
73        assert!(!prompt.contains("gh pr ready"));
74        assert!(!prompt.contains("gh pr merge"));
75    }
76
77    #[test]
78    fn prompt_with_merge() {
79        let prompt = build_prompt(&sample_context(), true).unwrap();
80        assert!(prompt.contains("gh pr ready 99"));
81        assert!(prompt.contains("gh pr merge 99"));
82        assert!(prompt.contains("--squash"));
83        assert!(prompt.contains("--delete-branch"));
84    }
85
86    #[test]
87    fn prompt_includes_issue_close_when_auto_merge() {
88        let prompt = build_prompt(&sample_context(), true).unwrap();
89        assert!(prompt.contains("gh issue close 42"));
90    }
91
92    #[test]
93    fn prompt_includes_pr_description_update() {
94        let prompt = build_prompt(&sample_context(), false).unwrap();
95        assert!(prompt.contains("gh pr edit 99"));
96        assert!(prompt.contains("Resolves #42"));
97    }
98
99    #[test]
100    fn prompt_includes_merge_summary_output() {
101        let prompt = build_prompt(&sample_context(), false).unwrap();
102        assert!(prompt.contains("Merge Summary"));
103    }
104
105    #[test]
106    fn prompt_skips_issue_close_in_multi_repo() {
107        let mut ctx = sample_context();
108        ctx.target_repo = Some("backend-api".to_string());
109        let prompt = build_prompt(&ctx, true).unwrap();
110        // Should still merge the PR
111        assert!(prompt.contains("gh pr merge 99"));
112        // But should NOT try to close the issue (executor handles it)
113        assert!(!prompt.contains("gh issue close"));
114    }
115
116    #[test]
117    fn prompt_includes_issue_close_in_single_repo() {
118        let prompt = build_prompt(&sample_context(), true).unwrap();
119        assert!(prompt.contains("gh issue close 42"));
120    }
121
122    #[test]
123    fn prompt_skips_issue_close_for_local_source() {
124        let mut ctx = sample_context();
125        ctx.issue_source = "local".to_string();
126        let prompt = build_prompt(&ctx, true).unwrap();
127        assert!(prompt.contains("gh pr merge 99"));
128        assert!(!prompt.contains("gh issue close"));
129    }
130
131    #[test]
132    fn prompt_uses_local_issue_reference_for_local_source() {
133        let mut ctx = sample_context();
134        ctx.issue_source = "local".to_string();
135        let prompt = build_prompt(&ctx, true).unwrap();
136        assert!(prompt.contains("From local issue #42"));
137        assert!(!prompt.contains("Resolves #42"));
138    }
139
140    #[test]
141    fn prompt_fails_without_pr_number() {
142        let mut ctx = sample_context();
143        ctx.pr_number = None;
144        let result = build_prompt(&ctx, false);
145        assert!(result.is_err());
146        assert!(result.unwrap_err().to_string().contains("PR number"));
147    }
148
149    #[test]
150    fn prompt_escapes_shell_metacharacters_in_title() {
151        let mut ctx = sample_context();
152        ctx.issue_title = r#"Fix "bug" with $HOME expansion"#.to_string();
153        let prompt = build_prompt(&ctx, false).unwrap();
154        assert!(prompt.contains(r#"Fix \"bug\" with \$HOME expansion"#));
155    }
156
157    #[test]
158    fn shell_escape_strips_newlines() {
159        assert_eq!(shell_escape("line1\nline2\rline3"), "line1line2line3");
160    }
161
162    #[test]
163    fn shell_escape_strips_null_bytes() {
164        assert_eq!(shell_escape("before\0after"), "beforeafter");
165    }
166
167    #[test]
168    fn shell_escape_preserves_single_quotes() {
169        assert_eq!(shell_escape("it's"), "it's");
170    }
171}