Skip to main content

oven_cli/pipeline/
executor.rs

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/// The result of running an issue through the pipeline (before merge).
24#[derive(Debug)]
25pub struct PipelineOutcome {
26    pub run_id: String,
27    pub pr_number: u32,
28    /// Worktree path, retained so the caller can clean up after merge.
29    pub worktree_path: PathBuf,
30    /// Repo directory the worktree belongs to (needed for `git::remove_worktree`).
31    pub target_dir: PathBuf,
32}
33
34/// Runs a single issue through the full pipeline.
35pub 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    /// Run the full pipeline for a single issue.
47    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    /// Run the full pipeline for a single issue with an optional complexity classification.
52    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    /// Run the pipeline up to (but not including) finalization.
63    ///
64    /// Returns a `PipelineOutcome` with the run ID and PR number.
65    /// The caller is responsible for calling `finalize_run` or `finalize_merge`
66    /// at the appropriate time (e.g., after the PR is actually merged).
67    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        // Determine target repo for worktrees and PRs (multi-repo routing)
76        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        // Seed branch with an empty commit so GitHub accepts the draft PR
97        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            // On failure, finalize immediately (no merge to wait for)
132            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        // Update status to AwaitingMerge
140        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    /// Finalize a run after its PR has been merged.
146    ///
147    /// Transitions labels, closes issues, marks the run as complete, and cleans
148    /// up the worktree that was left around while awaiting merge.
149    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    /// Invoke the planner agent to decide dependency ordering for a set of issues.
167    ///
168    /// `graph_context` describes the current dependency graph state so the planner
169    /// can avoid scheduling conflicting work alongside in-flight issues.
170    ///
171    /// Returns `None` if the planner fails or returns unparseable output (fallback to default).
172    pub async fn plan_issues(
173        &self,
174        issues: &[PipelineIssue],
175        graph_context: &[GraphContextNode],
176    ) -> Option<PlannerGraphOutput> {
177        let prompt = match agents::planner::build_prompt(issues, graph_context) {
178            Ok(p) => p,
179            Err(e) => {
180                warn!(error = %e, "planner prompt build failed");
181                return None;
182            }
183        };
184        let invocation = AgentInvocation {
185            role: AgentRole::Planner,
186            prompt,
187            working_dir: self.repo_dir.clone(),
188            max_turns: Some(self.config.pipeline.turn_limit),
189        };
190
191        match invoke_agent(self.runner.as_ref(), &invocation).await {
192            Ok(result) => {
193                debug!(output = %result.output, "raw planner output");
194                let parsed = parse_planner_graph_output(&result.output);
195                if parsed.is_none() {
196                    warn!(output = %result.output, "planner returned unparseable output, falling back to all-parallel");
197                }
198                parsed
199            }
200            Err(e) => {
201                warn!(error = %e, "planner agent failed, falling back to all-parallel");
202                None
203            }
204        }
205    }
206
207    /// Determine the effective repo directory for worktrees and PRs.
208    ///
209    /// Returns `(target_dir, is_multi_repo)`. When multi-repo is disabled or no target
210    /// is specified, falls back to `self.repo_dir`.
211    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    /// Reconstruct a `PipelineOutcome` from graph node data (for merge polling).
228    ///
229    /// Worktree paths are deterministic, so we can rebuild the outcome from
230    /// the issue metadata stored on the graph node.
231    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                // Close the issue when the merger can't do it:
311                // - Local issues: merger can't use `gh issue close`
312                // - Multi-repo: merger runs in target repo, can't close god-repo issue
313                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        // 1. Implement
368        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        // 1b. Update PR description and mark ready for review
376        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        // 1c. Post implementation comment on PR
389        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        // 2. Review-fix loop (posts its own step comments on the PR)
401        self.run_review_fix_loop(run_id, ctx, worktree_path, target_dir).await?;
402
403        // 3. Rebase onto base branch to resolve any conflicts from parallel merges
404        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        // 4. Merge (only when auto-merge is enabled)
426        if auto_merge {
427            self.check_cancelled()?;
428            ctx.pr_number.context("no PR number for merge step")?;
429            self.update_status(run_id, RunStatus::Merging).await?;
430            let merge_prompt = agents::merger::build_prompt(ctx, auto_merge)?;
431            self.run_agent(run_id, AgentRole::Merger, &merge_prompt, worktree_path, 1).await?;
432
433            if let Some(pr_number) = ctx.pr_number {
434                github::safe_comment(&self.github, pr_number, &format_merge_comment(), target_dir)
435                    .await;
436            }
437        } else if let Some(pr_number) = ctx.pr_number {
438            github::safe_comment(&self.github, pr_number, &format_ready_comment(), target_dir)
439                .await;
440        }
441
442        Ok(())
443    }
444
445    async fn run_review_fix_loop(
446        &self,
447        run_id: &str,
448        ctx: &AgentContext,
449        worktree_path: &std::path::Path,
450        target_dir: &std::path::Path,
451    ) -> Result<()> {
452        for cycle in 1..=3 {
453            self.check_cancelled()?;
454
455            self.update_status(run_id, RunStatus::Reviewing).await?;
456
457            let (prior_addressed, prior_disputes) =
458                self.gather_prior_findings(run_id, cycle).await?;
459
460            let review_prompt =
461                agents::reviewer::build_prompt(ctx, &prior_addressed, &prior_disputes)?;
462
463            // Reviewer failure: skip review and continue (don't kill pipeline)
464            let review_result = match self
465                .run_agent(run_id, AgentRole::Reviewer, &review_prompt, worktree_path, cycle)
466                .await
467            {
468                Ok(result) => result,
469                Err(e) => {
470                    warn!(run_id = %run_id, cycle, error = %e, "reviewer agent failed, skipping review");
471                    if let Some(pr_number) = ctx.pr_number {
472                        github::safe_comment(
473                            &self.github,
474                            pr_number,
475                            &format_review_skipped_comment(cycle, &e),
476                            target_dir,
477                        )
478                        .await;
479                    }
480                    return Ok(());
481                }
482            };
483
484            let review_output = parse_review_output(&review_result.output);
485            self.store_findings(run_id, &review_output.findings).await?;
486
487            let actionable: Vec<_> =
488                review_output.findings.iter().filter(|f| f.severity != Severity::Info).collect();
489
490            // Post review comment on PR
491            if let Some(pr_number) = ctx.pr_number {
492                github::safe_comment(
493                    &self.github,
494                    pr_number,
495                    &format_review_comment(cycle, &actionable),
496                    target_dir,
497                )
498                .await;
499            }
500
501            if actionable.is_empty() {
502                info!(run_id = %run_id, cycle, "review clean");
503                return Ok(());
504            }
505
506            info!(run_id = %run_id, cycle, findings = actionable.len(), "review found issues");
507
508            if cycle == 3 {
509                if let Some(pr_number) = ctx.pr_number {
510                    let comment = format_unresolved_comment(&actionable);
511                    github::safe_comment(&self.github, pr_number, &comment, target_dir).await;
512                } else {
513                    warn!(run_id = %run_id, "no PR number, cannot post unresolved findings");
514                }
515                return Ok(());
516            }
517
518            self.run_fix_step(run_id, ctx, worktree_path, target_dir, cycle).await?;
519        }
520
521        Ok(())
522    }
523
524    /// Gather prior addressed and disputed findings for review cycles 2+.
525    async fn gather_prior_findings(
526        &self,
527        run_id: &str,
528        cycle: u32,
529    ) -> Result<(Vec<ReviewFinding>, Vec<ReviewFinding>)> {
530        if cycle <= 1 {
531            return Ok((Vec::new(), Vec::new()));
532        }
533
534        let conn = self.db.lock().await;
535        let all_resolved = db::agent_runs::get_resolved_findings(&conn, run_id)?;
536        drop(conn);
537
538        let (mut addressed, disputed): (Vec<_>, Vec<_>) = all_resolved.into_iter().partition(|f| {
539            f.dispute_reason.as_deref().is_some_and(|r| r.starts_with("ADDRESSED: "))
540        });
541
542        // Strip the "ADDRESSED: " prefix so the template gets clean action text
543        for f in &mut addressed {
544            if let Some(ref mut reason) = f.dispute_reason {
545                if let Some(stripped) = reason.strip_prefix("ADDRESSED: ") {
546                    *reason = stripped.to_string();
547                }
548            }
549        }
550
551        Ok((addressed, disputed))
552    }
553
554    /// Run the fixer agent for a given cycle, process its output, and push.
555    ///
556    /// If the fixer agent fails, posts a comment on the PR and returns `Ok(())`
557    /// so the caller can continue to the next review cycle.
558    async fn run_fix_step(
559        &self,
560        run_id: &str,
561        ctx: &AgentContext,
562        worktree_path: &std::path::Path,
563        target_dir: &std::path::Path,
564        cycle: u32,
565    ) -> Result<()> {
566        self.check_cancelled()?;
567        self.update_status(run_id, RunStatus::Fixing).await?;
568
569        let unresolved = {
570            let conn = self.db.lock().await;
571            db::agent_runs::get_unresolved_findings(&conn, run_id)?
572        };
573
574        let fix_prompt = agents::fixer::build_prompt(ctx, &unresolved)?;
575
576        // Fixer failure: skip fix (caller continues to next review cycle)
577        let fix_result =
578            match self.run_agent(run_id, AgentRole::Fixer, &fix_prompt, worktree_path, cycle).await
579            {
580                Ok(result) => result,
581                Err(e) => {
582                    warn!(run_id = %run_id, cycle, error = %e, "fixer agent failed, skipping fix");
583                    if let Some(pr_number) = ctx.pr_number {
584                        github::safe_comment(
585                            &self.github,
586                            pr_number,
587                            &format_fix_skipped_comment(cycle, &e),
588                            target_dir,
589                        )
590                        .await;
591                    }
592                    return Ok(());
593                }
594            };
595
596        // Parse fixer output and mark disputed + addressed findings as resolved
597        let fixer_output = parse_fixer_output(&fix_result.output);
598        self.process_fixer_disputes(run_id, &unresolved, &fixer_output).await?;
599        self.process_fixer_addressed(run_id, &unresolved, &fixer_output).await?;
600
601        // Post fix comment on PR
602        if let Some(pr_number) = ctx.pr_number {
603            github::safe_comment(
604                &self.github,
605                pr_number,
606                &format_fix_comment(cycle, &fixer_output),
607                target_dir,
608            )
609            .await;
610        }
611
612        git::push_branch(worktree_path, &ctx.branch).await?;
613        Ok(())
614    }
615
616    /// Process fixer disputes by marking corresponding review findings as resolved.
617    ///
618    /// The fixer references findings by 1-indexed position in the list it received.
619    /// We map those back to the actual `ReviewFinding` IDs and mark them resolved
620    /// with the fixer's dispute reason.
621    async fn process_fixer_disputes(
622        &self,
623        run_id: &str,
624        findings_sent_to_fixer: &[ReviewFinding],
625        fixer_output: &agents::FixerOutput,
626    ) -> Result<()> {
627        if fixer_output.disputed.is_empty() {
628            return Ok(());
629        }
630
631        let conn = self.db.lock().await;
632        for dispute in &fixer_output.disputed {
633            // finding numbers are 1-indexed
634            let idx = dispute.finding.saturating_sub(1) as usize;
635            if let Some(finding) = findings_sent_to_fixer.get(idx) {
636                db::agent_runs::resolve_finding(&conn, finding.id, &dispute.reason)?;
637                info!(
638                    run_id = %run_id,
639                    finding_id = finding.id,
640                    reason = %dispute.reason,
641                    "finding disputed by fixer, marked resolved"
642                );
643            }
644        }
645        drop(conn);
646        Ok(())
647    }
648
649    /// Process fixer addressed actions by marking corresponding review findings as resolved.
650    ///
651    /// Similar to `process_fixer_disputes`, but for findings the fixer actually fixed.
652    /// Stores the action with an `ADDRESSED: ` prefix so we can distinguish addressed
653    /// findings from disputed ones when building the next reviewer prompt.
654    async fn process_fixer_addressed(
655        &self,
656        run_id: &str,
657        findings_sent_to_fixer: &[ReviewFinding],
658        fixer_output: &agents::FixerOutput,
659    ) -> Result<()> {
660        if fixer_output.addressed.is_empty() {
661            return Ok(());
662        }
663
664        let conn = self.db.lock().await;
665        for action in &fixer_output.addressed {
666            let idx = action.finding.saturating_sub(1) as usize;
667            if let Some(finding) = findings_sent_to_fixer.get(idx) {
668                let reason = format!("ADDRESSED: {}", action.action);
669                db::agent_runs::resolve_finding(&conn, finding.id, &reason)?;
670                info!(
671                    run_id = %run_id,
672                    finding_id = finding.id,
673                    action = %action.action,
674                    "finding addressed by fixer, marked resolved"
675                );
676            }
677        }
678        drop(conn);
679        Ok(())
680    }
681
682    async fn store_findings(&self, run_id: &str, findings: &[agents::Finding]) -> Result<()> {
683        let conn = self.db.lock().await;
684        let agent_runs = db::agent_runs::get_agent_runs_for_run(&conn, run_id)?;
685        let reviewer_run_id = agent_runs
686            .iter()
687            .rev()
688            .find_map(|ar| if ar.agent == "reviewer" { Some(ar.id) } else { None });
689        if let Some(ar_id) = reviewer_run_id {
690            for finding in findings {
691                let db_finding = ReviewFinding {
692                    id: 0,
693                    agent_run_id: ar_id,
694                    severity: finding.severity.to_string(),
695                    category: finding.category.clone(),
696                    file_path: finding.file_path.clone(),
697                    line_number: finding.line_number,
698                    message: finding.message.clone(),
699                    resolved: false,
700                    dispute_reason: None,
701                };
702                db::agent_runs::insert_finding(&conn, &db_finding)?;
703            }
704        }
705        drop(conn);
706        Ok(())
707    }
708
709    /// Rebase with fallbacks, including agent-assisted conflict resolution.
710    ///
711    /// Chain: rebase -> merge -> implementer agent resolves conflicts -> fail.
712    async fn rebase_with_agent_fallback(
713        &self,
714        run_id: &str,
715        ctx: &AgentContext,
716        worktree_path: &std::path::Path,
717        target_dir: &std::path::Path,
718    ) -> Result<RebaseOutcome> {
719        let outcome = git::rebase_with_fallbacks(worktree_path, &ctx.base_branch).await;
720
721        let conflicting_files = match outcome {
722            RebaseOutcome::MergeConflicts(ref files) => files.clone(),
723            other => return Ok(other),
724        };
725
726        info!(
727            run_id = %run_id,
728            files = ?conflicting_files,
729            "rebase and merge failed, attempting agent conflict resolution"
730        );
731
732        // Post a comment so the PR shows what's happening
733        if let Some(pr_number) = ctx.pr_number {
734            github::safe_comment(
735                &self.github,
736                pr_number,
737                &format!(
738                    "### Resolving merge conflicts\n\n\
739                     Rebase and merge both failed. Attempting agent-assisted resolution \
740                     for {} conflicting file{}: {}{COMMENT_FOOTER}",
741                    conflicting_files.len(),
742                    if conflicting_files.len() == 1 { "" } else { "s" },
743                    conflicting_files
744                        .iter()
745                        .map(|f| format!("`{f}`"))
746                        .collect::<Vec<_>>()
747                        .join(", "),
748                ),
749                target_dir,
750            )
751            .await;
752        }
753
754        let conflict_prompt = format!(
755            "You are resolving merge conflicts. The following files have unresolved \
756             merge conflicts (<<<<<<< / ======= / >>>>>>> markers):\n\n{}\n\n\
757             Open each file, find the conflict markers, and resolve them by choosing \
758             the correct code. Remove all conflict markers. Do NOT add new features \
759             or refactor -- just resolve the conflicts so the code compiles and tests pass.\n\n\
760             After resolving, run any test/lint commands if available:\n\
761             - Test: {}\n\
762             - Lint: {}",
763            conflicting_files.iter().map(|f| format!("- {f}")).collect::<Vec<_>>().join("\n"),
764            ctx.test_command.as_deref().unwrap_or("(none)"),
765            ctx.lint_command.as_deref().unwrap_or("(none)"),
766        );
767
768        match self
769            .run_agent(run_id, AgentRole::Implementer, &conflict_prompt, worktree_path, 1)
770            .await
771        {
772            Ok(_) => {}
773            Err(e) => {
774                warn!(run_id = %run_id, error = %e, "conflict resolution agent failed");
775                git::abort_merge(worktree_path).await;
776                return Ok(RebaseOutcome::Failed(format!(
777                    "agent conflict resolution failed: {e:#}"
778                )));
779            }
780        }
781
782        // Check if conflicts are actually resolved
783        let remaining = git::conflicting_files(worktree_path).await;
784        if remaining.is_empty() {
785            if let Err(e) = git::commit_merge(worktree_path, &conflicting_files).await {
786                git::abort_merge(worktree_path).await;
787                return Ok(RebaseOutcome::Failed(format!("failed to commit resolution: {e:#}")));
788            }
789            info!(run_id = %run_id, "agent resolved merge conflicts");
790            Ok(RebaseOutcome::AgentResolved)
791        } else {
792            warn!(
793                run_id = %run_id,
794                remaining = ?remaining,
795                "agent did not resolve all conflicts"
796            );
797            git::abort_merge(worktree_path).await;
798            Ok(RebaseOutcome::Failed(format!(
799                "agent could not resolve conflicts in: {}",
800                remaining.join(", ")
801            )))
802        }
803    }
804
805    async fn run_agent(
806        &self,
807        run_id: &str,
808        role: AgentRole,
809        prompt: &str,
810        working_dir: &std::path::Path,
811        cycle: u32,
812    ) -> Result<crate::process::AgentResult> {
813        let agent_run_id = self.record_agent_start(run_id, role, cycle).await?;
814
815        info!(run_id = %run_id, agent = %role, cycle, "agent starting");
816
817        let invocation = AgentInvocation {
818            role,
819            prompt: prompt.to_string(),
820            working_dir: working_dir.to_path_buf(),
821            max_turns: Some(self.config.pipeline.turn_limit),
822        };
823
824        let result = process::run_with_retry(self.runner.as_ref(), &invocation).await;
825
826        match &result {
827            Ok(agent_result) => {
828                self.record_agent_success(run_id, agent_run_id, agent_result).await?;
829            }
830            Err(e) => {
831                let conn = self.db.lock().await;
832                db::agent_runs::finish_agent_run(
833                    &conn,
834                    agent_run_id,
835                    "failed",
836                    0.0,
837                    0,
838                    None,
839                    Some(&format!("{e:#}")),
840                    None,
841                )?;
842            }
843        }
844
845        result
846    }
847
848    async fn record_agent_start(&self, run_id: &str, role: AgentRole, cycle: u32) -> Result<i64> {
849        let agent_run = AgentRun {
850            id: 0,
851            run_id: run_id.to_string(),
852            agent: role.to_string(),
853            cycle,
854            status: "running".to_string(),
855            cost_usd: 0.0,
856            turns: 0,
857            started_at: chrono::Utc::now().to_rfc3339(),
858            finished_at: None,
859            output_summary: None,
860            error_message: None,
861            raw_output: None,
862        };
863        let conn = self.db.lock().await;
864        db::agent_runs::insert_agent_run(&conn, &agent_run)
865    }
866
867    async fn record_agent_success(
868        &self,
869        run_id: &str,
870        agent_run_id: i64,
871        agent_result: &crate::process::AgentResult,
872    ) -> Result<()> {
873        let conn = self.db.lock().await;
874        db::agent_runs::finish_agent_run(
875            &conn,
876            agent_run_id,
877            "complete",
878            agent_result.cost_usd,
879            agent_result.turns,
880            Some(&truncate(&agent_result.output, 500)),
881            None,
882            Some(&agent_result.output),
883        )?;
884
885        let new_cost = db::runs::increment_run_cost(&conn, run_id, agent_result.cost_usd)?;
886        drop(conn);
887
888        if new_cost > self.config.pipeline.cost_budget {
889            anyhow::bail!(
890                "cost budget exceeded: ${:.2} > ${:.2}",
891                new_cost,
892                self.config.pipeline.cost_budget
893            );
894        }
895        Ok(())
896    }
897
898    async fn update_status(&self, run_id: &str, status: RunStatus) -> Result<()> {
899        let conn = self.db.lock().await;
900        db::runs::update_run_status(&conn, run_id, status)
901    }
902
903    fn check_cancelled(&self) -> Result<()> {
904        if self.cancel_token.is_cancelled() {
905            anyhow::bail!("pipeline cancelled");
906        }
907        Ok(())
908    }
909}
910
911const COMMENT_FOOTER: &str =
912    "\n---\nAutomated by [oven](https://github.com/clayharmon/oven-cli) \u{1F35E}";
913
914fn format_unresolved_comment(actionable: &[&agents::Finding]) -> String {
915    let mut comment = String::from(
916        "### Unresolved review findings\n\n\
917         The review-fix loop ran 2 cycles but these findings remain unresolved.\n",
918    );
919
920    // Group findings by severity
921    for severity in &[Severity::Critical, Severity::Warning] {
922        let group: Vec<_> = actionable.iter().filter(|f| &f.severity == severity).collect();
923        if group.is_empty() {
924            continue;
925        }
926        let heading = match severity {
927            Severity::Critical => "Critical",
928            Severity::Warning => "Warning",
929            Severity::Info => unreachable!("loop only iterates Critical and Warning"),
930        };
931        let _ = writeln!(comment, "\n#### {heading}\n");
932        for f in group {
933            let loc = match (&f.file_path, f.line_number) {
934                (Some(path), Some(line)) => format!(" in `{path}:{line}`"),
935                (Some(path), None) => format!(" in `{path}`"),
936                _ => String::new(),
937            };
938            let _ = writeln!(comment, "- **{}**{} -- {}", f.category, loc, f.message);
939        }
940    }
941
942    comment.push_str(COMMENT_FOOTER);
943    comment
944}
945
946fn format_impl_comment(summary: &str) -> String {
947    format!("### Implementation complete\n\n{summary}{COMMENT_FOOTER}")
948}
949
950fn format_review_comment(cycle: u32, actionable: &[&agents::Finding]) -> String {
951    if actionable.is_empty() {
952        return format!(
953            "### Review complete (cycle {cycle})\n\n\
954             Clean review, no actionable findings.{COMMENT_FOOTER}"
955        );
956    }
957
958    let mut comment = format!(
959        "### Review complete (cycle {cycle})\n\n\
960         **{count} finding{s}:**\n",
961        count = actionable.len(),
962        s = if actionable.len() == 1 { "" } else { "s" },
963    );
964
965    for f in actionable {
966        let loc = match (&f.file_path, f.line_number) {
967            (Some(path), Some(line)) => format!(" in `{path}:{line}`"),
968            (Some(path), None) => format!(" in `{path}`"),
969            _ => String::new(),
970        };
971        let _ = writeln!(
972            comment,
973            "- [{sev}] **{cat}**{loc} -- {msg}",
974            sev = f.severity,
975            cat = f.category,
976            msg = f.message,
977        );
978    }
979
980    comment.push_str(COMMENT_FOOTER);
981    comment
982}
983
984fn format_fix_comment(cycle: u32, fixer: &agents::FixerOutput) -> String {
985    let addressed = fixer.addressed.len();
986    let disputed = fixer.disputed.len();
987    format!(
988        "### Fix complete (cycle {cycle})\n\n\
989         **Addressed:** {addressed} finding{a_s}\n\
990         **Disputed:** {disputed} finding{d_s}{COMMENT_FOOTER}",
991        a_s = if addressed == 1 { "" } else { "s" },
992        d_s = if disputed == 1 { "" } else { "s" },
993    )
994}
995
996fn format_review_skipped_comment(cycle: u32, error: &anyhow::Error) -> String {
997    format!(
998        "### Review skipped (cycle {cycle})\n\n\
999         Reviewer agent encountered an error. Continuing without review.\n\n\
1000         **Error:** {error:#}{COMMENT_FOOTER}"
1001    )
1002}
1003
1004fn format_fix_skipped_comment(cycle: u32, error: &anyhow::Error) -> String {
1005    format!(
1006        "### Fix skipped (cycle {cycle})\n\n\
1007         Fixer agent encountered an error. Continuing to next cycle.\n\n\
1008         **Error:** {error:#}{COMMENT_FOOTER}"
1009    )
1010}
1011
1012fn format_rebase_comment(outcome: &RebaseOutcome) -> String {
1013    match outcome {
1014        RebaseOutcome::Clean => {
1015            format!("### Rebase\n\nRebased onto base branch cleanly.{COMMENT_FOOTER}")
1016        }
1017        RebaseOutcome::MergeFallback => {
1018            format!(
1019                "### Rebase\n\n\
1020                 Rebase had conflicts, fell back to a merge commit.{COMMENT_FOOTER}"
1021            )
1022        }
1023        RebaseOutcome::AgentResolved => {
1024            format!(
1025                "### Rebase\n\n\
1026                 Rebase and merge both had conflicts. Agent resolved them.{COMMENT_FOOTER}"
1027            )
1028        }
1029        RebaseOutcome::MergeConflicts(_) => {
1030            format!(
1031                "### Rebase\n\n\
1032                 Merge conflicts present (awaiting resolution).{COMMENT_FOOTER}"
1033            )
1034        }
1035        RebaseOutcome::Failed(msg) => {
1036            format!(
1037                "### Rebase failed\n\n\
1038                 Could not rebase or merge onto the base branch.\n\n\
1039                 **Error:** {msg}{COMMENT_FOOTER}"
1040            )
1041        }
1042    }
1043}
1044
1045fn format_ready_comment() -> String {
1046    format!(
1047        "### Ready for review\n\nPipeline complete. This PR is ready for manual review.{COMMENT_FOOTER}"
1048    )
1049}
1050
1051fn format_merge_comment() -> String {
1052    format!("### Merged\n\nPipeline complete. PR has been merged.{COMMENT_FOOTER}")
1053}
1054
1055fn format_pipeline_failure(e: &anyhow::Error) -> String {
1056    format!(
1057        "## Pipeline failed\n\n\
1058         **Error:** {e:#}\n\n\
1059         The pipeline hit an unrecoverable error. Check the run logs for detail, \
1060         or re-run the pipeline.\
1061         {COMMENT_FOOTER}"
1062    )
1063}
1064
1065/// Build a PR title using the issue metadata.
1066///
1067/// Infers a conventional-commit prefix from the issue title. Falls back to
1068/// `fix` when no keyword matches.
1069fn pr_title(ctx: &AgentContext) -> String {
1070    let prefix = infer_commit_type(&ctx.issue_title);
1071    if ctx.issue_source == "github" {
1072        format!("{prefix}(#{}): {}", ctx.issue_number, ctx.issue_title)
1073    } else {
1074        format!("{prefix}: {}", ctx.issue_title)
1075    }
1076}
1077
1078/// Infer a conventional-commit type from an issue title.
1079fn infer_commit_type(title: &str) -> &'static str {
1080    let lower = title.to_lowercase();
1081    if lower.starts_with("feat") || lower.contains("add ") || lower.contains("implement ") {
1082        "feat"
1083    } else if lower.starts_with("refactor") {
1084        "refactor"
1085    } else if lower.starts_with("docs") || lower.starts_with("document") {
1086        "docs"
1087    } else if lower.starts_with("test") || lower.starts_with("add test") {
1088        "test"
1089    } else if lower.starts_with("chore") {
1090        "chore"
1091    } else {
1092        "fix"
1093    }
1094}
1095
1096/// Build a full PR body from the implementer's output and issue context.
1097fn build_pr_body(impl_output: &str, ctx: &AgentContext) -> String {
1098    let issue_ref = if ctx.issue_source == "github" {
1099        format!("Resolves #{}", ctx.issue_number)
1100    } else {
1101        format!("From local issue #{}", ctx.issue_number)
1102    };
1103
1104    let summary = extract_impl_summary(impl_output);
1105
1106    let mut body = String::new();
1107    let _ = writeln!(body, "{issue_ref}\n");
1108    let _ = write!(body, "{summary}");
1109    body.push_str(COMMENT_FOOTER);
1110    body
1111}
1112
1113/// Extract the summary section from implementer output.
1114///
1115/// Looks for `## PR Template` (repo-specific PR template) or `## Changes Made`
1116/// (default format) headings. Falls back to the full output (truncated) if
1117/// neither heading is found.
1118fn extract_impl_summary(output: &str) -> String {
1119    // Prefer a filled-out PR template if the implementer found one
1120    let idx = output.find("## PR Template").or_else(|| output.find("## Changes Made"));
1121
1122    if let Some(idx) = idx {
1123        let summary = output[idx..].trim();
1124        // Strip the "## PR Template" heading itself so the body reads cleanly
1125        let summary = summary.strip_prefix("## PR Template").map_or(summary, |s| s.trim_start());
1126        if summary.len() <= 4000 {
1127            return summary.to_string();
1128        }
1129        return truncate(summary, 4000);
1130    }
1131    // Fallback: no structured summary found. Don't dump raw agent narration
1132    // (stream-of-consciousness "Let me read..." text) into the PR body.
1133    String::from("*No implementation summary available. See commit history for details.*")
1134}
1135
1136fn new_run(run_id: &str, issue: &PipelineIssue, auto_merge: bool) -> Run {
1137    Run {
1138        id: run_id.to_string(),
1139        issue_number: issue.number,
1140        status: RunStatus::Pending,
1141        pr_number: None,
1142        branch: None,
1143        worktree_path: None,
1144        cost_usd: 0.0,
1145        auto_merge,
1146        started_at: chrono::Utc::now().to_rfc3339(),
1147        finished_at: None,
1148        error_message: None,
1149        complexity: "full".to_string(),
1150        issue_source: issue.source.to_string(),
1151    }
1152}
1153
1154/// Generate an 8-character hex run ID.
1155pub fn generate_run_id() -> String {
1156    uuid::Uuid::new_v4().to_string()[..8].to_string()
1157}
1158
1159/// Truncate a string to at most `max_len` bytes, appending "..." if truncated.
1160///
1161/// Reserves 3 bytes for the "..." suffix so the total output never exceeds `max_len`.
1162/// Always cuts at a valid UTF-8 character boundary to avoid panics on multi-byte input.
1163pub(crate) fn truncate(s: &str, max_len: usize) -> String {
1164    if s.len() <= max_len {
1165        return s.to_string();
1166    }
1167    let target = max_len.saturating_sub(3);
1168    let mut end = target;
1169    while end > 0 && !s.is_char_boundary(end) {
1170        end -= 1;
1171    }
1172    format!("{}...", &s[..end])
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use proptest::prelude::*;
1178
1179    use super::*;
1180
1181    proptest! {
1182        #[test]
1183        fn run_ids_always_8_hex_chars(_seed in any::<u64>()) {
1184            let id = generate_run_id();
1185            prop_assert_eq!(id.len(), 8);
1186            prop_assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1187        }
1188    }
1189
1190    #[test]
1191    fn run_id_is_8_hex_chars() {
1192        let id = generate_run_id();
1193        assert_eq!(id.len(), 8);
1194        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1195    }
1196
1197    #[test]
1198    fn run_ids_are_unique() {
1199        let ids: Vec<_> = (0..100).map(|_| generate_run_id()).collect();
1200        let unique: std::collections::HashSet<_> = ids.iter().collect();
1201        assert_eq!(ids.len(), unique.len());
1202    }
1203
1204    #[test]
1205    fn truncate_short_string() {
1206        assert_eq!(truncate("hello", 10), "hello");
1207    }
1208
1209    #[test]
1210    fn truncate_long_string() {
1211        let long = "a".repeat(100);
1212        let result = truncate(&long, 10);
1213        assert_eq!(result.len(), 10); // 7 chars + "..."
1214        assert!(result.ends_with("..."));
1215    }
1216
1217    #[test]
1218    fn truncate_multibyte_does_not_panic() {
1219        // Each emoji is 4 bytes. "πŸ˜€πŸ˜€πŸ˜€" = 12 bytes.
1220        // max_len=8, target=5, walks back to boundary at 4 (one emoji).
1221        let s = "πŸ˜€πŸ˜€πŸ˜€";
1222        let result = truncate(s, 8);
1223        assert!(result.ends_with("..."));
1224        assert!(result.starts_with("πŸ˜€"));
1225        assert!(result.len() <= 8);
1226    }
1227
1228    #[test]
1229    fn truncate_cjk_boundary() {
1230        // CJK chars are 3 bytes each
1231        let s = "δ½ ε₯½δΈ–η•Œζ΅‹θ―•"; // 18 bytes
1232        // max_len=10, target=7, walks back to boundary at 6 (two 3-byte chars).
1233        let result = truncate(s, 10);
1234        assert!(result.ends_with("..."));
1235        assert!(result.starts_with("δ½ ε₯½"));
1236        assert!(result.len() <= 10);
1237    }
1238
1239    #[test]
1240    fn extract_impl_summary_finds_changes_made() {
1241        let output = "Some preamble text\n\n## Changes Made\n- src/foo.rs: added bar\n\n## Tests Added\n- tests/foo.rs: bar test\n";
1242        let summary = extract_impl_summary(output);
1243        assert!(summary.starts_with("## Changes Made"));
1244        assert!(summary.contains("added bar"));
1245        assert!(summary.contains("## Tests Added"));
1246    }
1247
1248    #[test]
1249    fn extract_impl_summary_prefers_pr_template() {
1250        let output = "Preamble\n\n## PR Template\n## Summary\n- Added auth flow\n\n## Testing\n- Unit tests pass\n";
1251        let summary = extract_impl_summary(output);
1252        // Should strip the "## PR Template" heading
1253        assert!(!summary.contains("## PR Template"));
1254        assert!(summary.starts_with("## Summary"));
1255        assert!(summary.contains("Added auth flow"));
1256    }
1257
1258    #[test]
1259    fn extract_impl_summary_fallback_on_no_heading() {
1260        let output = "just some raw agent output with no structure";
1261        let summary = extract_impl_summary(output);
1262        assert_eq!(
1263            summary,
1264            "*No implementation summary available. See commit history for details.*"
1265        );
1266    }
1267
1268    #[test]
1269    fn extract_impl_summary_empty_output() {
1270        let placeholder = "*No implementation summary available. See commit history for details.*";
1271        assert_eq!(extract_impl_summary(""), placeholder);
1272        assert_eq!(extract_impl_summary("   "), placeholder);
1273    }
1274
1275    #[test]
1276    fn build_pr_body_github_issue() {
1277        let ctx = AgentContext {
1278            issue_number: 42,
1279            issue_title: "fix the thing".to_string(),
1280            issue_body: String::new(),
1281            branch: "oven/issue-42".to_string(),
1282            pr_number: Some(10),
1283            test_command: None,
1284            lint_command: None,
1285            review_findings: None,
1286            cycle: 1,
1287            target_repo: None,
1288            issue_source: "github".to_string(),
1289            base_branch: "main".to_string(),
1290        };
1291        let body = build_pr_body("## Changes Made\n- added stuff", &ctx);
1292        assert!(body.contains("Resolves #42"));
1293        assert!(body.contains("## Changes Made"));
1294        assert!(body.contains("Automated by [oven]"));
1295    }
1296
1297    #[test]
1298    fn build_pr_body_local_issue() {
1299        let ctx = AgentContext {
1300            issue_number: 7,
1301            issue_title: "local thing".to_string(),
1302            issue_body: String::new(),
1303            branch: "oven/issue-7".to_string(),
1304            pr_number: Some(10),
1305            test_command: None,
1306            lint_command: None,
1307            review_findings: None,
1308            cycle: 1,
1309            target_repo: None,
1310            issue_source: "local".to_string(),
1311            base_branch: "main".to_string(),
1312        };
1313        let body = build_pr_body("## Changes Made\n- did local stuff", &ctx);
1314        assert!(body.contains("From local issue #7"));
1315        assert!(body.contains("## Changes Made"));
1316    }
1317
1318    #[test]
1319    fn pr_title_github() {
1320        let ctx = AgentContext {
1321            issue_number: 42,
1322            issue_title: "fix the thing".to_string(),
1323            issue_body: String::new(),
1324            branch: String::new(),
1325            pr_number: None,
1326            test_command: None,
1327            lint_command: None,
1328            review_findings: None,
1329            cycle: 1,
1330            target_repo: None,
1331            issue_source: "github".to_string(),
1332            base_branch: "main".to_string(),
1333        };
1334        assert_eq!(pr_title(&ctx), "fix(#42): fix the thing");
1335    }
1336
1337    #[test]
1338    fn pr_title_local() {
1339        let ctx = AgentContext {
1340            issue_number: 7,
1341            issue_title: "local thing".to_string(),
1342            issue_body: String::new(),
1343            branch: String::new(),
1344            pr_number: None,
1345            test_command: None,
1346            lint_command: None,
1347            review_findings: None,
1348            cycle: 1,
1349            target_repo: None,
1350            issue_source: "local".to_string(),
1351            base_branch: "main".to_string(),
1352        };
1353        assert_eq!(pr_title(&ctx), "fix: local thing");
1354    }
1355
1356    #[test]
1357    fn infer_commit_type_feat() {
1358        assert_eq!(infer_commit_type("Add dark mode support"), "feat");
1359        assert_eq!(infer_commit_type("Implement caching layer"), "feat");
1360        assert_eq!(infer_commit_type("Feature: new dashboard"), "feat");
1361    }
1362
1363    #[test]
1364    fn infer_commit_type_refactor() {
1365        assert_eq!(infer_commit_type("Refactor auth middleware"), "refactor");
1366    }
1367
1368    #[test]
1369    fn infer_commit_type_docs() {
1370        assert_eq!(infer_commit_type("Document the API endpoints"), "docs");
1371        assert_eq!(infer_commit_type("Docs: update README"), "docs");
1372    }
1373
1374    #[test]
1375    fn infer_commit_type_defaults_to_fix() {
1376        assert_eq!(infer_commit_type("Null pointer in config parser"), "fix");
1377        assert_eq!(infer_commit_type("Crash on empty input"), "fix");
1378    }
1379
1380    #[test]
1381    fn pr_title_feat_github() {
1382        let ctx = AgentContext {
1383            issue_number: 10,
1384            issue_title: "Add dark mode".to_string(),
1385            issue_body: String::new(),
1386            branch: String::new(),
1387            pr_number: None,
1388            test_command: None,
1389            lint_command: None,
1390            review_findings: None,
1391            cycle: 1,
1392            target_repo: None,
1393            issue_source: "github".to_string(),
1394            base_branch: "main".to_string(),
1395        };
1396        assert_eq!(pr_title(&ctx), "feat(#10): Add dark mode");
1397    }
1398
1399    #[test]
1400    fn format_unresolved_comment_groups_by_severity() {
1401        let findings = [
1402            agents::Finding {
1403                severity: Severity::Critical,
1404                category: "bug".to_string(),
1405                file_path: Some("src/main.rs".to_string()),
1406                line_number: Some(42),
1407                message: "null pointer".to_string(),
1408            },
1409            agents::Finding {
1410                severity: Severity::Warning,
1411                category: "style".to_string(),
1412                file_path: None,
1413                line_number: None,
1414                message: "missing docs".to_string(),
1415            },
1416        ];
1417        let refs: Vec<_> = findings.iter().collect();
1418        let comment = format_unresolved_comment(&refs);
1419        assert!(comment.contains("#### Critical"));
1420        assert!(comment.contains("#### Warning"));
1421        assert!(comment.contains("**bug** in `src/main.rs:42` -- null pointer"));
1422        assert!(comment.contains("**style** -- missing docs"));
1423        assert!(comment.contains("Automated by [oven]"));
1424    }
1425
1426    #[test]
1427    fn format_unresolved_comment_skips_empty_severity_groups() {
1428        let findings = [agents::Finding {
1429            severity: Severity::Warning,
1430            category: "testing".to_string(),
1431            file_path: Some("src/lib.rs".to_string()),
1432            line_number: None,
1433            message: "missing edge case test".to_string(),
1434        }];
1435        let refs: Vec<_> = findings.iter().collect();
1436        let comment = format_unresolved_comment(&refs);
1437        assert!(!comment.contains("#### Critical"));
1438        assert!(comment.contains("#### Warning"));
1439    }
1440
1441    #[test]
1442    fn format_pipeline_failure_includes_error() {
1443        let err = anyhow::anyhow!("cost budget exceeded: $12.50 > $10.00");
1444        let comment = format_pipeline_failure(&err);
1445        assert!(comment.contains("## Pipeline failed"));
1446        assert!(comment.contains("cost budget exceeded"));
1447        assert!(comment.contains("Automated by [oven]"));
1448    }
1449
1450    #[test]
1451    fn format_impl_comment_includes_summary() {
1452        let comment = format_impl_comment("Added login endpoint with tests");
1453        assert!(comment.contains("### Implementation complete"));
1454        assert!(comment.contains("Added login endpoint with tests"));
1455        assert!(comment.contains("Automated by [oven]"));
1456    }
1457
1458    #[test]
1459    fn format_review_comment_clean() {
1460        let comment = format_review_comment(1, &[]);
1461        assert!(comment.contains("### Review complete (cycle 1)"));
1462        assert!(comment.contains("Clean review"));
1463    }
1464
1465    #[test]
1466    fn format_review_comment_with_findings() {
1467        let findings = [agents::Finding {
1468            severity: Severity::Critical,
1469            category: "bug".to_string(),
1470            file_path: Some("src/main.rs".to_string()),
1471            line_number: Some(42),
1472            message: "null pointer".to_string(),
1473        }];
1474        let refs: Vec<_> = findings.iter().collect();
1475        let comment = format_review_comment(1, &refs);
1476        assert!(comment.contains("### Review complete (cycle 1)"));
1477        assert!(comment.contains("1 finding"));
1478        assert!(comment.contains("[critical]"));
1479        assert!(comment.contains("`src/main.rs:42`"));
1480    }
1481
1482    #[test]
1483    fn format_fix_comment_counts() {
1484        let fixer = agents::FixerOutput {
1485            addressed: vec![
1486                agents::FixerAction { finding: 1, action: "fixed it".to_string() },
1487                agents::FixerAction { finding: 2, action: "also fixed".to_string() },
1488            ],
1489            disputed: vec![agents::FixerDispute { finding: 3, reason: "not a bug".to_string() }],
1490        };
1491        let comment = format_fix_comment(1, &fixer);
1492        assert!(comment.contains("### Fix complete (cycle 1)"));
1493        assert!(comment.contains("Addressed:** 2 findings"));
1494        assert!(comment.contains("Disputed:** 1 finding\n"));
1495    }
1496
1497    #[test]
1498    fn format_rebase_comment_variants() {
1499        let clean = format_rebase_comment(&RebaseOutcome::Clean);
1500        assert!(clean.contains("Rebased onto base branch cleanly"));
1501
1502        let merge = format_rebase_comment(&RebaseOutcome::MergeFallback);
1503        assert!(merge.contains("fell back to a merge commit"));
1504
1505        let agent = format_rebase_comment(&RebaseOutcome::AgentResolved);
1506        assert!(agent.contains("Agent resolved them"));
1507
1508        let conflicts =
1509            format_rebase_comment(&RebaseOutcome::MergeConflicts(vec!["foo.rs".into()]));
1510        assert!(conflicts.contains("awaiting resolution"));
1511
1512        let failed = format_rebase_comment(&RebaseOutcome::Failed("conflict in foo.rs".into()));
1513        assert!(failed.contains("Rebase failed"));
1514        assert!(failed.contains("conflict in foo.rs"));
1515    }
1516
1517    #[test]
1518    fn format_ready_comment_content() {
1519        let comment = format_ready_comment();
1520        assert!(comment.contains("### Ready for review"));
1521        assert!(comment.contains("manual review"));
1522    }
1523
1524    #[test]
1525    fn format_merge_comment_content() {
1526        let comment = format_merge_comment();
1527        assert!(comment.contains("### Merged"));
1528    }
1529}