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            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    /// Determine the effective repo directory for worktrees and PRs.
209    ///
210    /// Returns `(target_dir, is_multi_repo)`. When multi-repo is disabled or no target
211    /// is specified, falls back to `self.repo_dir`.
212    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    /// Reconstruct a `PipelineOutcome` from graph node data (for merge polling).
229    ///
230    /// Worktree paths are deterministic, so we can rebuild the outcome from
231    /// the issue metadata stored on the graph node.
232    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                // Close the issue for local and multi-repo cases. Single-repo
312                // GitHub issues are closed directly in the merge step (run_steps)
313                // because they share the same gh context.
314                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        // 1. Implement
369        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        // 1b. Update PR description and mark ready for review
377        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        // 1c. Post implementation comment on PR
390        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        // 2. Review-fix loop (posts its own step comments on the PR)
402        self.run_review_fix_loop(run_id, ctx, worktree_path, target_dir).await?;
403
404        // 3. Rebase onto base branch to resolve any conflicts from parallel merges
405        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        // 4. Merge (only when auto-merge is enabled)
427        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            // Close the issue for single-repo GitHub issues. Multi-repo and local
436            // issues are closed by finalize_run instead (different repo context).
437            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            // Reviewer failure: skip review and continue (don't kill pipeline)
483            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            // Post review comment on PR
510            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            // Snapshot HEAD before fix step so next reviewer can scope to fixer changes
538            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    /// Gather prior addressed, disputed, and unresolved findings for review cycles 2+.
547    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        // Strip the "ADDRESSED: " prefix so the template gets clean action text
566        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    /// Run the fixer agent for a given cycle, process its output, and push.
578    ///
579    /// If the fixer agent fails, posts a comment on the PR and returns `Ok(())`
580    /// so the caller can continue to the next review cycle.
581    ///
582    /// Handles three fixer outcome scenarios:
583    /// 1. Normal: fixer produces structured JSON with addressed/disputed findings
584    /// 2. Silent commits: fixer makes commits but no structured output (infer from git)
585    /// 3. Did nothing: no commits and no output (mark findings as not actionable)
586    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        // Snapshot HEAD before fixer runs
618        let pre_fix_head = git::head_sha(worktree_path).await?;
619
620        let fix_prompt = agents::fixer::build_prompt(ctx, &actionable)?;
621
622        // Fixer failure: skip fix (caller continues to next review cycle)
623        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        // Parse fixer output and detect "did nothing" scenarios
643        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                // Fixer made commits but didn't produce structured output.
656                // Infer which findings were addressed by checking changed files.
657                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                // Fixer did literally nothing. Mark findings so they don't zombie.
665                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        // Post fix comment on PR
684        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    /// Process all fixer results (disputes + addressed) in a single lock acquisition.
698    ///
699    /// The fixer references findings by 1-indexed position in the list it received.
700    /// We map those back to the actual `ReviewFinding` IDs and mark them resolved.
701    /// Disputed findings store the fixer's reason directly; addressed findings get
702    /// an `ADDRESSED: ` prefix so we can distinguish them when building the next
703    /// reviewer prompt.
704    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    /// Filter unresolved findings into actionable (has `file_path`) and non-actionable.
748    ///
749    /// Non-actionable findings are auto-resolved in the DB so they don't accumulate
750    /// as zombie findings across cycles.
751    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    /// Infer which findings were addressed by the fixer based on git changes.
778    ///
779    /// When the fixer makes commits but doesn't produce structured JSON output,
780    /// we cross-reference the changed files against the finding file paths.
781    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    /// Rebase with agent-assisted conflict resolution.
843    ///
844    /// Chain: rebase -> if conflicts, agent resolves -> rebase --continue -> loop.
845    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            // Check if the agent actually resolved the conflicts
906            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            // Stage resolved files and continue the rebase
921            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                    // Next commit in the rebase also has conflicts -- loop
928                    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        // Exhausted all rounds
938        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    // Group findings by severity
1061    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
1227/// Build a PR title using the issue metadata.
1228///
1229/// Infers a conventional-commit prefix from the issue title. Falls back to
1230/// `fix` when no keyword matches.
1231fn 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
1240/// Infer a conventional-commit type from an issue title.
1241fn 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
1258/// Build a full PR body from the implementer's output and issue context.
1259fn 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
1275/// Extract the summary section from implementer output.
1276///
1277/// Looks for `## PR Template` (repo-specific PR template) or `## Changes Made`
1278/// (default format) headings. Falls back to the full output (truncated) if
1279/// neither heading is found.
1280fn extract_impl_summary(output: &str) -> String {
1281    // Prefer a filled-out PR template if the implementer found one
1282    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        // Strip the "## PR Template" heading itself so the body reads cleanly
1287        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    // Fallback: no structured summary found. Don't dump raw agent narration
1294    // (stream-of-consciousness "Let me read..." text) into the PR body.
1295    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
1316/// Generate an 8-character hex run ID.
1317pub fn generate_run_id() -> String {
1318    uuid::Uuid::new_v4().to_string()[..8].to_string()
1319}
1320
1321/// Truncate a string to at most `max_len` bytes, appending "..." if truncated.
1322///
1323/// Reserves 3 bytes for the "..." suffix so the total output never exceeds `max_len`.
1324/// Always cuts at a valid UTF-8 character boundary to avoid panics on multi-byte input.
1325pub(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); // 7 chars + "..."
1376        assert!(result.ends_with("..."));
1377    }
1378
1379    #[test]
1380    fn truncate_multibyte_does_not_panic() {
1381        // Each emoji is 4 bytes. "πŸ˜€πŸ˜€πŸ˜€" = 12 bytes.
1382        // max_len=8, target=5, walks back to boundary at 4 (one emoji).
1383        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        // CJK chars are 3 bytes each
1393        let s = "δ½ ε₯½δΈ–η•Œζ΅‹θ―•"; // 18 bytes
1394        // max_len=10, target=7, walks back to boundary at 6 (two 3-byte chars).
1395        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        // Should strip the "## PR Template" heading
1415        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}