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