1use std::{fmt::Write as _, path::PathBuf, sync::Arc};
2
3use anyhow::{Context, Result};
4use rusqlite::Connection;
5use tokio::sync::Mutex;
6use tokio_util::sync::CancellationToken;
7use tracing::{debug, info, warn};
8
9use crate::{
10 agents::{
11 self, AgentContext, AgentInvocation, AgentRole, Complexity, GraphContextNode,
12 PlannerGraphOutput, Severity, invoke_agent, parse_fixer_output, parse_planner_graph_output,
13 parse_review_output,
14 },
15 config::Config,
16 db::{self, AgentRun, ReviewFinding, Run, RunStatus},
17 git::{self, RebaseOutcome},
18 github::{self, GhClient},
19 issues::{IssueOrigin, IssueProvider, PipelineIssue},
20 process::{self, CommandRunner},
21};
22
23#[derive(Debug)]
25pub struct PipelineOutcome {
26 pub run_id: String,
27 pub pr_number: u32,
28 pub worktree_path: PathBuf,
30 pub target_dir: PathBuf,
32}
33
34pub struct PipelineExecutor<R: CommandRunner> {
36 pub runner: Arc<R>,
37 pub github: Arc<GhClient<R>>,
38 pub issues: Arc<dyn IssueProvider>,
39 pub db: Arc<Mutex<Connection>>,
40 pub config: Config,
41 pub cancel_token: CancellationToken,
42 pub repo_dir: PathBuf,
43}
44
45impl<R: CommandRunner + 'static> PipelineExecutor<R> {
46 pub async fn run_issue(&self, issue: &PipelineIssue, auto_merge: bool) -> Result<()> {
48 self.run_issue_with_complexity(issue, auto_merge, None).await
49 }
50
51 pub async fn run_issue_with_complexity(
53 &self,
54 issue: &PipelineIssue,
55 auto_merge: bool,
56 complexity: Option<Complexity>,
57 ) -> Result<()> {
58 let outcome = self.run_issue_pipeline(issue, auto_merge, complexity).await?;
59 self.finalize_merge(&outcome, issue).await
60 }
61
62 pub async fn run_issue_pipeline(
68 &self,
69 issue: &PipelineIssue,
70 auto_merge: bool,
71 complexity: Option<Complexity>,
72 ) -> Result<PipelineOutcome> {
73 let run_id = generate_run_id();
74
75 let (target_dir, is_multi_repo) = self.resolve_target_dir(issue.target_repo.as_ref())?;
77
78 let base_branch = git::default_branch(&target_dir).await?;
79
80 let mut run = new_run(&run_id, issue, auto_merge);
81 if let Some(ref c) = complexity {
82 run.complexity = c.to_string();
83 }
84 {
85 let conn = self.db.lock().await;
86 db::runs::insert_run(&conn, &run)?;
87 }
88
89 self.issues
90 .transition(issue.number, &self.config.labels.ready, &self.config.labels.cooking)
91 .await?;
92
93 let worktree = git::create_worktree(&target_dir, issue.number, &base_branch).await?;
94 self.record_worktree(&run_id, &worktree).await?;
95
96 git::empty_commit(
98 &worktree.path,
99 &format!("chore: start oven pipeline for issue #{}", issue.number),
100 )
101 .await?;
102
103 info!(
104 run_id = %run_id,
105 issue = issue.number,
106 branch = %worktree.branch,
107 target_repo = ?issue.target_repo,
108 "starting pipeline"
109 );
110
111 let pr_number = self.create_pr(&run_id, issue, &worktree.branch, &target_dir).await?;
112
113 let ctx = AgentContext {
114 issue_number: issue.number,
115 issue_title: issue.title.clone(),
116 issue_body: issue.body.clone(),
117 branch: worktree.branch.clone(),
118 pr_number: Some(pr_number),
119 test_command: self.config.project.test.clone(),
120 lint_command: self.config.project.lint.clone(),
121 review_findings: None,
122 cycle: 1,
123 target_repo: if is_multi_repo { issue.target_repo.clone() } else { None },
124 issue_source: issue.source.as_str().to_string(),
125 base_branch: base_branch.clone(),
126 };
127
128 let result = self.run_steps(&run_id, &ctx, &worktree.path, auto_merge, &target_dir).await;
129
130 if let Err(ref e) = result {
131 self.finalize_run(&run_id, issue, pr_number, &result, &target_dir).await?;
133 if let Err(e) = git::remove_worktree(&target_dir, &worktree.path).await {
134 warn!(run_id = %run_id, error = %e, "failed to clean up worktree");
135 }
136 return Err(anyhow::anyhow!("{e:#}"));
137 }
138
139 self.update_status(&run_id, RunStatus::AwaitingMerge).await?;
141
142 Ok(PipelineOutcome { run_id, pr_number, worktree_path: worktree.path, target_dir })
143 }
144
145 pub async fn finalize_merge(
150 &self,
151 outcome: &PipelineOutcome,
152 issue: &PipelineIssue,
153 ) -> Result<()> {
154 self.finalize_run(&outcome.run_id, issue, outcome.pr_number, &Ok(()), &outcome.target_dir)
155 .await?;
156 if let Err(e) = git::remove_worktree(&outcome.target_dir, &outcome.worktree_path).await {
157 warn!(
158 run_id = %outcome.run_id,
159 error = %e,
160 "failed to clean up worktree after merge"
161 );
162 }
163 Ok(())
164 }
165
166 pub async fn plan_issues(
173 &self,
174 issues: &[PipelineIssue],
175 graph_context: &[GraphContextNode],
176 ) -> Option<PlannerGraphOutput> {
177 let prompt = match agents::planner::build_prompt(issues, graph_context) {
178 Ok(p) => p,
179 Err(e) => {
180 warn!(error = %e, "planner prompt build failed");
181 return None;
182 }
183 };
184 let invocation = AgentInvocation {
185 role: AgentRole::Planner,
186 prompt,
187 working_dir: self.repo_dir.clone(),
188 max_turns: Some(self.config.pipeline.turn_limit),
189 };
190
191 match invoke_agent(self.runner.as_ref(), &invocation).await {
192 Ok(result) => {
193 debug!(output = %result.output, "raw planner output");
194 let parsed = parse_planner_graph_output(&result.output);
195 if parsed.is_none() {
196 warn!(output = %result.output, "planner returned unparseable output, falling back to all-parallel");
197 }
198 parsed
199 }
200 Err(e) => {
201 warn!(error = %e, "planner agent failed, falling back to all-parallel");
202 None
203 }
204 }
205 }
206
207 pub(crate) fn resolve_target_dir(
212 &self,
213 target_repo: Option<&String>,
214 ) -> Result<(PathBuf, bool)> {
215 if !self.config.multi_repo.enabled {
216 return Ok((self.repo_dir.clone(), false));
217 }
218 match target_repo {
219 Some(name) => {
220 let path = self.config.resolve_repo(name)?;
221 Ok((path, true))
222 }
223 None => Ok((self.repo_dir.clone(), false)),
224 }
225 }
226
227 pub fn reconstruct_outcome(
232 &self,
233 issue: &PipelineIssue,
234 run_id: &str,
235 pr_number: u32,
236 ) -> Result<PipelineOutcome> {
237 let (target_dir, _) = self.resolve_target_dir(issue.target_repo.as_ref())?;
238 let worktree_path =
239 target_dir.join(".oven").join("worktrees").join(format!("issue-{}", issue.number));
240 Ok(PipelineOutcome { run_id: run_id.to_string(), pr_number, worktree_path, target_dir })
241 }
242
243 async fn record_worktree(&self, run_id: &str, worktree: &git::Worktree) -> Result<()> {
244 let conn = self.db.lock().await;
245 db::runs::update_run_worktree(
246 &conn,
247 run_id,
248 &worktree.branch,
249 &worktree.path.to_string_lossy(),
250 )?;
251 drop(conn);
252 Ok(())
253 }
254
255 async fn create_pr(
256 &self,
257 run_id: &str,
258 issue: &PipelineIssue,
259 branch: &str,
260 repo_dir: &std::path::Path,
261 ) -> Result<u32> {
262 let (pr_title, pr_body) = match issue.source {
263 IssueOrigin::Github => (
264 format!("fix(#{}): {}", issue.number, issue.title),
265 format!(
266 "Resolves #{}\n\nAutomated by [oven](https://github.com/clayharmon/oven-cli).",
267 issue.number
268 ),
269 ),
270 IssueOrigin::Local => (
271 format!("fix: {}", issue.title),
272 format!(
273 "From local issue #{}\n\nAutomated by [oven](https://github.com/clayharmon/oven-cli).",
274 issue.number
275 ),
276 ),
277 };
278
279 git::push_branch(repo_dir, branch).await?;
280 let pr_number =
281 self.github.create_draft_pr_in(&pr_title, branch, &pr_body, repo_dir).await?;
282
283 {
284 let conn = self.db.lock().await;
285 db::runs::update_run_pr(&conn, run_id, pr_number)?;
286 }
287
288 info!(run_id = %run_id, pr = pr_number, "draft PR created");
289 Ok(pr_number)
290 }
291
292 async fn finalize_run(
293 &self,
294 run_id: &str,
295 issue: &PipelineIssue,
296 pr_number: u32,
297 result: &Result<()>,
298 target_dir: &std::path::Path,
299 ) -> Result<()> {
300 let (final_status, error_msg) = match result {
301 Ok(()) => {
302 self.issues
303 .transition(
304 issue.number,
305 &self.config.labels.cooking,
306 &self.config.labels.complete,
307 )
308 .await?;
309
310 let should_close =
314 issue.source == IssueOrigin::Local || issue.target_repo.is_some();
315
316 if should_close {
317 let comment = issue.target_repo.as_ref().map_or_else(
318 || format!("Implemented in #{pr_number}"),
319 |repo_name| format!("Implemented in {repo_name}#{pr_number}"),
320 );
321 if let Err(e) = self.issues.close(issue.number, Some(&comment)).await {
322 warn!(
323 run_id = %run_id,
324 error = %e,
325 "failed to close issue"
326 );
327 }
328 }
329
330 (RunStatus::Complete, None)
331 }
332 Err(e) => {
333 warn!(run_id = %run_id, error = %e, "pipeline failed");
334 github::safe_comment(
335 &self.github,
336 pr_number,
337 &format_pipeline_failure(e),
338 target_dir,
339 )
340 .await;
341 let _ = self
342 .issues
343 .transition(
344 issue.number,
345 &self.config.labels.cooking,
346 &self.config.labels.failed,
347 )
348 .await;
349 (RunStatus::Failed, Some(format!("{e:#}")))
350 }
351 };
352
353 let conn = self.db.lock().await;
354 db::runs::finish_run(&conn, run_id, final_status, error_msg.as_deref())
355 }
356
357 async fn run_steps(
358 &self,
359 run_id: &str,
360 ctx: &AgentContext,
361 worktree_path: &std::path::Path,
362 auto_merge: bool,
363 target_dir: &std::path::Path,
364 ) -> Result<()> {
365 self.check_cancelled()?;
366
367 self.update_status(run_id, RunStatus::Implementing).await?;
369 let impl_prompt = agents::implementer::build_prompt(ctx)?;
370 let impl_result =
371 self.run_agent(run_id, AgentRole::Implementer, &impl_prompt, worktree_path, 1).await?;
372
373 git::push_branch(worktree_path, &ctx.branch).await?;
374
375 if let Some(pr_number) = ctx.pr_number {
377 let body = build_pr_body(&impl_result.output, ctx);
378 if let Err(e) =
379 self.github.edit_pr_in(pr_number, &pr_title(ctx), &body, target_dir).await
380 {
381 warn!(run_id = %run_id, error = %e, "failed to update PR description");
382 }
383 if let Err(e) = self.github.mark_pr_ready_in(pr_number, target_dir).await {
384 warn!(run_id = %run_id, error = %e, "failed to mark PR ready");
385 }
386 }
387
388 if let Some(pr_number) = ctx.pr_number {
390 let summary = extract_impl_summary(&impl_result.output);
391 github::safe_comment(
392 &self.github,
393 pr_number,
394 &format_impl_comment(&summary),
395 target_dir,
396 )
397 .await;
398 }
399
400 self.run_review_fix_loop(run_id, ctx, worktree_path, target_dir).await?;
402
403 self.check_cancelled()?;
405 info!(run_id = %run_id, base = %ctx.base_branch, "rebasing onto base branch");
406 let rebase_outcome =
407 self.rebase_with_agent_fallback(run_id, ctx, worktree_path, target_dir).await?;
408
409 if let Some(pr_number) = ctx.pr_number {
410 github::safe_comment(
411 &self.github,
412 pr_number,
413 &format_rebase_comment(&rebase_outcome),
414 target_dir,
415 )
416 .await;
417 }
418
419 if let RebaseOutcome::Failed(ref msg) = rebase_outcome {
420 anyhow::bail!("rebase failed: {msg}");
421 }
422
423 git::force_push_branch(worktree_path, &ctx.branch).await?;
424
425 if auto_merge {
427 self.check_cancelled()?;
428 ctx.pr_number.context("no PR number for merge step")?;
429 self.update_status(run_id, RunStatus::Merging).await?;
430 let merge_prompt = agents::merger::build_prompt(ctx, auto_merge)?;
431 self.run_agent(run_id, AgentRole::Merger, &merge_prompt, worktree_path, 1).await?;
432
433 if let Some(pr_number) = ctx.pr_number {
434 github::safe_comment(&self.github, pr_number, &format_merge_comment(), target_dir)
435 .await;
436 }
437 } else if let Some(pr_number) = ctx.pr_number {
438 github::safe_comment(&self.github, pr_number, &format_ready_comment(), target_dir)
439 .await;
440 }
441
442 Ok(())
443 }
444
445 async fn run_review_fix_loop(
446 &self,
447 run_id: &str,
448 ctx: &AgentContext,
449 worktree_path: &std::path::Path,
450 target_dir: &std::path::Path,
451 ) -> Result<()> {
452 for cycle in 1..=3 {
453 self.check_cancelled()?;
454
455 self.update_status(run_id, RunStatus::Reviewing).await?;
456
457 let (prior_addressed, prior_disputes) =
458 self.gather_prior_findings(run_id, cycle).await?;
459
460 let review_prompt =
461 agents::reviewer::build_prompt(ctx, &prior_addressed, &prior_disputes)?;
462
463 let review_result = match self
465 .run_agent(run_id, AgentRole::Reviewer, &review_prompt, worktree_path, cycle)
466 .await
467 {
468 Ok(result) => result,
469 Err(e) => {
470 warn!(run_id = %run_id, cycle, error = %e, "reviewer agent failed, skipping review");
471 if let Some(pr_number) = ctx.pr_number {
472 github::safe_comment(
473 &self.github,
474 pr_number,
475 &format_review_skipped_comment(cycle, &e),
476 target_dir,
477 )
478 .await;
479 }
480 return Ok(());
481 }
482 };
483
484 let review_output = parse_review_output(&review_result.output);
485 self.store_findings(run_id, &review_output.findings).await?;
486
487 let actionable: Vec<_> =
488 review_output.findings.iter().filter(|f| f.severity != Severity::Info).collect();
489
490 if let Some(pr_number) = ctx.pr_number {
492 github::safe_comment(
493 &self.github,
494 pr_number,
495 &format_review_comment(cycle, &actionable),
496 target_dir,
497 )
498 .await;
499 }
500
501 if actionable.is_empty() {
502 info!(run_id = %run_id, cycle, "review clean");
503 return Ok(());
504 }
505
506 info!(run_id = %run_id, cycle, findings = actionable.len(), "review found issues");
507
508 if cycle == 3 {
509 if let Some(pr_number) = ctx.pr_number {
510 let comment = format_unresolved_comment(&actionable);
511 github::safe_comment(&self.github, pr_number, &comment, target_dir).await;
512 } else {
513 warn!(run_id = %run_id, "no PR number, cannot post unresolved findings");
514 }
515 return Ok(());
516 }
517
518 self.run_fix_step(run_id, ctx, worktree_path, target_dir, cycle).await?;
519 }
520
521 Ok(())
522 }
523
524 async fn gather_prior_findings(
526 &self,
527 run_id: &str,
528 cycle: u32,
529 ) -> Result<(Vec<ReviewFinding>, Vec<ReviewFinding>)> {
530 if cycle <= 1 {
531 return Ok((Vec::new(), Vec::new()));
532 }
533
534 let conn = self.db.lock().await;
535 let all_resolved = db::agent_runs::get_resolved_findings(&conn, run_id)?;
536 drop(conn);
537
538 let (mut addressed, disputed): (Vec<_>, Vec<_>) = all_resolved.into_iter().partition(|f| {
539 f.dispute_reason.as_deref().is_some_and(|r| r.starts_with("ADDRESSED: "))
540 });
541
542 for f in &mut addressed {
544 if let Some(ref mut reason) = f.dispute_reason {
545 if let Some(stripped) = reason.strip_prefix("ADDRESSED: ") {
546 *reason = stripped.to_string();
547 }
548 }
549 }
550
551 Ok((addressed, disputed))
552 }
553
554 async fn run_fix_step(
559 &self,
560 run_id: &str,
561 ctx: &AgentContext,
562 worktree_path: &std::path::Path,
563 target_dir: &std::path::Path,
564 cycle: u32,
565 ) -> Result<()> {
566 self.check_cancelled()?;
567 self.update_status(run_id, RunStatus::Fixing).await?;
568
569 let unresolved = {
570 let conn = self.db.lock().await;
571 db::agent_runs::get_unresolved_findings(&conn, run_id)?
572 };
573
574 let fix_prompt = agents::fixer::build_prompt(ctx, &unresolved)?;
575
576 let fix_result =
578 match self.run_agent(run_id, AgentRole::Fixer, &fix_prompt, worktree_path, cycle).await
579 {
580 Ok(result) => result,
581 Err(e) => {
582 warn!(run_id = %run_id, cycle, error = %e, "fixer agent failed, skipping fix");
583 if let Some(pr_number) = ctx.pr_number {
584 github::safe_comment(
585 &self.github,
586 pr_number,
587 &format_fix_skipped_comment(cycle, &e),
588 target_dir,
589 )
590 .await;
591 }
592 return Ok(());
593 }
594 };
595
596 let fixer_output = parse_fixer_output(&fix_result.output);
598 self.process_fixer_disputes(run_id, &unresolved, &fixer_output).await?;
599 self.process_fixer_addressed(run_id, &unresolved, &fixer_output).await?;
600
601 if let Some(pr_number) = ctx.pr_number {
603 github::safe_comment(
604 &self.github,
605 pr_number,
606 &format_fix_comment(cycle, &fixer_output),
607 target_dir,
608 )
609 .await;
610 }
611
612 git::push_branch(worktree_path, &ctx.branch).await?;
613 Ok(())
614 }
615
616 async fn process_fixer_disputes(
622 &self,
623 run_id: &str,
624 findings_sent_to_fixer: &[ReviewFinding],
625 fixer_output: &agents::FixerOutput,
626 ) -> Result<()> {
627 if fixer_output.disputed.is_empty() {
628 return Ok(());
629 }
630
631 let conn = self.db.lock().await;
632 for dispute in &fixer_output.disputed {
633 let idx = dispute.finding.saturating_sub(1) as usize;
635 if let Some(finding) = findings_sent_to_fixer.get(idx) {
636 db::agent_runs::resolve_finding(&conn, finding.id, &dispute.reason)?;
637 info!(
638 run_id = %run_id,
639 finding_id = finding.id,
640 reason = %dispute.reason,
641 "finding disputed by fixer, marked resolved"
642 );
643 }
644 }
645 drop(conn);
646 Ok(())
647 }
648
649 async fn process_fixer_addressed(
655 &self,
656 run_id: &str,
657 findings_sent_to_fixer: &[ReviewFinding],
658 fixer_output: &agents::FixerOutput,
659 ) -> Result<()> {
660 if fixer_output.addressed.is_empty() {
661 return Ok(());
662 }
663
664 let conn = self.db.lock().await;
665 for action in &fixer_output.addressed {
666 let idx = action.finding.saturating_sub(1) as usize;
667 if let Some(finding) = findings_sent_to_fixer.get(idx) {
668 let reason = format!("ADDRESSED: {}", action.action);
669 db::agent_runs::resolve_finding(&conn, finding.id, &reason)?;
670 info!(
671 run_id = %run_id,
672 finding_id = finding.id,
673 action = %action.action,
674 "finding addressed by fixer, marked resolved"
675 );
676 }
677 }
678 drop(conn);
679 Ok(())
680 }
681
682 async fn store_findings(&self, run_id: &str, findings: &[agents::Finding]) -> Result<()> {
683 let conn = self.db.lock().await;
684 let agent_runs = db::agent_runs::get_agent_runs_for_run(&conn, run_id)?;
685 let reviewer_run_id = agent_runs
686 .iter()
687 .rev()
688 .find_map(|ar| if ar.agent == "reviewer" { Some(ar.id) } else { None });
689 if let Some(ar_id) = reviewer_run_id {
690 for finding in findings {
691 let db_finding = ReviewFinding {
692 id: 0,
693 agent_run_id: ar_id,
694 severity: finding.severity.to_string(),
695 category: finding.category.clone(),
696 file_path: finding.file_path.clone(),
697 line_number: finding.line_number,
698 message: finding.message.clone(),
699 resolved: false,
700 dispute_reason: None,
701 };
702 db::agent_runs::insert_finding(&conn, &db_finding)?;
703 }
704 }
705 drop(conn);
706 Ok(())
707 }
708
709 async fn rebase_with_agent_fallback(
713 &self,
714 run_id: &str,
715 ctx: &AgentContext,
716 worktree_path: &std::path::Path,
717 target_dir: &std::path::Path,
718 ) -> Result<RebaseOutcome> {
719 let outcome = git::rebase_with_fallbacks(worktree_path, &ctx.base_branch).await;
720
721 let conflicting_files = match outcome {
722 RebaseOutcome::MergeConflicts(ref files) => files.clone(),
723 other => return Ok(other),
724 };
725
726 info!(
727 run_id = %run_id,
728 files = ?conflicting_files,
729 "rebase and merge failed, attempting agent conflict resolution"
730 );
731
732 if let Some(pr_number) = ctx.pr_number {
734 github::safe_comment(
735 &self.github,
736 pr_number,
737 &format!(
738 "### Resolving merge conflicts\n\n\
739 Rebase and merge both failed. Attempting agent-assisted resolution \
740 for {} conflicting file{}: {}{COMMENT_FOOTER}",
741 conflicting_files.len(),
742 if conflicting_files.len() == 1 { "" } else { "s" },
743 conflicting_files
744 .iter()
745 .map(|f| format!("`{f}`"))
746 .collect::<Vec<_>>()
747 .join(", "),
748 ),
749 target_dir,
750 )
751 .await;
752 }
753
754 let conflict_prompt = format!(
755 "You are resolving merge conflicts. The following files have unresolved \
756 merge conflicts (<<<<<<< / ======= / >>>>>>> markers):\n\n{}\n\n\
757 Open each file, find the conflict markers, and resolve them by choosing \
758 the correct code. Remove all conflict markers. Do NOT add new features \
759 or refactor -- just resolve the conflicts so the code compiles and tests pass.\n\n\
760 After resolving, run any test/lint commands if available:\n\
761 - Test: {}\n\
762 - Lint: {}",
763 conflicting_files.iter().map(|f| format!("- {f}")).collect::<Vec<_>>().join("\n"),
764 ctx.test_command.as_deref().unwrap_or("(none)"),
765 ctx.lint_command.as_deref().unwrap_or("(none)"),
766 );
767
768 match self
769 .run_agent(run_id, AgentRole::Implementer, &conflict_prompt, worktree_path, 1)
770 .await
771 {
772 Ok(_) => {}
773 Err(e) => {
774 warn!(run_id = %run_id, error = %e, "conflict resolution agent failed");
775 git::abort_merge(worktree_path).await;
776 return Ok(RebaseOutcome::Failed(format!(
777 "agent conflict resolution failed: {e:#}"
778 )));
779 }
780 }
781
782 let remaining = git::conflicting_files(worktree_path).await;
784 if remaining.is_empty() {
785 if let Err(e) = git::commit_merge(worktree_path, &conflicting_files).await {
786 git::abort_merge(worktree_path).await;
787 return Ok(RebaseOutcome::Failed(format!("failed to commit resolution: {e:#}")));
788 }
789 info!(run_id = %run_id, "agent resolved merge conflicts");
790 Ok(RebaseOutcome::AgentResolved)
791 } else {
792 warn!(
793 run_id = %run_id,
794 remaining = ?remaining,
795 "agent did not resolve all conflicts"
796 );
797 git::abort_merge(worktree_path).await;
798 Ok(RebaseOutcome::Failed(format!(
799 "agent could not resolve conflicts in: {}",
800 remaining.join(", ")
801 )))
802 }
803 }
804
805 async fn run_agent(
806 &self,
807 run_id: &str,
808 role: AgentRole,
809 prompt: &str,
810 working_dir: &std::path::Path,
811 cycle: u32,
812 ) -> Result<crate::process::AgentResult> {
813 let agent_run_id = self.record_agent_start(run_id, role, cycle).await?;
814
815 info!(run_id = %run_id, agent = %role, cycle, "agent starting");
816
817 let invocation = AgentInvocation {
818 role,
819 prompt: prompt.to_string(),
820 working_dir: working_dir.to_path_buf(),
821 max_turns: Some(self.config.pipeline.turn_limit),
822 };
823
824 let result = process::run_with_retry(self.runner.as_ref(), &invocation).await;
825
826 match &result {
827 Ok(agent_result) => {
828 self.record_agent_success(run_id, agent_run_id, agent_result).await?;
829 }
830 Err(e) => {
831 let conn = self.db.lock().await;
832 db::agent_runs::finish_agent_run(
833 &conn,
834 agent_run_id,
835 "failed",
836 0.0,
837 0,
838 None,
839 Some(&format!("{e:#}")),
840 None,
841 )?;
842 }
843 }
844
845 result
846 }
847
848 async fn record_agent_start(&self, run_id: &str, role: AgentRole, cycle: u32) -> Result<i64> {
849 let agent_run = AgentRun {
850 id: 0,
851 run_id: run_id.to_string(),
852 agent: role.to_string(),
853 cycle,
854 status: "running".to_string(),
855 cost_usd: 0.0,
856 turns: 0,
857 started_at: chrono::Utc::now().to_rfc3339(),
858 finished_at: None,
859 output_summary: None,
860 error_message: None,
861 raw_output: None,
862 };
863 let conn = self.db.lock().await;
864 db::agent_runs::insert_agent_run(&conn, &agent_run)
865 }
866
867 async fn record_agent_success(
868 &self,
869 run_id: &str,
870 agent_run_id: i64,
871 agent_result: &crate::process::AgentResult,
872 ) -> Result<()> {
873 let conn = self.db.lock().await;
874 db::agent_runs::finish_agent_run(
875 &conn,
876 agent_run_id,
877 "complete",
878 agent_result.cost_usd,
879 agent_result.turns,
880 Some(&truncate(&agent_result.output, 500)),
881 None,
882 Some(&agent_result.output),
883 )?;
884
885 let new_cost = db::runs::increment_run_cost(&conn, run_id, agent_result.cost_usd)?;
886 drop(conn);
887
888 if new_cost > self.config.pipeline.cost_budget {
889 anyhow::bail!(
890 "cost budget exceeded: ${:.2} > ${:.2}",
891 new_cost,
892 self.config.pipeline.cost_budget
893 );
894 }
895 Ok(())
896 }
897
898 async fn update_status(&self, run_id: &str, status: RunStatus) -> Result<()> {
899 let conn = self.db.lock().await;
900 db::runs::update_run_status(&conn, run_id, status)
901 }
902
903 fn check_cancelled(&self) -> Result<()> {
904 if self.cancel_token.is_cancelled() {
905 anyhow::bail!("pipeline cancelled");
906 }
907 Ok(())
908 }
909}
910
911const COMMENT_FOOTER: &str =
912 "\n---\nAutomated by [oven](https://github.com/clayharmon/oven-cli) \u{1F35E}";
913
914fn format_unresolved_comment(actionable: &[&agents::Finding]) -> String {
915 let mut comment = String::from(
916 "### Unresolved review findings\n\n\
917 The review-fix loop ran 2 cycles but these findings remain unresolved.\n",
918 );
919
920 for severity in &[Severity::Critical, Severity::Warning] {
922 let group: Vec<_> = actionable.iter().filter(|f| &f.severity == severity).collect();
923 if group.is_empty() {
924 continue;
925 }
926 let heading = match severity {
927 Severity::Critical => "Critical",
928 Severity::Warning => "Warning",
929 Severity::Info => unreachable!("loop only iterates Critical and Warning"),
930 };
931 let _ = writeln!(comment, "\n#### {heading}\n");
932 for f in group {
933 let loc = match (&f.file_path, f.line_number) {
934 (Some(path), Some(line)) => format!(" in `{path}:{line}`"),
935 (Some(path), None) => format!(" in `{path}`"),
936 _ => String::new(),
937 };
938 let _ = writeln!(comment, "- **{}**{} -- {}", f.category, loc, f.message);
939 }
940 }
941
942 comment.push_str(COMMENT_FOOTER);
943 comment
944}
945
946fn format_impl_comment(summary: &str) -> String {
947 format!("### Implementation complete\n\n{summary}{COMMENT_FOOTER}")
948}
949
950fn format_review_comment(cycle: u32, actionable: &[&agents::Finding]) -> String {
951 if actionable.is_empty() {
952 return format!(
953 "### Review complete (cycle {cycle})\n\n\
954 Clean review, no actionable findings.{COMMENT_FOOTER}"
955 );
956 }
957
958 let mut comment = format!(
959 "### Review complete (cycle {cycle})\n\n\
960 **{count} finding{s}:**\n",
961 count = actionable.len(),
962 s = if actionable.len() == 1 { "" } else { "s" },
963 );
964
965 for f in actionable {
966 let loc = match (&f.file_path, f.line_number) {
967 (Some(path), Some(line)) => format!(" in `{path}:{line}`"),
968 (Some(path), None) => format!(" in `{path}`"),
969 _ => String::new(),
970 };
971 let _ = writeln!(
972 comment,
973 "- [{sev}] **{cat}**{loc} -- {msg}",
974 sev = f.severity,
975 cat = f.category,
976 msg = f.message,
977 );
978 }
979
980 comment.push_str(COMMENT_FOOTER);
981 comment
982}
983
984fn format_fix_comment(cycle: u32, fixer: &agents::FixerOutput) -> String {
985 let addressed = fixer.addressed.len();
986 let disputed = fixer.disputed.len();
987 format!(
988 "### Fix complete (cycle {cycle})\n\n\
989 **Addressed:** {addressed} finding{a_s}\n\
990 **Disputed:** {disputed} finding{d_s}{COMMENT_FOOTER}",
991 a_s = if addressed == 1 { "" } else { "s" },
992 d_s = if disputed == 1 { "" } else { "s" },
993 )
994}
995
996fn format_review_skipped_comment(cycle: u32, error: &anyhow::Error) -> String {
997 format!(
998 "### Review skipped (cycle {cycle})\n\n\
999 Reviewer agent encountered an error. Continuing without review.\n\n\
1000 **Error:** {error:#}{COMMENT_FOOTER}"
1001 )
1002}
1003
1004fn format_fix_skipped_comment(cycle: u32, error: &anyhow::Error) -> String {
1005 format!(
1006 "### Fix skipped (cycle {cycle})\n\n\
1007 Fixer agent encountered an error. Continuing to next cycle.\n\n\
1008 **Error:** {error:#}{COMMENT_FOOTER}"
1009 )
1010}
1011
1012fn format_rebase_comment(outcome: &RebaseOutcome) -> String {
1013 match outcome {
1014 RebaseOutcome::Clean => {
1015 format!("### Rebase\n\nRebased onto base branch cleanly.{COMMENT_FOOTER}")
1016 }
1017 RebaseOutcome::MergeFallback => {
1018 format!(
1019 "### Rebase\n\n\
1020 Rebase had conflicts, fell back to a merge commit.{COMMENT_FOOTER}"
1021 )
1022 }
1023 RebaseOutcome::AgentResolved => {
1024 format!(
1025 "### Rebase\n\n\
1026 Rebase and merge both had conflicts. Agent resolved them.{COMMENT_FOOTER}"
1027 )
1028 }
1029 RebaseOutcome::MergeConflicts(_) => {
1030 format!(
1031 "### Rebase\n\n\
1032 Merge conflicts present (awaiting resolution).{COMMENT_FOOTER}"
1033 )
1034 }
1035 RebaseOutcome::Failed(msg) => {
1036 format!(
1037 "### Rebase failed\n\n\
1038 Could not rebase or merge onto the base branch.\n\n\
1039 **Error:** {msg}{COMMENT_FOOTER}"
1040 )
1041 }
1042 }
1043}
1044
1045fn format_ready_comment() -> String {
1046 format!(
1047 "### Ready for review\n\nPipeline complete. This PR is ready for manual review.{COMMENT_FOOTER}"
1048 )
1049}
1050
1051fn format_merge_comment() -> String {
1052 format!("### Merged\n\nPipeline complete. PR has been merged.{COMMENT_FOOTER}")
1053}
1054
1055fn format_pipeline_failure(e: &anyhow::Error) -> String {
1056 format!(
1057 "## Pipeline failed\n\n\
1058 **Error:** {e:#}\n\n\
1059 The pipeline hit an unrecoverable error. Check the run logs for detail, \
1060 or re-run the pipeline.\
1061 {COMMENT_FOOTER}"
1062 )
1063}
1064
1065fn pr_title(ctx: &AgentContext) -> String {
1070 let prefix = infer_commit_type(&ctx.issue_title);
1071 if ctx.issue_source == "github" {
1072 format!("{prefix}(#{}): {}", ctx.issue_number, ctx.issue_title)
1073 } else {
1074 format!("{prefix}: {}", ctx.issue_title)
1075 }
1076}
1077
1078fn infer_commit_type(title: &str) -> &'static str {
1080 let lower = title.to_lowercase();
1081 if lower.starts_with("feat") || lower.contains("add ") || lower.contains("implement ") {
1082 "feat"
1083 } else if lower.starts_with("refactor") {
1084 "refactor"
1085 } else if lower.starts_with("docs") || lower.starts_with("document") {
1086 "docs"
1087 } else if lower.starts_with("test") || lower.starts_with("add test") {
1088 "test"
1089 } else if lower.starts_with("chore") {
1090 "chore"
1091 } else {
1092 "fix"
1093 }
1094}
1095
1096fn build_pr_body(impl_output: &str, ctx: &AgentContext) -> String {
1098 let issue_ref = if ctx.issue_source == "github" {
1099 format!("Resolves #{}", ctx.issue_number)
1100 } else {
1101 format!("From local issue #{}", ctx.issue_number)
1102 };
1103
1104 let summary = extract_impl_summary(impl_output);
1105
1106 let mut body = String::new();
1107 let _ = writeln!(body, "{issue_ref}\n");
1108 let _ = write!(body, "{summary}");
1109 body.push_str(COMMENT_FOOTER);
1110 body
1111}
1112
1113fn extract_impl_summary(output: &str) -> String {
1119 let idx = output.find("## PR Template").or_else(|| output.find("## Changes Made"));
1121
1122 if let Some(idx) = idx {
1123 let summary = output[idx..].trim();
1124 let summary = summary.strip_prefix("## PR Template").map_or(summary, |s| s.trim_start());
1126 if summary.len() <= 4000 {
1127 return summary.to_string();
1128 }
1129 return truncate(summary, 4000);
1130 }
1131 String::from("*No implementation summary available. See commit history for details.*")
1134}
1135
1136fn new_run(run_id: &str, issue: &PipelineIssue, auto_merge: bool) -> Run {
1137 Run {
1138 id: run_id.to_string(),
1139 issue_number: issue.number,
1140 status: RunStatus::Pending,
1141 pr_number: None,
1142 branch: None,
1143 worktree_path: None,
1144 cost_usd: 0.0,
1145 auto_merge,
1146 started_at: chrono::Utc::now().to_rfc3339(),
1147 finished_at: None,
1148 error_message: None,
1149 complexity: "full".to_string(),
1150 issue_source: issue.source.to_string(),
1151 }
1152}
1153
1154pub fn generate_run_id() -> String {
1156 uuid::Uuid::new_v4().to_string()[..8].to_string()
1157}
1158
1159pub(crate) fn truncate(s: &str, max_len: usize) -> String {
1164 if s.len() <= max_len {
1165 return s.to_string();
1166 }
1167 let target = max_len.saturating_sub(3);
1168 let mut end = target;
1169 while end > 0 && !s.is_char_boundary(end) {
1170 end -= 1;
1171 }
1172 format!("{}...", &s[..end])
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177 use proptest::prelude::*;
1178
1179 use super::*;
1180
1181 proptest! {
1182 #[test]
1183 fn run_ids_always_8_hex_chars(_seed in any::<u64>()) {
1184 let id = generate_run_id();
1185 prop_assert_eq!(id.len(), 8);
1186 prop_assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1187 }
1188 }
1189
1190 #[test]
1191 fn run_id_is_8_hex_chars() {
1192 let id = generate_run_id();
1193 assert_eq!(id.len(), 8);
1194 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1195 }
1196
1197 #[test]
1198 fn run_ids_are_unique() {
1199 let ids: Vec<_> = (0..100).map(|_| generate_run_id()).collect();
1200 let unique: std::collections::HashSet<_> = ids.iter().collect();
1201 assert_eq!(ids.len(), unique.len());
1202 }
1203
1204 #[test]
1205 fn truncate_short_string() {
1206 assert_eq!(truncate("hello", 10), "hello");
1207 }
1208
1209 #[test]
1210 fn truncate_long_string() {
1211 let long = "a".repeat(100);
1212 let result = truncate(&long, 10);
1213 assert_eq!(result.len(), 10); assert!(result.ends_with("..."));
1215 }
1216
1217 #[test]
1218 fn truncate_multibyte_does_not_panic() {
1219 let s = "πππ";
1222 let result = truncate(s, 8);
1223 assert!(result.ends_with("..."));
1224 assert!(result.starts_with("π"));
1225 assert!(result.len() <= 8);
1226 }
1227
1228 #[test]
1229 fn truncate_cjk_boundary() {
1230 let s = "δ½ ε₯½δΈηζ΅θ―"; let result = truncate(s, 10);
1234 assert!(result.ends_with("..."));
1235 assert!(result.starts_with("δ½ ε₯½"));
1236 assert!(result.len() <= 10);
1237 }
1238
1239 #[test]
1240 fn extract_impl_summary_finds_changes_made() {
1241 let output = "Some preamble text\n\n## Changes Made\n- src/foo.rs: added bar\n\n## Tests Added\n- tests/foo.rs: bar test\n";
1242 let summary = extract_impl_summary(output);
1243 assert!(summary.starts_with("## Changes Made"));
1244 assert!(summary.contains("added bar"));
1245 assert!(summary.contains("## Tests Added"));
1246 }
1247
1248 #[test]
1249 fn extract_impl_summary_prefers_pr_template() {
1250 let output = "Preamble\n\n## PR Template\n## Summary\n- Added auth flow\n\n## Testing\n- Unit tests pass\n";
1251 let summary = extract_impl_summary(output);
1252 assert!(!summary.contains("## PR Template"));
1254 assert!(summary.starts_with("## Summary"));
1255 assert!(summary.contains("Added auth flow"));
1256 }
1257
1258 #[test]
1259 fn extract_impl_summary_fallback_on_no_heading() {
1260 let output = "just some raw agent output with no structure";
1261 let summary = extract_impl_summary(output);
1262 assert_eq!(
1263 summary,
1264 "*No implementation summary available. See commit history for details.*"
1265 );
1266 }
1267
1268 #[test]
1269 fn extract_impl_summary_empty_output() {
1270 let placeholder = "*No implementation summary available. See commit history for details.*";
1271 assert_eq!(extract_impl_summary(""), placeholder);
1272 assert_eq!(extract_impl_summary(" "), placeholder);
1273 }
1274
1275 #[test]
1276 fn build_pr_body_github_issue() {
1277 let ctx = AgentContext {
1278 issue_number: 42,
1279 issue_title: "fix the thing".to_string(),
1280 issue_body: String::new(),
1281 branch: "oven/issue-42".to_string(),
1282 pr_number: Some(10),
1283 test_command: None,
1284 lint_command: None,
1285 review_findings: None,
1286 cycle: 1,
1287 target_repo: None,
1288 issue_source: "github".to_string(),
1289 base_branch: "main".to_string(),
1290 };
1291 let body = build_pr_body("## Changes Made\n- added stuff", &ctx);
1292 assert!(body.contains("Resolves #42"));
1293 assert!(body.contains("## Changes Made"));
1294 assert!(body.contains("Automated by [oven]"));
1295 }
1296
1297 #[test]
1298 fn build_pr_body_local_issue() {
1299 let ctx = AgentContext {
1300 issue_number: 7,
1301 issue_title: "local thing".to_string(),
1302 issue_body: String::new(),
1303 branch: "oven/issue-7".to_string(),
1304 pr_number: Some(10),
1305 test_command: None,
1306 lint_command: None,
1307 review_findings: None,
1308 cycle: 1,
1309 target_repo: None,
1310 issue_source: "local".to_string(),
1311 base_branch: "main".to_string(),
1312 };
1313 let body = build_pr_body("## Changes Made\n- did local stuff", &ctx);
1314 assert!(body.contains("From local issue #7"));
1315 assert!(body.contains("## Changes Made"));
1316 }
1317
1318 #[test]
1319 fn pr_title_github() {
1320 let ctx = AgentContext {
1321 issue_number: 42,
1322 issue_title: "fix the thing".to_string(),
1323 issue_body: String::new(),
1324 branch: String::new(),
1325 pr_number: None,
1326 test_command: None,
1327 lint_command: None,
1328 review_findings: None,
1329 cycle: 1,
1330 target_repo: None,
1331 issue_source: "github".to_string(),
1332 base_branch: "main".to_string(),
1333 };
1334 assert_eq!(pr_title(&ctx), "fix(#42): fix the thing");
1335 }
1336
1337 #[test]
1338 fn pr_title_local() {
1339 let ctx = AgentContext {
1340 issue_number: 7,
1341 issue_title: "local thing".to_string(),
1342 issue_body: String::new(),
1343 branch: String::new(),
1344 pr_number: None,
1345 test_command: None,
1346 lint_command: None,
1347 review_findings: None,
1348 cycle: 1,
1349 target_repo: None,
1350 issue_source: "local".to_string(),
1351 base_branch: "main".to_string(),
1352 };
1353 assert_eq!(pr_title(&ctx), "fix: local thing");
1354 }
1355
1356 #[test]
1357 fn infer_commit_type_feat() {
1358 assert_eq!(infer_commit_type("Add dark mode support"), "feat");
1359 assert_eq!(infer_commit_type("Implement caching layer"), "feat");
1360 assert_eq!(infer_commit_type("Feature: new dashboard"), "feat");
1361 }
1362
1363 #[test]
1364 fn infer_commit_type_refactor() {
1365 assert_eq!(infer_commit_type("Refactor auth middleware"), "refactor");
1366 }
1367
1368 #[test]
1369 fn infer_commit_type_docs() {
1370 assert_eq!(infer_commit_type("Document the API endpoints"), "docs");
1371 assert_eq!(infer_commit_type("Docs: update README"), "docs");
1372 }
1373
1374 #[test]
1375 fn infer_commit_type_defaults_to_fix() {
1376 assert_eq!(infer_commit_type("Null pointer in config parser"), "fix");
1377 assert_eq!(infer_commit_type("Crash on empty input"), "fix");
1378 }
1379
1380 #[test]
1381 fn pr_title_feat_github() {
1382 let ctx = AgentContext {
1383 issue_number: 10,
1384 issue_title: "Add dark mode".to_string(),
1385 issue_body: String::new(),
1386 branch: String::new(),
1387 pr_number: None,
1388 test_command: None,
1389 lint_command: None,
1390 review_findings: None,
1391 cycle: 1,
1392 target_repo: None,
1393 issue_source: "github".to_string(),
1394 base_branch: "main".to_string(),
1395 };
1396 assert_eq!(pr_title(&ctx), "feat(#10): Add dark mode");
1397 }
1398
1399 #[test]
1400 fn format_unresolved_comment_groups_by_severity() {
1401 let findings = [
1402 agents::Finding {
1403 severity: Severity::Critical,
1404 category: "bug".to_string(),
1405 file_path: Some("src/main.rs".to_string()),
1406 line_number: Some(42),
1407 message: "null pointer".to_string(),
1408 },
1409 agents::Finding {
1410 severity: Severity::Warning,
1411 category: "style".to_string(),
1412 file_path: None,
1413 line_number: None,
1414 message: "missing docs".to_string(),
1415 },
1416 ];
1417 let refs: Vec<_> = findings.iter().collect();
1418 let comment = format_unresolved_comment(&refs);
1419 assert!(comment.contains("#### Critical"));
1420 assert!(comment.contains("#### Warning"));
1421 assert!(comment.contains("**bug** in `src/main.rs:42` -- null pointer"));
1422 assert!(comment.contains("**style** -- missing docs"));
1423 assert!(comment.contains("Automated by [oven]"));
1424 }
1425
1426 #[test]
1427 fn format_unresolved_comment_skips_empty_severity_groups() {
1428 let findings = [agents::Finding {
1429 severity: Severity::Warning,
1430 category: "testing".to_string(),
1431 file_path: Some("src/lib.rs".to_string()),
1432 line_number: None,
1433 message: "missing edge case test".to_string(),
1434 }];
1435 let refs: Vec<_> = findings.iter().collect();
1436 let comment = format_unresolved_comment(&refs);
1437 assert!(!comment.contains("#### Critical"));
1438 assert!(comment.contains("#### Warning"));
1439 }
1440
1441 #[test]
1442 fn format_pipeline_failure_includes_error() {
1443 let err = anyhow::anyhow!("cost budget exceeded: $12.50 > $10.00");
1444 let comment = format_pipeline_failure(&err);
1445 assert!(comment.contains("## Pipeline failed"));
1446 assert!(comment.contains("cost budget exceeded"));
1447 assert!(comment.contains("Automated by [oven]"));
1448 }
1449
1450 #[test]
1451 fn format_impl_comment_includes_summary() {
1452 let comment = format_impl_comment("Added login endpoint with tests");
1453 assert!(comment.contains("### Implementation complete"));
1454 assert!(comment.contains("Added login endpoint with tests"));
1455 assert!(comment.contains("Automated by [oven]"));
1456 }
1457
1458 #[test]
1459 fn format_review_comment_clean() {
1460 let comment = format_review_comment(1, &[]);
1461 assert!(comment.contains("### Review complete (cycle 1)"));
1462 assert!(comment.contains("Clean review"));
1463 }
1464
1465 #[test]
1466 fn format_review_comment_with_findings() {
1467 let findings = [agents::Finding {
1468 severity: Severity::Critical,
1469 category: "bug".to_string(),
1470 file_path: Some("src/main.rs".to_string()),
1471 line_number: Some(42),
1472 message: "null pointer".to_string(),
1473 }];
1474 let refs: Vec<_> = findings.iter().collect();
1475 let comment = format_review_comment(1, &refs);
1476 assert!(comment.contains("### Review complete (cycle 1)"));
1477 assert!(comment.contains("1 finding"));
1478 assert!(comment.contains("[critical]"));
1479 assert!(comment.contains("`src/main.rs:42`"));
1480 }
1481
1482 #[test]
1483 fn format_fix_comment_counts() {
1484 let fixer = agents::FixerOutput {
1485 addressed: vec![
1486 agents::FixerAction { finding: 1, action: "fixed it".to_string() },
1487 agents::FixerAction { finding: 2, action: "also fixed".to_string() },
1488 ],
1489 disputed: vec![agents::FixerDispute { finding: 3, reason: "not a bug".to_string() }],
1490 };
1491 let comment = format_fix_comment(1, &fixer);
1492 assert!(comment.contains("### Fix complete (cycle 1)"));
1493 assert!(comment.contains("Addressed:** 2 findings"));
1494 assert!(comment.contains("Disputed:** 1 finding\n"));
1495 }
1496
1497 #[test]
1498 fn format_rebase_comment_variants() {
1499 let clean = format_rebase_comment(&RebaseOutcome::Clean);
1500 assert!(clean.contains("Rebased onto base branch cleanly"));
1501
1502 let merge = format_rebase_comment(&RebaseOutcome::MergeFallback);
1503 assert!(merge.contains("fell back to a merge commit"));
1504
1505 let agent = format_rebase_comment(&RebaseOutcome::AgentResolved);
1506 assert!(agent.contains("Agent resolved them"));
1507
1508 let conflicts =
1509 format_rebase_comment(&RebaseOutcome::MergeConflicts(vec!["foo.rs".into()]));
1510 assert!(conflicts.contains("awaiting resolution"));
1511
1512 let failed = format_rebase_comment(&RebaseOutcome::Failed("conflict in foo.rs".into()));
1513 assert!(failed.contains("Rebase failed"));
1514 assert!(failed.contains("conflict in foo.rs"));
1515 }
1516
1517 #[test]
1518 fn format_ready_comment_content() {
1519 let comment = format_ready_comment();
1520 assert!(comment.contains("### Ready for review"));
1521 assert!(comment.contains("manual review"));
1522 }
1523
1524 #[test]
1525 fn format_merge_comment_content() {
1526 let comment = format_merge_comment();
1527 assert!(comment.contains("### Merged"));
1528 }
1529}