Skip to main content

agent_runtime/commands/
pr_body.rs

1use clap::{Args, Subcommand, ValueEnum};
2use std::fs;
3use std::io::{self, Write as _};
4use std::path::{Path, PathBuf};
5
6const DEFAULT_RISK_NOTES: &str = "- N/A";
7
8#[derive(Args, Debug)]
9pub struct PrBodyArgs {
10    #[command(subcommand)]
11    pub command: PrBodyCommand,
12}
13
14#[derive(Subcommand, Debug)]
15pub enum PrBodyCommand {
16    /// Render a forge-cli-compatible PR / MR body.
17    Render(PrBodyRenderArgs),
18}
19
20#[derive(Args, Debug)]
21pub struct PrBodyRenderArgs {
22    /// Body kind. `feature` and `bug` render their dedicated templates;
23    /// `chore`, `docs`, `ci`, and `refactor` render a generic
24    /// Summary / Issues (optional) / Test-First / Test plan / Risk skeleton.
25    /// The set matches the six kinds `forge-cli pr deliver --kind` accepts.
26    #[arg(long, value_enum)]
27    pub kind: PrBodyKind,
28    /// One-paragraph summary of the change and scope.
29    #[arg(long)]
30    pub summary_file: PathBuf,
31    /// Feature-only list of key changes. Rejected for other kinds.
32    #[arg(long, required_if_eq("kind", "feature"))]
33    pub changes_file: Option<PathBuf>,
34    /// Bug-only expected/actual/impact section. Rejected for other kinds.
35    #[arg(long, required_if_eq("kind", "bug"))]
36    pub problem_file: Option<PathBuf>,
37    /// Bug-only reproduction steps. Rejected for other kinds.
38    #[arg(long, required_if_eq("kind", "bug"))]
39    pub reproduction_file: Option<PathBuf>,
40    /// Issue table or issue references (for example `Refs #N`). Required for
41    /// `bug` (rendered as `## Issues Found`); optional for every other kind
42    /// (rendered as `## Issues` right after `## Summary`).
43    #[arg(long, required_if_eq("kind", "bug"))]
44    pub issues_file: Option<PathBuf>,
45    /// Bug-only fix approach summary. Rejected for other kinds.
46    #[arg(long, required_if_eq("kind", "bug"))]
47    pub fix_approach_file: Option<PathBuf>,
48    /// Test-first evidence, including the waiver when a failing test was not practical.
49    #[arg(long)]
50    pub test_first_file: PathBuf,
51    /// Validation commands and results. Rendered as `## Test plan` for forge-cli.
52    #[arg(long)]
53    pub test_plan_file: PathBuf,
54    /// Optional risk notes. Defaults to `- N/A`.
55    #[arg(long)]
56    pub risk_file: Option<PathBuf>,
57    /// Output path. Defaults to stdout.
58    #[arg(long)]
59    pub out: Option<PathBuf>,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
63#[clap(rename_all = "lower")]
64pub enum PrBodyKind {
65    Feature,
66    Bug,
67    Chore,
68    Docs,
69    Ci,
70    Refactor,
71}
72
73pub fn run(args: PrBodyArgs) -> anyhow::Result<u8> {
74    match args.command {
75        PrBodyCommand::Render(render_args) => render(render_args),
76    }
77}
78
79fn render(args: PrBodyRenderArgs) -> anyhow::Result<u8> {
80    reject_kind_mismatched_files(&args)?;
81
82    let summary = read_required("summary", &args.summary_file)?;
83    let test_first = read_required("test-first", &args.test_first_file)?;
84    let test_plan = read_required("test-plan", &args.test_plan_file)?;
85    let risk = match args.risk_file.as_deref() {
86        Some(path) => read_required("risk", path)?,
87        None => DEFAULT_RISK_NOTES.to_string(),
88    };
89    let optional_issues = match (args.kind, args.issues_file.as_deref()) {
90        (PrBodyKind::Bug, _) | (_, None) => None,
91        (_, Some(path)) => Some(read_required("issues", path)?),
92    };
93
94    let body = match args.kind {
95        PrBodyKind::Feature => {
96            let changes = read_required(
97                "changes",
98                required_path("changes", args.changes_file.as_deref())?,
99            )?;
100            render_feature(
101                &summary,
102                optional_issues.as_deref(),
103                &changes,
104                &test_first,
105                &test_plan,
106                &risk,
107            )
108        }
109        PrBodyKind::Bug => {
110            let problem = read_required(
111                "problem",
112                required_path("problem", args.problem_file.as_deref())?,
113            )?;
114            let reproduction = read_required(
115                "reproduction",
116                required_path("reproduction", args.reproduction_file.as_deref())?,
117            )?;
118            let issues = read_required(
119                "issues",
120                required_path("issues", args.issues_file.as_deref())?,
121            )?;
122            let fix_approach = read_required(
123                "fix-approach",
124                required_path("fix-approach", args.fix_approach_file.as_deref())?,
125            )?;
126            render_bug(
127                &summary,
128                &problem,
129                &reproduction,
130                &issues,
131                &fix_approach,
132                &test_first,
133                &test_plan,
134                &risk,
135            )
136        }
137        PrBodyKind::Chore | PrBodyKind::Docs | PrBodyKind::Ci | PrBodyKind::Refactor => {
138            render_generic(
139                &summary,
140                optional_issues.as_deref(),
141                &test_first,
142                &test_plan,
143                &risk,
144            )
145        }
146    };
147
148    validate_forge_sections(&body)?;
149    write_output(args.out.as_deref(), &body)?;
150    Ok(0)
151}
152
153/// `clap` only enforces the required direction (`required_if_eq`); a
154/// kind-specific file passed with a non-owning kind would otherwise be
155/// accepted and silently dropped from the rendered body.
156fn reject_kind_mismatched_files(args: &PrBodyRenderArgs) -> anyhow::Result<()> {
157    let mismatches = [
158        (
159            args.changes_file.is_some() && args.kind != PrBodyKind::Feature,
160            "changes",
161            "feature",
162        ),
163        (
164            args.problem_file.is_some() && args.kind != PrBodyKind::Bug,
165            "problem",
166            "bug",
167        ),
168        (
169            args.reproduction_file.is_some() && args.kind != PrBodyKind::Bug,
170            "reproduction",
171            "bug",
172        ),
173        (
174            args.fix_approach_file.is_some() && args.kind != PrBodyKind::Bug,
175            "fix-approach",
176            "bug",
177        ),
178    ];
179    for (mismatched, label, owner) in mismatches {
180        if mismatched {
181            anyhow::bail!("--{label}-file is only rendered by --kind {owner}");
182        }
183    }
184    Ok(())
185}
186
187fn read_required(label: &str, path: &Path) -> anyhow::Result<String> {
188    let raw = fs::read_to_string(path).map_err(|err| {
189        anyhow::anyhow!("failed to read --{label}-file {}: {err}", path.display())
190    })?;
191    let body = raw.trim();
192    if body.is_empty() {
193        anyhow::bail!("--{label}-file {} is empty", path.display());
194    }
195    Ok(body.to_string())
196}
197
198fn required_path<'a>(label: &str, path: Option<&'a Path>) -> anyhow::Result<&'a Path> {
199    path.ok_or_else(|| anyhow::anyhow!("--{label}-file is required"))
200}
201
202fn render_feature(
203    summary: &str,
204    issues: Option<&str>,
205    changes: &str,
206    test_first: &str,
207    test_plan: &str,
208    risk: &str,
209) -> String {
210    let issues = issues_section(issues);
211    format!(
212        "## Summary\n\n{summary}\n\n{issues}## Changes\n\n{changes}\n\n## Test-First Evidence\n\n{test_first}\n\n## Test plan\n\n{test_plan}\n\n## Risk / Notes\n\n{risk}\n"
213    )
214}
215
216/// Generic skeleton for kinds without a dedicated template
217/// (`chore` / `docs` / `ci` / `refactor`). Emits the forge-cli-required
218/// `## Summary` and `## Test plan` sections plus test-first evidence and
219/// risk notes, with an optional `## Issues` references section.
220fn render_generic(
221    summary: &str,
222    issues: Option<&str>,
223    test_first: &str,
224    test_plan: &str,
225    risk: &str,
226) -> String {
227    let issues = issues_section(issues);
228    format!(
229        "## Summary\n\n{summary}\n\n{issues}## Test-First Evidence\n\n{test_first}\n\n## Test plan\n\n{test_plan}\n\n## Risk / Notes\n\n{risk}\n"
230    )
231}
232
233/// Optional `## Issues` references block for non-bug kinds, placed right
234/// after `## Summary`. The bug template keeps its own `## Issues Found`
235/// section instead.
236fn issues_section(issues: Option<&str>) -> String {
237    issues
238        .map(|issues| format!("## Issues\n\n{issues}\n\n"))
239        .unwrap_or_default()
240}
241
242#[allow(clippy::too_many_arguments)]
243fn render_bug(
244    summary: &str,
245    problem: &str,
246    reproduction: &str,
247    issues: &str,
248    fix_approach: &str,
249    test_first: &str,
250    test_plan: &str,
251    risk: &str,
252) -> String {
253    format!(
254        "## Summary\n\n{summary}\n\n## Problem\n\n{problem}\n\n## Reproduction\n\n{reproduction}\n\n## Issues Found\n\n{issues}\n\n## Fix Approach\n\n{fix_approach}\n\n## Test-First Evidence\n\n{test_first}\n\n## Test plan\n\n{test_plan}\n\n## Risk / Notes\n\n{risk}\n"
255    )
256}
257
258fn validate_forge_sections(body: &str) -> anyhow::Result<()> {
259    for heading in ["## Summary", "## Test plan"] {
260        if !has_non_empty_h2_section(body, heading) {
261            anyhow::bail!("rendered body is missing non-empty {heading}");
262        }
263    }
264    Ok(())
265}
266
267fn has_non_empty_h2_section(body: &str, heading: &str) -> bool {
268    let mut in_section = false;
269    for line in body.lines() {
270        if line.starts_with("## ") {
271            if in_section {
272                return false;
273            }
274            in_section = line.trim_end() == heading;
275            continue;
276        }
277        if in_section && !line.trim().is_empty() {
278            return true;
279        }
280    }
281    false
282}
283
284fn write_output(out: Option<&Path>, body: &str) -> anyhow::Result<()> {
285    match out {
286        Some(path) => {
287            fs::write(path, body).map_err(|err| {
288                anyhow::anyhow!("failed to write --out {}: {err}", path.display())
289            })?;
290        }
291        None => {
292            let mut stdout = io::stdout().lock();
293            stdout.write_all(body.as_bytes())?;
294        }
295    }
296    Ok(())
297}