oven_cli/agents/
merger.rs1use 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
15fn 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 assert!(prompt.contains("gh pr merge 99"));
112 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}