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?;
135 return Err(anyhow::anyhow!("{e:#}"));
136 }
137
138 self.update_status(&run_id, RunStatus::AwaitingMerge).await?;
140
141 Ok(PipelineOutcome { run_id, pr_number, worktree_path: worktree.path, target_dir })
142 }
143
144 pub async fn finalize_merge(
149 &self,
150 outcome: &PipelineOutcome,
151 issue: &PipelineIssue,
152 ) -> Result<()> {
153 self.finalize_run(&outcome.run_id, issue, outcome.pr_number, &Ok(()), &outcome.target_dir)
154 .await?;
155 if let Err(e) = git::remove_worktree(&outcome.target_dir, &outcome.worktree_path).await {
156 warn!(
157 run_id = %outcome.run_id,
158 error = %e,
159 "failed to clean up worktree after merge"
160 );
161 }
162 Ok(())
163 }
164
165 pub async fn plan_issues(
172 &self,
173 issues: &[PipelineIssue],
174 graph_context: &[GraphContextNode],
175 ) -> Option<PlannerGraphOutput> {
176 let prompt = match agents::planner::build_prompt(issues, graph_context) {
177 Ok(p) => p,
178 Err(e) => {
179 warn!(error = %e, "planner prompt build failed");
180 return None;
181 }
182 };
183 let invocation = AgentInvocation {
184 role: AgentRole::Planner,
185 prompt,
186 working_dir: self.repo_dir.clone(),
187 max_turns: Some(self.config.pipeline.turn_limit),
188 model: self.config.models.model_for(AgentRole::Planner.as_str()).map(String::from),
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 let pr_number = ctx.pr_number.context("no PR number for merge step")?;
429 self.update_status(run_id, RunStatus::Merging).await?;
430
431 self.github
432 .merge_pr_in(pr_number, &self.config.pipeline.merge_strategy, target_dir)
433 .await?;
434 info!(run_id = %run_id, pr = pr_number, "PR merged");
435
436 if ctx.target_repo.is_none() && ctx.issue_source == "github" {
439 if let Err(e) = self
440 .github
441 .close_issue(ctx.issue_number, Some(&format!("Implemented in #{pr_number}")))
442 .await
443 {
444 warn!(run_id = %run_id, error = %e, "failed to close issue after merge");
445 }
446 }
447
448 github::safe_comment(&self.github, pr_number, &format_merge_comment(), target_dir)
449 .await;
450 } else if let Some(pr_number) = ctx.pr_number {
451 github::safe_comment(&self.github, pr_number, &format_ready_comment(), target_dir)
452 .await;
453 }
454
455 Ok(())
456 }
457
458 async fn run_review_fix_loop(
459 &self,
460 run_id: &str,
461 ctx: &AgentContext,
462 worktree_path: &std::path::Path,
463 target_dir: &std::path::Path,
464 ) -> Result<()> {
465 let mut pre_fix_ref: Option<String> = None;
466
467 for cycle in 1..=3 {
468 self.check_cancelled()?;
469
470 self.update_status(run_id, RunStatus::Reviewing).await?;
471
472 let (prior_addressed, prior_disputes, prior_unresolved) =
473 self.gather_prior_findings(run_id, cycle).await?;
474
475 let review_prompt = agents::reviewer::build_prompt(
476 ctx,
477 &prior_addressed,
478 &prior_disputes,
479 &prior_unresolved,
480 pre_fix_ref.as_deref(),
481 )?;
482
483 let review_result = match self
485 .run_agent(run_id, AgentRole::Reviewer, &review_prompt, worktree_path, cycle)
486 .await
487 {
488 Ok(result) => result,
489 Err(e) => {
490 warn!(run_id = %run_id, cycle, error = %e, "reviewer agent failed, skipping review");
491 if let Some(pr_number) = ctx.pr_number {
492 github::safe_comment(
493 &self.github,
494 pr_number,
495 &format_review_skipped_comment(cycle, &e),
496 target_dir,
497 )
498 .await;
499 }
500 return Ok(());
501 }
502 };
503
504 let review_output = parse_review_output(&review_result.output);
505 self.store_findings(run_id, &review_output.findings).await?;
506
507 let actionable: Vec<_> =
508 review_output.findings.iter().filter(|f| f.severity != Severity::Info).collect();
509
510 if let Some(pr_number) = ctx.pr_number {
512 github::safe_comment(
513 &self.github,
514 pr_number,
515 &format_review_comment(cycle, &actionable),
516 target_dir,
517 )
518 .await;
519 }
520
521 if actionable.is_empty() {
522 info!(run_id = %run_id, cycle, "review clean");
523 return Ok(());
524 }
525
526 info!(run_id = %run_id, cycle, findings = actionable.len(), "review found issues");
527
528 if cycle == 3 {
529 if let Some(pr_number) = ctx.pr_number {
530 let comment = format_unresolved_comment(&actionable);
531 github::safe_comment(&self.github, pr_number, &comment, target_dir).await;
532 } else {
533 warn!(run_id = %run_id, "no PR number, cannot post unresolved findings");
534 }
535 return Ok(());
536 }
537
538 pre_fix_ref = Some(git::head_sha(worktree_path).await?);
540
541 self.run_fix_step(run_id, ctx, worktree_path, target_dir, cycle).await?;
542 }
543
544 Ok(())
545 }
546
547 async fn gather_prior_findings(
549 &self,
550 run_id: &str,
551 cycle: u32,
552 ) -> Result<(Vec<ReviewFinding>, Vec<ReviewFinding>, Vec<ReviewFinding>)> {
553 if cycle <= 1 {
554 return Ok((Vec::new(), Vec::new(), Vec::new()));
555 }
556
557 let conn = self.db.lock().await;
558 let all_resolved = db::agent_runs::get_resolved_findings(&conn, run_id)?;
559 let all_unresolved = db::agent_runs::get_unresolved_findings(&conn, run_id)?;
560 drop(conn);
561
562 let (mut addressed, disputed): (Vec<_>, Vec<_>) = all_resolved.into_iter().partition(|f| {
563 f.dispute_reason.as_deref().is_some_and(|r| r.starts_with("ADDRESSED: "))
564 });
565
566 for f in &mut addressed {
568 if let Some(ref mut reason) = f.dispute_reason {
569 if let Some(stripped) = reason.strip_prefix("ADDRESSED: ") {
570 *reason = stripped.to_string();
571 }
572 }
573 }
574
575 Ok((addressed, disputed, all_unresolved))
576 }
577
578 async fn run_fix_step(
588 &self,
589 run_id: &str,
590 ctx: &AgentContext,
591 worktree_path: &std::path::Path,
592 target_dir: &std::path::Path,
593 cycle: u32,
594 ) -> Result<()> {
595 self.check_cancelled()?;
596 self.update_status(run_id, RunStatus::Fixing).await?;
597
598 let actionable = self.filter_actionable_findings(run_id).await?;
599
600 if actionable.is_empty() {
601 info!(run_id = %run_id, cycle, "no actionable findings for fixer, skipping");
602 if let Some(pr_number) = ctx.pr_number {
603 github::safe_comment(
604 &self.github,
605 pr_number,
606 &format!(
607 "### Fix skipped (cycle {cycle})\n\n\
608 No actionable findings (all findings lacked file paths).\
609 {COMMENT_FOOTER}"
610 ),
611 target_dir,
612 )
613 .await;
614 }
615 return Ok(());
616 }
617
618 let pre_fix_head = git::head_sha(worktree_path).await?;
620
621 let fix_prompt = agents::fixer::build_prompt(ctx, &actionable)?;
622
623 let fix_result =
625 match self.run_agent(run_id, AgentRole::Fixer, &fix_prompt, worktree_path, cycle).await
626 {
627 Ok(result) => result,
628 Err(e) => {
629 warn!(run_id = %run_id, cycle, error = %e, "fixer agent failed, skipping fix");
630 if let Some(pr_number) = ctx.pr_number {
631 github::safe_comment(
632 &self.github,
633 pr_number,
634 &format_fix_skipped_comment(cycle, &e),
635 target_dir,
636 )
637 .await;
638 }
639 return Ok(());
640 }
641 };
642
643 let fixer_output = parse_fixer_output(&fix_result.output);
645 let fixer_did_nothing =
646 fixer_output.addressed.is_empty() && fixer_output.disputed.is_empty();
647
648 let new_commits = if fixer_did_nothing {
649 git::commit_count_since(worktree_path, &pre_fix_head).await.unwrap_or(0)
650 } else {
651 0
652 };
653
654 if fixer_did_nothing {
655 if new_commits > 0 {
656 warn!(
659 run_id = %run_id, cycle, commits = new_commits,
660 "fixer output unparseable but commits exist, inferring addressed from git"
661 );
662 self.infer_addressed_from_git(run_id, &actionable, worktree_path, &pre_fix_head)
663 .await?;
664 } else {
665 warn!(
667 run_id = %run_id, cycle,
668 "fixer produced no output and no commits, marking findings not actionable"
669 );
670 let conn = self.db.lock().await;
671 for f in &actionable {
672 db::agent_runs::resolve_finding(
673 &conn,
674 f.id,
675 "ADDRESSED: fixer could not act on this finding (no commits, no output)",
676 )?;
677 }
678 drop(conn);
679 }
680 } else {
681 self.process_fixer_results(run_id, &actionable, &fixer_output).await?;
682 }
683
684 if let Some(pr_number) = ctx.pr_number {
686 let comment = if fixer_did_nothing {
687 format_fixer_recovery_comment(cycle, new_commits)
688 } else {
689 format_fix_comment(cycle, &fixer_output)
690 };
691 github::safe_comment(&self.github, pr_number, &comment, target_dir).await;
692 }
693
694 git::push_branch(worktree_path, &ctx.branch).await?;
695 Ok(())
696 }
697
698 async fn process_fixer_results(
706 &self,
707 run_id: &str,
708 findings_sent_to_fixer: &[ReviewFinding],
709 fixer_output: &agents::FixerOutput,
710 ) -> Result<()> {
711 if fixer_output.disputed.is_empty() && fixer_output.addressed.is_empty() {
712 return Ok(());
713 }
714
715 let conn = self.db.lock().await;
716
717 for dispute in &fixer_output.disputed {
718 let idx = dispute.finding.saturating_sub(1) as usize;
719 if let Some(finding) = findings_sent_to_fixer.get(idx) {
720 db::agent_runs::resolve_finding(&conn, finding.id, &dispute.reason)?;
721 info!(
722 run_id = %run_id,
723 finding_id = finding.id,
724 reason = %dispute.reason,
725 "finding disputed by fixer, marked resolved"
726 );
727 }
728 }
729
730 for action in &fixer_output.addressed {
731 let idx = action.finding.saturating_sub(1) as usize;
732 if let Some(finding) = findings_sent_to_fixer.get(idx) {
733 let reason = format!("ADDRESSED: {}", action.action);
734 db::agent_runs::resolve_finding(&conn, finding.id, &reason)?;
735 info!(
736 run_id = %run_id,
737 finding_id = finding.id,
738 action = %action.action,
739 "finding addressed by fixer, marked resolved"
740 );
741 }
742 }
743
744 drop(conn);
745 Ok(())
746 }
747
748 async fn filter_actionable_findings(&self, run_id: &str) -> Result<Vec<ReviewFinding>> {
753 let conn = self.db.lock().await;
754 let unresolved = db::agent_runs::get_unresolved_findings(&conn, run_id)?;
755
756 let (actionable, non_actionable): (Vec<_>, Vec<_>) =
757 unresolved.into_iter().partition(|f| f.file_path.is_some());
758
759 if !non_actionable.is_empty() {
760 warn!(
761 run_id = %run_id,
762 count = non_actionable.len(),
763 "auto-resolving non-actionable findings (no file_path)"
764 );
765 for f in &non_actionable {
766 db::agent_runs::resolve_finding(
767 &conn,
768 f.id,
769 "ADDRESSED: auto-resolved -- finding has no file path, not actionable by fixer",
770 )?;
771 }
772 }
773
774 drop(conn);
775 Ok(actionable)
776 }
777
778 async fn infer_addressed_from_git(
783 &self,
784 run_id: &str,
785 findings: &[ReviewFinding],
786 worktree_path: &std::path::Path,
787 pre_fix_head: &str,
788 ) -> Result<()> {
789 let changed_files =
790 git::changed_files_since(worktree_path, pre_fix_head).await.unwrap_or_default();
791
792 let conn = self.db.lock().await;
793 for f in findings {
794 let was_touched =
795 f.file_path.as_ref().is_some_and(|fp| changed_files.iter().any(|cf| cf == fp));
796
797 let reason = if was_touched {
798 "ADDRESSED: inferred from git -- fixer modified this file (no structured output)"
799 } else {
800 "ADDRESSED: inferred from git -- file not modified (no structured output)"
801 };
802
803 db::agent_runs::resolve_finding(&conn, f.id, reason)?;
804 info!(
805 run_id = %run_id,
806 finding_id = f.id,
807 file = ?f.file_path,
808 touched = was_touched,
809 "finding resolved via git inference"
810 );
811 }
812 drop(conn);
813 Ok(())
814 }
815
816 async fn store_findings(&self, run_id: &str, findings: &[agents::Finding]) -> Result<()> {
817 let conn = self.db.lock().await;
818 let agent_runs = db::agent_runs::get_agent_runs_for_run(&conn, run_id)?;
819 let reviewer_run_id = agent_runs
820 .iter()
821 .rev()
822 .find_map(|ar| if ar.agent == "reviewer" { Some(ar.id) } else { None });
823 if let Some(ar_id) = reviewer_run_id {
824 for finding in findings {
825 let db_finding = ReviewFinding {
826 id: 0,
827 agent_run_id: ar_id,
828 severity: finding.severity.to_string(),
829 category: finding.category.clone(),
830 file_path: finding.file_path.clone(),
831 line_number: finding.line_number,
832 message: finding.message.clone(),
833 resolved: false,
834 dispute_reason: None,
835 };
836 db::agent_runs::insert_finding(&conn, &db_finding)?;
837 }
838 }
839 drop(conn);
840 Ok(())
841 }
842
843 async fn rebase_with_agent_fallback(
847 &self,
848 run_id: &str,
849 ctx: &AgentContext,
850 worktree_path: &std::path::Path,
851 target_dir: &std::path::Path,
852 ) -> Result<RebaseOutcome> {
853 const MAX_REBASE_ROUNDS: u32 = 5;
854
855 let outcome = git::start_rebase(worktree_path, &ctx.base_branch).await;
856
857 let mut conflicting_files = match outcome {
858 RebaseOutcome::RebaseConflicts(files) => files,
859 other => return Ok(other),
860 };
861
862 for round in 1..=MAX_REBASE_ROUNDS {
863 self.check_cancelled()?;
864 info!(
865 run_id = %run_id,
866 round,
867 files = ?conflicting_files,
868 "rebase conflicts, attempting agent resolution"
869 );
870
871 if let Some(pr_number) = ctx.pr_number {
872 github::safe_comment(
873 &self.github,
874 pr_number,
875 &format_rebase_conflict_comment(round, &conflicting_files),
876 target_dir,
877 )
878 .await;
879 }
880
881 let conflict_prompt = format!(
882 "You are resolving rebase conflicts. The following files have unresolved \
883 conflict markers (<<<<<<< / ======= / >>>>>>> markers):\n\n{}\n\n\
884 Open each file, find the conflict markers, and resolve them by choosing \
885 the correct code. Remove all conflict markers. Do NOT add new features \
886 or refactor -- just resolve the conflicts so the code compiles and tests pass.\n\n\
887 After resolving, run any test/lint commands if available:\n\
888 - Test: {}\n\
889 - Lint: {}",
890 conflicting_files.iter().map(|f| format!("- {f}")).collect::<Vec<_>>().join("\n"),
891 ctx.test_command.as_deref().unwrap_or("(none)"),
892 ctx.lint_command.as_deref().unwrap_or("(none)"),
893 );
894
895 if let Err(e) = self
896 .run_agent(run_id, AgentRole::Implementer, &conflict_prompt, worktree_path, 1)
897 .await
898 {
899 warn!(run_id = %run_id, error = %e, "conflict resolution agent failed");
900 git::abort_rebase(worktree_path).await;
901 return Ok(RebaseOutcome::Failed(format!(
902 "agent conflict resolution failed: {e:#}"
903 )));
904 }
905
906 let remaining =
911 git::files_with_conflict_markers(worktree_path, &conflicting_files).await;
912 if !remaining.is_empty() {
913 warn!(
914 run_id = %run_id,
915 remaining = ?remaining,
916 "agent did not resolve all conflicts"
917 );
918 git::abort_rebase(worktree_path).await;
919 return Ok(RebaseOutcome::Failed(format!(
920 "agent could not resolve conflicts in: {}",
921 remaining.join(", ")
922 )));
923 }
924
925 match git::rebase_continue(worktree_path, &conflicting_files).await {
927 Ok(None) => {
928 info!(run_id = %run_id, "agent resolved rebase conflicts");
929 return Ok(RebaseOutcome::AgentResolved);
930 }
931 Ok(Some(new_conflicts)) => {
932 conflicting_files = new_conflicts;
934 }
935 Err(e) => {
936 git::abort_rebase(worktree_path).await;
937 return Ok(RebaseOutcome::Failed(format!("rebase --continue failed: {e:#}")));
938 }
939 }
940 }
941
942 git::abort_rebase(worktree_path).await;
944 Ok(RebaseOutcome::Failed(format!(
945 "rebase conflicts persisted after {MAX_REBASE_ROUNDS} resolution rounds"
946 )))
947 }
948
949 async fn run_agent(
950 &self,
951 run_id: &str,
952 role: AgentRole,
953 prompt: &str,
954 working_dir: &std::path::Path,
955 cycle: u32,
956 ) -> Result<crate::process::AgentResult> {
957 let agent_run_id = self.record_agent_start(run_id, role, cycle).await?;
958
959 info!(run_id = %run_id, agent = %role, cycle, "agent starting");
960
961 let invocation = AgentInvocation {
962 role,
963 prompt: prompt.to_string(),
964 working_dir: working_dir.to_path_buf(),
965 max_turns: Some(self.config.pipeline.turn_limit),
966 model: self.config.models.model_for(role.as_str()).map(String::from),
967 };
968
969 let result = process::run_with_retry(self.runner.as_ref(), &invocation).await;
970
971 match &result {
972 Ok(agent_result) => {
973 self.record_agent_success(run_id, agent_run_id, agent_result).await?;
974 }
975 Err(e) => {
976 let conn = self.db.lock().await;
977 db::agent_runs::finish_agent_run(
978 &conn,
979 agent_run_id,
980 "failed",
981 0.0,
982 0,
983 None,
984 Some(&format!("{e:#}")),
985 None,
986 )?;
987 }
988 }
989
990 result
991 }
992
993 async fn record_agent_start(&self, run_id: &str, role: AgentRole, cycle: u32) -> Result<i64> {
994 let agent_run = AgentRun {
995 id: 0,
996 run_id: run_id.to_string(),
997 agent: role.to_string(),
998 cycle,
999 status: "running".to_string(),
1000 cost_usd: 0.0,
1001 turns: 0,
1002 started_at: chrono::Utc::now().to_rfc3339(),
1003 finished_at: None,
1004 output_summary: None,
1005 error_message: None,
1006 raw_output: None,
1007 };
1008 let conn = self.db.lock().await;
1009 db::agent_runs::insert_agent_run(&conn, &agent_run)
1010 }
1011
1012 async fn record_agent_success(
1013 &self,
1014 run_id: &str,
1015 agent_run_id: i64,
1016 agent_result: &crate::process::AgentResult,
1017 ) -> Result<()> {
1018 let conn = self.db.lock().await;
1019 db::agent_runs::finish_agent_run(
1020 &conn,
1021 agent_run_id,
1022 "complete",
1023 agent_result.cost_usd,
1024 agent_result.turns,
1025 Some(&truncate(&agent_result.output, 500)),
1026 None,
1027 Some(&agent_result.output),
1028 )?;
1029
1030 let new_cost = db::runs::increment_run_cost(&conn, run_id, agent_result.cost_usd)?;
1031 drop(conn);
1032
1033 if new_cost > self.config.pipeline.cost_budget {
1034 anyhow::bail!(
1035 "cost budget exceeded: ${:.2} > ${:.2}",
1036 new_cost,
1037 self.config.pipeline.cost_budget
1038 );
1039 }
1040 Ok(())
1041 }
1042
1043 async fn update_status(&self, run_id: &str, status: RunStatus) -> Result<()> {
1044 let conn = self.db.lock().await;
1045 db::runs::update_run_status(&conn, run_id, status)
1046 }
1047
1048 fn check_cancelled(&self) -> Result<()> {
1049 if self.cancel_token.is_cancelled() {
1050 anyhow::bail!("pipeline cancelled");
1051 }
1052 Ok(())
1053 }
1054}
1055
1056const COMMENT_FOOTER: &str =
1057 "\n---\nAutomated by [oven](https://github.com/clayharmon/oven-cli) \u{1F35E}";
1058
1059fn format_unresolved_comment(actionable: &[&agents::Finding]) -> String {
1060 let mut comment = String::from(
1061 "### Unresolved review findings\n\n\
1062 The review-fix loop ran 2 cycles but these findings remain unresolved.\n",
1063 );
1064
1065 for severity in &[Severity::Critical, Severity::Warning] {
1067 let group: Vec<_> = actionable.iter().filter(|f| &f.severity == severity).collect();
1068 if group.is_empty() {
1069 continue;
1070 }
1071 let heading = match severity {
1072 Severity::Critical => "Critical",
1073 Severity::Warning => "Warning",
1074 Severity::Info => unreachable!("loop only iterates Critical and Warning"),
1075 };
1076 let _ = writeln!(comment, "\n#### {heading}\n");
1077 for f in group {
1078 let loc = match (&f.file_path, f.line_number) {
1079 (Some(path), Some(line)) => format!(" in `{path}:{line}`"),
1080 (Some(path), None) => format!(" in `{path}`"),
1081 _ => String::new(),
1082 };
1083 let _ = writeln!(comment, "- **{}**{} -- {}", f.category, loc, f.message);
1084 }
1085 }
1086
1087 comment.push_str(COMMENT_FOOTER);
1088 comment
1089}
1090
1091fn format_impl_comment(summary: &str) -> String {
1092 format!("### Implementation complete\n\n{summary}{COMMENT_FOOTER}")
1093}
1094
1095fn format_review_comment(cycle: u32, actionable: &[&agents::Finding]) -> String {
1096 if actionable.is_empty() {
1097 return format!(
1098 "### Review complete (cycle {cycle})\n\n\
1099 Clean review, no actionable findings.{COMMENT_FOOTER}"
1100 );
1101 }
1102
1103 let mut comment = format!(
1104 "### Review complete (cycle {cycle})\n\n\
1105 **{count} finding{s}:**\n",
1106 count = actionable.len(),
1107 s = if actionable.len() == 1 { "" } else { "s" },
1108 );
1109
1110 for f in actionable {
1111 let loc = match (&f.file_path, f.line_number) {
1112 (Some(path), Some(line)) => format!(" in `{path}:{line}`"),
1113 (Some(path), None) => format!(" in `{path}`"),
1114 _ => String::new(),
1115 };
1116 let _ = writeln!(
1117 comment,
1118 "- [{sev}] **{cat}**{loc} -- {msg}",
1119 sev = f.severity,
1120 cat = f.category,
1121 msg = f.message,
1122 );
1123 }
1124
1125 comment.push_str(COMMENT_FOOTER);
1126 comment
1127}
1128
1129fn format_fix_comment(cycle: u32, fixer: &agents::FixerOutput) -> String {
1130 let addressed = fixer.addressed.len();
1131 let disputed = fixer.disputed.len();
1132 format!(
1133 "### Fix complete (cycle {cycle})\n\n\
1134 **Addressed:** {addressed} finding{a_s}\n\
1135 **Disputed:** {disputed} finding{d_s}{COMMENT_FOOTER}",
1136 a_s = if addressed == 1 { "" } else { "s" },
1137 d_s = if disputed == 1 { "" } else { "s" },
1138 )
1139}
1140
1141fn format_rebase_conflict_comment(round: u32, conflicting_files: &[String]) -> String {
1142 format!(
1143 "### Resolving rebase conflicts (round {round})\n\n\
1144 Attempting agent-assisted resolution for {} conflicting file{}: \
1145 {}{COMMENT_FOOTER}",
1146 conflicting_files.len(),
1147 if conflicting_files.len() == 1 { "" } else { "s" },
1148 conflicting_files.iter().map(|f| format!("`{f}`")).collect::<Vec<_>>().join(", "),
1149 )
1150}
1151
1152fn format_fixer_recovery_comment(cycle: u32, new_commits: u32) -> String {
1153 if new_commits > 0 {
1154 format!(
1155 "### Fix complete (cycle {cycle})\n\n\
1156 Fixer made {new_commits} commit{s} but did not produce structured output. \
1157 Addressed findings inferred from changed files.{COMMENT_FOOTER}",
1158 s = if new_commits == 1 { "" } else { "s" },
1159 )
1160 } else {
1161 format!(
1162 "### Fix complete (cycle {cycle})\n\n\
1163 Fixer could not act on the findings (no code changes made). \
1164 Findings marked as not actionable.{COMMENT_FOOTER}"
1165 )
1166 }
1167}
1168
1169fn format_review_skipped_comment(cycle: u32, error: &anyhow::Error) -> String {
1170 format!(
1171 "### Review skipped (cycle {cycle})\n\n\
1172 Reviewer agent encountered an error. Continuing without review.\n\n\
1173 **Error:** {error:#}{COMMENT_FOOTER}"
1174 )
1175}
1176
1177fn format_fix_skipped_comment(cycle: u32, error: &anyhow::Error) -> String {
1178 format!(
1179 "### Fix skipped (cycle {cycle})\n\n\
1180 Fixer agent encountered an error. Continuing to next cycle.\n\n\
1181 **Error:** {error:#}{COMMENT_FOOTER}"
1182 )
1183}
1184
1185fn format_rebase_comment(outcome: &RebaseOutcome) -> String {
1186 match outcome {
1187 RebaseOutcome::Clean => {
1188 format!("### Rebase\n\nRebased onto base branch cleanly.{COMMENT_FOOTER}")
1189 }
1190 RebaseOutcome::AgentResolved => {
1191 format!(
1192 "### Rebase\n\n\
1193 Rebase had conflicts. Agent resolved them.{COMMENT_FOOTER}"
1194 )
1195 }
1196 RebaseOutcome::RebaseConflicts(_) => {
1197 format!(
1198 "### Rebase\n\n\
1199 Rebase conflicts present (awaiting resolution).{COMMENT_FOOTER}"
1200 )
1201 }
1202 RebaseOutcome::Failed(msg) => {
1203 format!(
1204 "### Rebase failed\n\n\
1205 Could not rebase onto the base branch.\n\n\
1206 **Error:** {msg}{COMMENT_FOOTER}"
1207 )
1208 }
1209 }
1210}
1211
1212fn format_ready_comment() -> String {
1213 format!(
1214 "### Ready for review\n\nPipeline complete. This PR is ready for manual review.{COMMENT_FOOTER}"
1215 )
1216}
1217
1218fn format_merge_comment() -> String {
1219 format!("### Merged\n\nPipeline complete. PR has been merged.{COMMENT_FOOTER}")
1220}
1221
1222fn format_pipeline_failure(e: &anyhow::Error) -> String {
1223 format!(
1224 "## Pipeline failed\n\n\
1225 **Error:** {e:#}\n\n\
1226 The pipeline hit an unrecoverable error. Check the run logs for detail, \
1227 or re-run the pipeline.\
1228 {COMMENT_FOOTER}"
1229 )
1230}
1231
1232fn pr_title(ctx: &AgentContext) -> String {
1237 let prefix = infer_commit_type(&ctx.issue_title);
1238 if ctx.issue_source == "github" {
1239 format!("{prefix}(#{}): {}", ctx.issue_number, ctx.issue_title)
1240 } else {
1241 format!("{prefix}: {}", ctx.issue_title)
1242 }
1243}
1244
1245fn infer_commit_type(title: &str) -> &'static str {
1247 let lower = title.to_lowercase();
1248 if lower.starts_with("feat") || lower.contains("add ") || lower.contains("implement ") {
1249 "feat"
1250 } else if lower.starts_with("refactor") {
1251 "refactor"
1252 } else if lower.starts_with("docs") || lower.starts_with("document") {
1253 "docs"
1254 } else if lower.starts_with("test") || lower.starts_with("add test") {
1255 "test"
1256 } else if lower.starts_with("chore") {
1257 "chore"
1258 } else {
1259 "fix"
1260 }
1261}
1262
1263fn build_pr_body(impl_output: &str, ctx: &AgentContext) -> String {
1265 let issue_ref = if ctx.issue_source == "github" {
1266 format!("Resolves #{}", ctx.issue_number)
1267 } else {
1268 format!("From local issue #{}", ctx.issue_number)
1269 };
1270
1271 let summary = extract_impl_summary(impl_output);
1272
1273 let mut body = String::new();
1274 let _ = writeln!(body, "{issue_ref}\n");
1275 let _ = write!(body, "{summary}");
1276 body.push_str(COMMENT_FOOTER);
1277 body
1278}
1279
1280fn extract_impl_summary(output: &str) -> String {
1286 let idx = output.find("## PR Template").or_else(|| output.find("## Changes Made"));
1288
1289 if let Some(idx) = idx {
1290 let summary = output[idx..].trim();
1291 let summary = summary.strip_prefix("## PR Template").map_or(summary, |s| s.trim_start());
1293 if summary.len() <= 4000 {
1294 return summary.to_string();
1295 }
1296 return truncate(summary, 4000);
1297 }
1298 String::from("*No implementation summary available. See commit history for details.*")
1301}
1302
1303fn new_run(run_id: &str, issue: &PipelineIssue, auto_merge: bool) -> Run {
1304 Run {
1305 id: run_id.to_string(),
1306 issue_number: issue.number,
1307 status: RunStatus::Pending,
1308 pr_number: None,
1309 branch: None,
1310 worktree_path: None,
1311 cost_usd: 0.0,
1312 auto_merge,
1313 started_at: chrono::Utc::now().to_rfc3339(),
1314 finished_at: None,
1315 error_message: None,
1316 complexity: "full".to_string(),
1317 issue_source: issue.source.to_string(),
1318 }
1319}
1320
1321pub fn generate_run_id() -> String {
1323 uuid::Uuid::new_v4().to_string()[..8].to_string()
1324}
1325
1326pub(crate) fn truncate(s: &str, max_len: usize) -> String {
1331 if s.len() <= max_len {
1332 return s.to_string();
1333 }
1334 let target = max_len.saturating_sub(3);
1335 let mut end = target;
1336 while end > 0 && !s.is_char_boundary(end) {
1337 end -= 1;
1338 }
1339 format!("{}...", &s[..end])
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344 use proptest::prelude::*;
1345
1346 use super::*;
1347
1348 proptest! {
1349 #[test]
1350 fn run_ids_always_8_hex_chars(_seed in any::<u64>()) {
1351 let id = generate_run_id();
1352 prop_assert_eq!(id.len(), 8);
1353 prop_assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1354 }
1355 }
1356
1357 #[test]
1358 fn run_id_is_8_hex_chars() {
1359 let id = generate_run_id();
1360 assert_eq!(id.len(), 8);
1361 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1362 }
1363
1364 #[test]
1365 fn run_ids_are_unique() {
1366 let ids: Vec<_> = (0..100).map(|_| generate_run_id()).collect();
1367 let unique: std::collections::HashSet<_> = ids.iter().collect();
1368 assert_eq!(ids.len(), unique.len());
1369 }
1370
1371 #[test]
1372 fn truncate_short_string() {
1373 assert_eq!(truncate("hello", 10), "hello");
1374 }
1375
1376 #[test]
1377 fn truncate_long_string() {
1378 let long = "a".repeat(100);
1379 let result = truncate(&long, 10);
1380 assert_eq!(result.len(), 10); assert!(result.ends_with("..."));
1382 }
1383
1384 #[test]
1385 fn truncate_multibyte_does_not_panic() {
1386 let s = "πππ";
1389 let result = truncate(s, 8);
1390 assert!(result.ends_with("..."));
1391 assert!(result.starts_with("π"));
1392 assert!(result.len() <= 8);
1393 }
1394
1395 #[test]
1396 fn truncate_cjk_boundary() {
1397 let s = "δ½ ε₯½δΈηζ΅θ―"; let result = truncate(s, 10);
1401 assert!(result.ends_with("..."));
1402 assert!(result.starts_with("δ½ ε₯½"));
1403 assert!(result.len() <= 10);
1404 }
1405
1406 #[test]
1407 fn extract_impl_summary_finds_changes_made() {
1408 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";
1409 let summary = extract_impl_summary(output);
1410 assert!(summary.starts_with("## Changes Made"));
1411 assert!(summary.contains("added bar"));
1412 assert!(summary.contains("## Tests Added"));
1413 }
1414
1415 #[test]
1416 fn extract_impl_summary_prefers_pr_template() {
1417 let output = "Preamble\n\n## PR Template\n## Summary\n- Added auth flow\n\n## Testing\n- Unit tests pass\n";
1418 let summary = extract_impl_summary(output);
1419 assert!(!summary.contains("## PR Template"));
1421 assert!(summary.starts_with("## Summary"));
1422 assert!(summary.contains("Added auth flow"));
1423 }
1424
1425 #[test]
1426 fn extract_impl_summary_fallback_on_no_heading() {
1427 let output = "just some raw agent output with no structure";
1428 let summary = extract_impl_summary(output);
1429 assert_eq!(
1430 summary,
1431 "*No implementation summary available. See commit history for details.*"
1432 );
1433 }
1434
1435 #[test]
1436 fn extract_impl_summary_empty_output() {
1437 let placeholder = "*No implementation summary available. See commit history for details.*";
1438 assert_eq!(extract_impl_summary(""), placeholder);
1439 assert_eq!(extract_impl_summary(" "), placeholder);
1440 }
1441
1442 #[test]
1443 fn build_pr_body_github_issue() {
1444 let ctx = AgentContext {
1445 issue_number: 42,
1446 issue_title: "fix the thing".to_string(),
1447 issue_body: String::new(),
1448 branch: "oven/issue-42".to_string(),
1449 pr_number: Some(10),
1450 test_command: None,
1451 lint_command: None,
1452 review_findings: None,
1453 cycle: 1,
1454 target_repo: None,
1455 issue_source: "github".to_string(),
1456 base_branch: "main".to_string(),
1457 };
1458 let body = build_pr_body("## Changes Made\n- added stuff", &ctx);
1459 assert!(body.contains("Resolves #42"));
1460 assert!(body.contains("## Changes Made"));
1461 assert!(body.contains("Automated by [oven]"));
1462 }
1463
1464 #[test]
1465 fn build_pr_body_local_issue() {
1466 let ctx = AgentContext {
1467 issue_number: 7,
1468 issue_title: "local thing".to_string(),
1469 issue_body: String::new(),
1470 branch: "oven/issue-7".to_string(),
1471 pr_number: Some(10),
1472 test_command: None,
1473 lint_command: None,
1474 review_findings: None,
1475 cycle: 1,
1476 target_repo: None,
1477 issue_source: "local".to_string(),
1478 base_branch: "main".to_string(),
1479 };
1480 let body = build_pr_body("## Changes Made\n- did local stuff", &ctx);
1481 assert!(body.contains("From local issue #7"));
1482 assert!(body.contains("## Changes Made"));
1483 }
1484
1485 #[test]
1486 fn pr_title_github() {
1487 let ctx = AgentContext {
1488 issue_number: 42,
1489 issue_title: "fix the thing".to_string(),
1490 issue_body: String::new(),
1491 branch: String::new(),
1492 pr_number: None,
1493 test_command: None,
1494 lint_command: None,
1495 review_findings: None,
1496 cycle: 1,
1497 target_repo: None,
1498 issue_source: "github".to_string(),
1499 base_branch: "main".to_string(),
1500 };
1501 assert_eq!(pr_title(&ctx), "fix(#42): fix the thing");
1502 }
1503
1504 #[test]
1505 fn pr_title_local() {
1506 let ctx = AgentContext {
1507 issue_number: 7,
1508 issue_title: "local thing".to_string(),
1509 issue_body: String::new(),
1510 branch: String::new(),
1511 pr_number: None,
1512 test_command: None,
1513 lint_command: None,
1514 review_findings: None,
1515 cycle: 1,
1516 target_repo: None,
1517 issue_source: "local".to_string(),
1518 base_branch: "main".to_string(),
1519 };
1520 assert_eq!(pr_title(&ctx), "fix: local thing");
1521 }
1522
1523 #[test]
1524 fn infer_commit_type_feat() {
1525 assert_eq!(infer_commit_type("Add dark mode support"), "feat");
1526 assert_eq!(infer_commit_type("Implement caching layer"), "feat");
1527 assert_eq!(infer_commit_type("Feature: new dashboard"), "feat");
1528 }
1529
1530 #[test]
1531 fn infer_commit_type_refactor() {
1532 assert_eq!(infer_commit_type("Refactor auth middleware"), "refactor");
1533 }
1534
1535 #[test]
1536 fn infer_commit_type_docs() {
1537 assert_eq!(infer_commit_type("Document the API endpoints"), "docs");
1538 assert_eq!(infer_commit_type("Docs: update README"), "docs");
1539 }
1540
1541 #[test]
1542 fn infer_commit_type_defaults_to_fix() {
1543 assert_eq!(infer_commit_type("Null pointer in config parser"), "fix");
1544 assert_eq!(infer_commit_type("Crash on empty input"), "fix");
1545 }
1546
1547 #[test]
1548 fn pr_title_feat_github() {
1549 let ctx = AgentContext {
1550 issue_number: 10,
1551 issue_title: "Add dark mode".to_string(),
1552 issue_body: String::new(),
1553 branch: String::new(),
1554 pr_number: None,
1555 test_command: None,
1556 lint_command: None,
1557 review_findings: None,
1558 cycle: 1,
1559 target_repo: None,
1560 issue_source: "github".to_string(),
1561 base_branch: "main".to_string(),
1562 };
1563 assert_eq!(pr_title(&ctx), "feat(#10): Add dark mode");
1564 }
1565
1566 #[test]
1567 fn format_unresolved_comment_groups_by_severity() {
1568 let findings = [
1569 agents::Finding {
1570 severity: Severity::Critical,
1571 category: "bug".to_string(),
1572 file_path: Some("src/main.rs".to_string()),
1573 line_number: Some(42),
1574 message: "null pointer".to_string(),
1575 },
1576 agents::Finding {
1577 severity: Severity::Warning,
1578 category: "style".to_string(),
1579 file_path: None,
1580 line_number: None,
1581 message: "missing docs".to_string(),
1582 },
1583 ];
1584 let refs: Vec<_> = findings.iter().collect();
1585 let comment = format_unresolved_comment(&refs);
1586 assert!(comment.contains("#### Critical"));
1587 assert!(comment.contains("#### Warning"));
1588 assert!(comment.contains("**bug** in `src/main.rs:42` -- null pointer"));
1589 assert!(comment.contains("**style** -- missing docs"));
1590 assert!(comment.contains("Automated by [oven]"));
1591 }
1592
1593 #[test]
1594 fn format_unresolved_comment_skips_empty_severity_groups() {
1595 let findings = [agents::Finding {
1596 severity: Severity::Warning,
1597 category: "testing".to_string(),
1598 file_path: Some("src/lib.rs".to_string()),
1599 line_number: None,
1600 message: "missing edge case test".to_string(),
1601 }];
1602 let refs: Vec<_> = findings.iter().collect();
1603 let comment = format_unresolved_comment(&refs);
1604 assert!(!comment.contains("#### Critical"));
1605 assert!(comment.contains("#### Warning"));
1606 }
1607
1608 #[test]
1609 fn format_pipeline_failure_includes_error() {
1610 let err = anyhow::anyhow!("cost budget exceeded: $12.50 > $10.00");
1611 let comment = format_pipeline_failure(&err);
1612 assert!(comment.contains("## Pipeline failed"));
1613 assert!(comment.contains("cost budget exceeded"));
1614 assert!(comment.contains("Automated by [oven]"));
1615 }
1616
1617 #[test]
1618 fn format_impl_comment_includes_summary() {
1619 let comment = format_impl_comment("Added login endpoint with tests");
1620 assert!(comment.contains("### Implementation complete"));
1621 assert!(comment.contains("Added login endpoint with tests"));
1622 assert!(comment.contains("Automated by [oven]"));
1623 }
1624
1625 #[test]
1626 fn format_review_comment_clean() {
1627 let comment = format_review_comment(1, &[]);
1628 assert!(comment.contains("### Review complete (cycle 1)"));
1629 assert!(comment.contains("Clean review"));
1630 }
1631
1632 #[test]
1633 fn format_review_comment_with_findings() {
1634 let findings = [agents::Finding {
1635 severity: Severity::Critical,
1636 category: "bug".to_string(),
1637 file_path: Some("src/main.rs".to_string()),
1638 line_number: Some(42),
1639 message: "null pointer".to_string(),
1640 }];
1641 let refs: Vec<_> = findings.iter().collect();
1642 let comment = format_review_comment(1, &refs);
1643 assert!(comment.contains("### Review complete (cycle 1)"));
1644 assert!(comment.contains("1 finding"));
1645 assert!(comment.contains("[critical]"));
1646 assert!(comment.contains("`src/main.rs:42`"));
1647 }
1648
1649 #[test]
1650 fn format_fix_comment_counts() {
1651 let fixer = agents::FixerOutput {
1652 addressed: vec![
1653 agents::FixerAction { finding: 1, action: "fixed it".to_string() },
1654 agents::FixerAction { finding: 2, action: "also fixed".to_string() },
1655 ],
1656 disputed: vec![agents::FixerDispute { finding: 3, reason: "not a bug".to_string() }],
1657 };
1658 let comment = format_fix_comment(1, &fixer);
1659 assert!(comment.contains("### Fix complete (cycle 1)"));
1660 assert!(comment.contains("Addressed:** 2 findings"));
1661 assert!(comment.contains("Disputed:** 1 finding\n"));
1662 }
1663
1664 #[test]
1665 fn format_rebase_comment_variants() {
1666 let clean = format_rebase_comment(&RebaseOutcome::Clean);
1667 assert!(clean.contains("Rebased onto base branch cleanly"));
1668
1669 let agent = format_rebase_comment(&RebaseOutcome::AgentResolved);
1670 assert!(agent.contains("Agent resolved them"));
1671
1672 let conflicts =
1673 format_rebase_comment(&RebaseOutcome::RebaseConflicts(vec!["foo.rs".into()]));
1674 assert!(conflicts.contains("awaiting resolution"));
1675
1676 let failed = format_rebase_comment(&RebaseOutcome::Failed("conflict in foo.rs".into()));
1677 assert!(failed.contains("Rebase failed"));
1678 assert!(failed.contains("conflict in foo.rs"));
1679 }
1680
1681 #[test]
1682 fn format_ready_comment_content() {
1683 let comment = format_ready_comment();
1684 assert!(comment.contains("### Ready for review"));
1685 assert!(comment.contains("manual review"));
1686 }
1687
1688 #[test]
1689 fn format_merge_comment_content() {
1690 let comment = format_merge_comment();
1691 assert!(comment.contains("### Merged"));
1692 }
1693}