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