Skip to main content

omni_dev/cli/git/
create_pr.rs

1//! Create PR command — AI-powered pull request creation.
2
3use anyhow::{bail, Context, Result};
4use clap::Parser;
5use tracing::{debug, error, warn};
6
7use super::info::InfoCommand;
8use crate::claude::error::is_transient_ai_error as ai_error_is_transient;
9
10/// Create PR command options.
11#[derive(Parser)]
12pub struct CreatePrCommand {
13    /// Base branch for the PR to be merged into (defaults to main/master).
14    #[arg(long, value_name = "BRANCH")]
15    pub base: Option<String>,
16
17    /// Skips confirmation prompt and creates PR automatically.
18    #[arg(long)]
19    pub auto_apply: bool,
20
21    /// Saves generated PR details to file without creating PR.
22    #[arg(long, value_name = "FILE")]
23    pub save_only: Option<String>,
24
25    /// Creates PR as ready for review (overrides default).
26    #[arg(long, conflicts_with = "draft")]
27    pub ready: bool,
28
29    /// Creates PR as draft (overrides default).
30    #[arg(long, conflicts_with = "ready")]
31    pub draft: bool,
32
33    /// Path to custom context directory (defaults to .omni-dev/).
34    #[arg(long)]
35    pub context_dir: Option<std::path::PathBuf>,
36
37    /// Skip pushing the branch to remote before creating the PR.
38    #[arg(long)]
39    pub no_push: bool,
40
41    /// Use commit messages (not the diff) as the primary input for PR generation.
42    #[arg(long)]
43    pub from_commits: bool,
44
45    /// AI backend selection (`--ai-backend`, `--model`, …).
46    #[command(flatten)]
47    pub ai: crate::cli::ai_backend_args::AiBackendArgs,
48}
49
50/// PR action choices.
51#[derive(Debug, PartialEq)]
52enum PrAction {
53    CreateNew,
54    UpdateExisting,
55    Cancel,
56}
57
58/// AI-generated PR content with structured fields.
59#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
60#[schemars(deny_unknown_fields)]
61pub struct PrContent {
62    /// Concise PR title (ideally 50-80 characters).
63    pub title: String,
64    /// Full PR description in markdown format.
65    pub description: String,
66}
67
68/// PR content plus how it was produced.
69///
70/// `used_fallback` is deliberately kept out of [`PrContent`], which is
71/// serialized to `pr-details.yaml` and must keep its two-field schema.
72struct GeneratedPr {
73    /// The title and description to apply.
74    content: PrContent,
75    /// True when the AI call failed and `content` is template-derived.
76    used_fallback: bool,
77}
78
79impl GeneratedPr {
80    /// Wraps content the AI produced successfully.
81    fn from_ai(content: PrContent) -> Self {
82        Self {
83            content,
84            used_fallback: false,
85        }
86    }
87
88    /// Wraps template-derived content produced after an AI failure.
89    fn from_fallback(content: PrContent) -> Self {
90        Self {
91            content,
92            used_fallback: true,
93        }
94    }
95}
96
97impl CreatePrCommand {
98    /// Determines if the PR should be created as draft.
99    ///
100    /// Priority order:
101    /// 1. --ready flag (not draft)
102    /// 2. --draft flag (draft)
103    /// 3. OMNI_DEV_DEFAULT_DRAFT_PR env/config setting
104    /// 4. Hard-coded default (draft)
105    fn should_create_as_draft(&self) -> bool {
106        use crate::utils::settings::get_env_var;
107
108        // Explicit flags take precedence
109        if self.ready {
110            return false;
111        }
112        if self.draft {
113            return true;
114        }
115
116        // Check configuration setting
117        get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
118            .ok()
119            .and_then(|val| parse_bool_string(&val))
120            .unwrap_or(true) // Default to draft if not configured
121    }
122
123    /// Executes the create PR command.
124    pub async fn execute(self, repo: Option<&std::path::Path>) -> Result<()> {
125        self.ai.apply();
126
127        // Resolve the repo root once; every git, config, scratch, PR-template,
128        // and `gh` read below anchors to it (the CWD is the default when no
129        // path is injected).
130        let repo_root = match repo {
131            Some(p) => p.to_path_buf(),
132            None => std::env::current_dir().context("Failed to determine current directory")?,
133        };
134        let repo_root = repo_root.as_path();
135
136        // Preflight check: validate all prerequisites before any processing
137        // This catches missing credentials/tools early before wasting time
138        // Model selection uses this command's `--model` flag (propagated as
139        // OMNI_DEV_MODEL by `self.ai.apply()`) and the per-backend env chain.
140        let ai_info = crate::utils::check_pr_command_prerequisites(None, repo_root)?;
141        println!(
142            "✓ {} credentials verified (model: {})",
143            ai_info.provider, ai_info.model
144        );
145        println!("✓ GitHub CLI verified");
146
147        println!("🔄 Starting pull request creation process...");
148
149        // 1. Generate repository view (reuse InfoCommand logic)
150        let repo_view = self.generate_repository_view(repo_root)?;
151
152        // 2. Validate branch state (always needed)
153        self.validate_branch_state(&repo_view)?;
154
155        // 3. Show guidance files status early (before AI processing)
156        use crate::claude::context::ProjectDiscovery;
157        let context_dir =
158            crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
159        let discovery = ProjectDiscovery::new(repo_root.to_path_buf(), context_dir);
160        let project_context = discovery.discover().unwrap_or_default();
161        self.show_guidance_files_status(repo_root, &project_context)?;
162
163        // 4. Show AI model configuration before generation
164        let claude_client = crate::claude::create_default_claude_client(None, None).await?;
165        self.show_model_info_from_client(&claude_client)?;
166
167        // 5. Show branch analysis and commit information
168        self.show_commit_range_info(&repo_view)?;
169
170        // 6. Show context analysis (quick collection for display only)
171        let context = {
172            use crate::claude::context::{BranchAnalyzer, FileAnalyzer, WorkPatternAnalyzer};
173            use crate::data::context::CommitContext;
174            let mut context = CommitContext::new();
175            context.project = project_context;
176
177            // Quick analysis for display
178            if let Some(branch_info) = &repo_view.branch_info {
179                context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
180            }
181
182            if !repo_view.commits.is_empty() {
183                context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
184                context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
185            }
186            context
187        };
188        self.show_context_summary(&context)?;
189
190        // 7. Generate AI-powered PR content (title + description)
191        debug!("About to generate PR content from AI");
192        let (generated, _claude_client) = self
193            .generate_pr_content_with_client_internal(repo_root, &repo_view, claude_client)
194            .await?;
195        let GeneratedPr {
196            content: pr_content,
197            used_fallback,
198        } = generated;
199
200        // 8. Show detailed context information (like twiddle command)
201        self.show_context_information(&repo_view)?;
202        debug!(
203            generated_title = %pr_content.title,
204            generated_description_length = pr_content.description.len(),
205            generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
206            "Generated PR content from AI"
207        );
208
209        // 5. Handle different output modes
210        if let Some(save_path) = self.save_only {
211            let pr_yaml = crate::data::to_yaml(&pr_content)
212                .context("Failed to serialize PR content to YAML")?;
213            std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
214            println!("💾 PR details saved to: {save_path}");
215            return Ok(());
216        }
217
218        // 6. Create temporary file for PR details
219        debug!("About to serialize PR content to YAML");
220        let temp_dir = tempfile::tempdir()?;
221        let pr_file = temp_dir.path().join("pr-details.yaml");
222
223        debug!(
224            pre_serialize_title = %pr_content.title,
225            pre_serialize_description_length = pr_content.description.len(),
226            pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
227            "About to serialize PR content with to_yaml"
228        );
229
230        let pr_yaml =
231            crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
232
233        debug!(
234            file_path = %pr_file.display(),
235            yaml_content_length = pr_yaml.len(),
236            yaml_content = %pr_yaml,
237            original_title = %pr_content.title,
238            original_description_length = pr_content.description.len(),
239            "Writing PR details to temporary YAML file"
240        );
241
242        std::fs::write(&pr_file, &pr_yaml)?;
243
244        // 7. Handle PR details file - show path and get user choice
245        let pr_action = if self.auto_apply {
246            // For auto-apply, default to update if PR exists, otherwise create new
247            if repo_view
248                .branch_prs
249                .as_ref()
250                .is_some_and(|prs| !prs.is_empty())
251            {
252                PrAction::UpdateExisting
253            } else {
254                PrAction::CreateNew
255            }
256        } else {
257            self.handle_pr_file(&pr_file, &repo_view)?
258        };
259
260        if pr_action == PrAction::Cancel {
261            println!("❌ PR operation cancelled by user");
262            return Ok(());
263        }
264
265        if used_fallback && self.auto_apply && pr_action == PrAction::UpdateExisting {
266            self.refuse_template_clobber(&repo_view)?;
267        }
268
269        // 8. Create or update PR (re-read from file to capture any user edits)
270        let final_pr_yaml =
271            std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
272
273        debug!(
274            yaml_length = final_pr_yaml.len(),
275            yaml_content = %final_pr_yaml,
276            "Read PR details YAML from file"
277        );
278
279        let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
280            .context("Failed to parse PR details YAML. Please check the file format.")?;
281
282        debug!(
283            title = %final_pr_content.title,
284            description_length = final_pr_content.description.len(),
285            description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
286            "Parsed PR content from YAML"
287        );
288
289        // Determine draft status
290        let is_draft = self.should_create_as_draft();
291
292        match pr_action {
293            PrAction::CreateNew => {
294                self.create_github_pr(
295                    repo_root,
296                    &repo_view,
297                    &final_pr_content.title,
298                    &final_pr_content.description,
299                    is_draft,
300                    self.base.as_deref(),
301                )?;
302                println!("✅ Pull request created successfully!");
303            }
304            PrAction::UpdateExisting => {
305                self.update_github_pr(
306                    repo_root,
307                    &repo_view,
308                    &final_pr_content.title,
309                    &final_pr_content.description,
310                    self.base.as_deref(),
311                )?;
312                println!("✅ Pull request updated successfully!");
313            }
314            PrAction::Cancel => unreachable!(), // Already handled above
315        }
316
317        Ok(())
318    }
319
320    /// Generates the repository view (reuses InfoCommand logic).
321    fn generate_repository_view(
322        &self,
323        repo_root: &std::path::Path,
324    ) -> Result<crate::data::RepositoryView> {
325        use crate::data::{
326            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
327            WorkingDirectoryInfo,
328        };
329        use crate::git::{GitRepository, RemoteInfo};
330        use crate::utils::ai_scratch;
331
332        // Open git repository at the injected root
333        let repo = GitRepository::open_at(repo_root)
334            .context("Failed to open git repository at the given path")?;
335
336        // Get current branch name
337        let current_branch = repo.get_current_branch().context(
338            "Failed to get current branch. Make sure you're not in detached HEAD state.",
339        )?;
340
341        // Get remote information to determine proper remote and main branch
342        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
343
344        // Find the primary remote (prefer origin, fallback to first available)
345        let primary_remote = remotes
346            .iter()
347            .find(|r| r.name == "origin")
348            .or_else(|| remotes.first())
349            .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
350
351        // Determine base branch (with remote prefix)
352        let base_branch = if let Some(branch) = self.base.as_ref() {
353            // User specified base branch - try to resolve it
354            // First, check if it's already a valid remote ref (e.g., "origin/main")
355            let remote_ref = format!("refs/remotes/{branch}");
356            if repo.repository().find_reference(&remote_ref).is_ok() {
357                branch.clone()
358            } else {
359                // Try prepending the primary remote name (e.g., "main" -> "origin/main")
360                let with_remote = format!("{}/{}", primary_remote.name, branch);
361                let remote_ref = format!("refs/remotes/{with_remote}");
362                if repo.repository().find_reference(&remote_ref).is_ok() {
363                    with_remote
364                } else {
365                    anyhow::bail!(
366                        "Remote branch '{branch}' does not exist (also tried '{with_remote}')"
367                    );
368                }
369            }
370        } else {
371            // Auto-detect using the primary remote's main branch
372            let main_branch = &primary_remote.main_branch;
373            if main_branch == "unknown" {
374                let remote_name = &primary_remote.name;
375                anyhow::bail!("Could not determine main branch for remote '{remote_name}'");
376            }
377
378            let remote_main = format!("{}/{}", primary_remote.name, main_branch);
379
380            // Validate that the remote main branch exists
381            let remote_ref = format!("refs/remotes/{remote_main}");
382            if repo.repository().find_reference(&remote_ref).is_err() {
383                anyhow::bail!(
384                    "Remote main branch '{remote_main}' does not exist. Try running 'git fetch' first."
385                );
386            }
387
388            remote_main
389        };
390
391        // Calculate commit range: [remote_base]..HEAD
392        let commit_range = format!("{base_branch}..HEAD");
393
394        // Get working directory status
395        let wd_status = repo.get_working_directory_status()?;
396        let working_directory = WorkingDirectoryInfo {
397            clean: wd_status.clean,
398            untracked_changes: wd_status
399                .untracked_changes
400                .into_iter()
401                .map(|fs| FileStatusInfo {
402                    status: fs.status,
403                    file: fs.file,
404                })
405                .collect(),
406        };
407
408        // Get remote information
409        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
410
411        // Parse commit range and get commits
412        let commits = repo.get_commits_in_range(&commit_range)?;
413
414        // Check for PR template
415        let pr_template_result = InfoCommand::read_pr_template(repo_root).ok();
416        let (pr_template, pr_template_location) = match pr_template_result {
417            Some((content, location)) => (Some(content), Some(location)),
418            None => (None, None),
419        };
420
421        // Get PRs for current branch
422        let branch_prs = InfoCommand::get_branch_prs(&current_branch, repo_root)
423            .ok()
424            .filter(|prs| !prs.is_empty());
425
426        // Create version information
427        let versions = Some(VersionInfo {
428            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
429        });
430
431        // Get AI scratch directory
432        let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
433            .context("Failed to determine AI scratch directory")?;
434        let ai_info = AiInfo {
435            scratch: ai_scratch_path.to_string_lossy().to_string(),
436        };
437
438        // Build repository view with branch info
439        let mut repo_view = RepositoryView {
440            versions,
441            explanation: FieldExplanation::default(),
442            working_directory,
443            remotes,
444            ai: ai_info,
445            branch_info: Some(BranchInfo {
446                branch: current_branch,
447            }),
448            pr_template,
449            pr_template_location,
450            branch_prs,
451            commits,
452        };
453
454        // Update field presence based on actual data
455        repo_view.update_field_presence();
456
457        Ok(repo_view)
458    }
459
460    /// Validates the branch state for PR creation.
461    fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
462        // Check if working directory is clean
463        if !repo_view.working_directory.clean {
464            anyhow::bail!(
465                "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
466            );
467        }
468
469        // Check if there are any untracked changes
470        if !repo_view.working_directory.untracked_changes.is_empty() {
471            let file_list: Vec<&str> = repo_view
472                .working_directory
473                .untracked_changes
474                .iter()
475                .map(|f| f.file.as_str())
476                .collect();
477            anyhow::bail!(
478                "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
479                file_list.join(", ")
480            );
481        }
482
483        // Check if commits exist
484        if repo_view.commits.is_empty() {
485            anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
486        }
487
488        // Check if PR already exists for this branch
489        if let Some(existing_prs) = &repo_view.branch_prs {
490            if !existing_prs.is_empty() {
491                let pr_info: Vec<String> = existing_prs
492                    .iter()
493                    .map(|pr| format!("#{} ({})", pr.number, pr.state))
494                    .collect();
495
496                println!(
497                    "📋 Existing PR(s) found for this branch: {}",
498                    pr_info.join(", ")
499                );
500                // Don't bail - we'll handle this in the main flow
501            }
502        }
503
504        Ok(())
505    }
506
507    /// Shows detailed context information (similar to twiddle command).
508    fn show_context_information(&self, _repo_view: &crate::data::RepositoryView) -> Result<()> {
509        // Note: commit range info and context summary are now shown earlier
510        // This method is kept for potential future detailed information
511        // that should be shown after AI generation
512
513        Ok(())
514    }
515
516    /// Shows commit range and count information.
517    fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
518        // Recreate the base branch determination logic from generate_repository_view
519        let base_branch = match self.base.as_ref() {
520            Some(branch) => {
521                // User specified base branch
522                // Get the primary remote name from repo_view
523                let primary_remote_name = repo_view
524                    .remotes
525                    .iter()
526                    .find(|r| r.name == "origin")
527                    .or_else(|| repo_view.remotes.first())
528                    .map_or("origin", |r| r.name.as_str());
529                // Check if already has remote prefix
530                if branch.starts_with(&format!("{primary_remote_name}/")) {
531                    branch.clone()
532                } else {
533                    format!("{primary_remote_name}/{branch}")
534                }
535            }
536            None => {
537                // Auto-detected base branch from remotes
538                repo_view
539                    .remotes
540                    .iter()
541                    .find(|r| r.name == "origin")
542                    .or_else(|| repo_view.remotes.first())
543                    .map_or_else(
544                        || "unknown".to_string(),
545                        |r| format!("{}/{}", r.name, r.main_branch),
546                    )
547            }
548        };
549
550        let commit_range = format!("{base_branch}..HEAD");
551        let commit_count = repo_view.commits.len();
552
553        // Get current branch name
554        let current_branch = repo_view
555            .branch_info
556            .as_ref()
557            .map_or("unknown", |bi| bi.branch.as_str());
558
559        println!("📊 Branch Analysis:");
560        println!("   🌿 Current branch: {current_branch}");
561        println!("   📏 Commit range: {commit_range}");
562        println!("   📝 Commits found: {commit_count} commits");
563        println!();
564
565        Ok(())
566    }
567
568    /// Collects contextual information for enhanced PR generation (adapted from twiddle).
569    fn collect_context(
570        &self,
571        repo_root: &std::path::Path,
572        repo_view: &crate::data::RepositoryView,
573    ) -> Result<crate::data::context::CommitContext> {
574        use crate::claude::context::{
575            BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
576        };
577        use crate::data::context::{CommitContext, ProjectContext};
578        use crate::git::GitRepository;
579
580        let mut context = CommitContext::new();
581
582        // 1. Discover project context
583        let context_dir =
584            crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
585
586        // ProjectDiscovery takes repo root and context directory
587        let discovery = ProjectDiscovery::new(repo_root.to_path_buf(), context_dir);
588        match discovery.discover() {
589            Ok(project_context) => {
590                context.project = project_context;
591            }
592            Err(_e) => {
593                context.project = ProjectContext::default();
594            }
595        }
596
597        // 2. Analyze current branch
598        let repo = GitRepository::open_at(repo_root)?;
599        let current_branch = repo
600            .get_current_branch()
601            .unwrap_or_else(|_| "HEAD".to_string());
602        context.branch = BranchAnalyzer::analyze(&current_branch).unwrap_or_default();
603
604        // 3. Analyze commit range patterns
605        if !repo_view.commits.is_empty() {
606            context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
607        }
608
609        // 3.5. Analyze file-level context
610        if !repo_view.commits.is_empty() {
611            context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
612        }
613
614        Ok(context)
615    }
616
617    /// Shows guidance files status (adapted from twiddle).
618    fn show_guidance_files_status(
619        &self,
620        repo_root: &std::path::Path,
621        project_context: &crate::data::context::ProjectContext,
622    ) -> Result<()> {
623        use crate::claude::context::{
624            config_source_label, resolve_context_dir_with_source_at, ConfigSourceLabel,
625        };
626
627        let (context_dir, dir_source) =
628            resolve_context_dir_with_source_at(self.context_dir.as_deref(), repo_root);
629
630        println!("📋 Project guidance files status:");
631        println!("   📂 Config dir: {} ({dir_source})", context_dir.display());
632
633        // Check PR guidelines (for PR commands)
634        let pr_guidelines_source = if project_context.pr_guidelines.is_some() {
635            match config_source_label(&context_dir, "pr-guidelines.md") {
636                ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
637                label => format!("✅ {label}"),
638            }
639        } else {
640            "❌ None found".to_string()
641        };
642        println!("   🔀 PR guidelines: {pr_guidelines_source}");
643
644        // Check scopes
645        let scopes_count = project_context.valid_scopes.len();
646        let scopes_source = if scopes_count > 0 {
647            match config_source_label(&context_dir, "scopes.yaml") {
648                ConfigSourceLabel::NotFound => {
649                    format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
650                }
651                label => format!("✅ {label} ({scopes_count} scopes)"),
652            }
653        } else {
654            "❌ None found".to_string()
655        };
656        println!("   🎯 Valid scopes: {scopes_source}");
657
658        // Check PR template
659        let pr_template_path = repo_root.join(".github/pull_request_template.md");
660        let pr_template_status = if pr_template_path.exists() {
661            format!("✅ Project: {}", pr_template_path.display())
662        } else {
663            "❌ None found".to_string()
664        };
665        println!("   📋 PR template: {pr_template_status}");
666
667        println!();
668        Ok(())
669    }
670
671    /// Shows the context summary (adapted from twiddle).
672    fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
673        use crate::data::context::{VerbosityLevel, WorkPattern};
674
675        println!("🔍 Context Analysis:");
676
677        // Project context
678        if !context.project.valid_scopes.is_empty() {
679            let scope_names: Vec<&str> = context
680                .project
681                .valid_scopes
682                .iter()
683                .map(|s| s.name.as_str())
684                .collect();
685            println!("   📁 Valid scopes: {}", scope_names.join(", "));
686        }
687
688        // Branch context
689        if context.branch.is_feature_branch {
690            println!(
691                "   🌿 Branch: {} ({})",
692                context.branch.description, context.branch.work_type
693            );
694            if let Some(ref ticket) = context.branch.ticket_id {
695                println!("   🎫 Ticket: {ticket}");
696            }
697        }
698
699        // Work pattern
700        match context.range.work_pattern {
701            WorkPattern::Sequential => println!("   🔄 Pattern: Sequential development"),
702            WorkPattern::Refactoring => println!("   🧹 Pattern: Refactoring work"),
703            WorkPattern::BugHunt => println!("   🐛 Pattern: Bug investigation"),
704            WorkPattern::Documentation => println!("   📖 Pattern: Documentation updates"),
705            WorkPattern::Configuration => println!("   ⚙️  Pattern: Configuration changes"),
706            WorkPattern::Unknown => {}
707        }
708
709        // File analysis
710        if let Some(label) = super::formatting::format_file_analysis(&context.files) {
711            println!("   {label}");
712        }
713
714        // Verbosity level
715        match context.suggested_verbosity() {
716            VerbosityLevel::Comprehensive => {
717                println!("   📝 Detail level: Comprehensive (significant changes detected)");
718            }
719            VerbosityLevel::Detailed => println!("   📝 Detail level: Detailed"),
720            VerbosityLevel::Concise => println!("   📝 Detail level: Concise"),
721        }
722
723        println!();
724        Ok(())
725    }
726
727    /// Generates PR content with a pre-created client (internal method that does not show model info).
728    ///
729    /// The returned [`GeneratedPr::used_fallback`] reports whether the content
730    /// came from the AI or from the template fallback, so callers can refuse to
731    /// overwrite a populated PR body with template text (issue #1333).
732    async fn generate_pr_content_with_client_internal(
733        &self,
734        repo_root: &std::path::Path,
735        repo_view: &crate::data::RepositoryView,
736        claude_client: crate::claude::client::ClaudeClient,
737    ) -> Result<(GeneratedPr, crate::claude::client::ClaudeClient)> {
738        use tracing::debug;
739
740        let pr_template = self.resolve_pr_template(repo_view);
741
742        debug!(
743            pr_template_length = pr_template.len(),
744            pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
745            "Using PR template for generation"
746        );
747
748        println!("🤖 Generating AI-powered PR description...");
749
750        // Collect project context for PR guidelines
751        debug!("Collecting context for PR generation");
752        let context = self.collect_context(repo_root, repo_view)?;
753        debug!("Context collection completed");
754
755        // Generate AI-powered PR content with context
756        debug!(
757            from_commits = self.from_commits,
758            "About to call Claude AI for PR content generation"
759        );
760        let ai_result = if self.from_commits {
761            claude_client
762                .generate_pr_content_with_context_from_commits(repo_view, &pr_template, &context)
763                .await
764        } else {
765            claude_client
766                .generate_pr_content_with_context(repo_view, &pr_template, &context)
767                .await
768        };
769        match ai_result {
770            Ok(pr_content) => {
771                debug!(
772                    ai_generated_title = %pr_content.title,
773                    ai_generated_description_length = pr_content.description.len(),
774                    ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
775                    "AI successfully generated PR content"
776                );
777                Ok((GeneratedPr::from_ai(pr_content), claude_client))
778            }
779            // A permanent failure can never succeed on a retry, so degrading to
780            // a template would report success for work that did not happen.
781            Err(e) if !ai_error_is_transient(&e) => {
782                Err(e).context("AI PR generation failed with a non-retryable error")
783            }
784            Err(e) => {
785                let content = self.fallback_pr_content(&e, pr_template, repo_view)?;
786                Ok((GeneratedPr::from_fallback(content), claude_client))
787            }
788        }
789    }
790
791    /// Builds template-derived PR content after a transient AI failure, warning
792    /// the user that the result is degraded.
793    ///
794    /// The warning goes to stderr rather than stdout: [`run_create_pr`] shares
795    /// this path and the MCP server owns stdout for JSON-RPC.
796    fn fallback_pr_content(
797        &self,
798        error: &anyhow::Error,
799        pr_template: String,
800        repo_view: &crate::data::RepositoryView,
801    ) -> Result<PrContent> {
802        warn!(error = %format!("{error:#}"), "AI PR generation failed, falling back to the PR template");
803        eprintln!("warning: AI PR generation failed: {error:#}");
804        eprintln!(
805            "warning: falling back to the PR template — the description below is not AI-generated."
806        );
807
808        let mut description = pr_template;
809        self.enhance_description_with_commits(&mut description, repo_view)?;
810        let title = self.generate_title_from_commits(repo_view);
811
812        debug!(
813            fallback_title = %title,
814            fallback_description_length = description.len(),
815            "Created fallback PR content"
816        );
817
818        Ok(PrContent { title, description })
819    }
820
821    /// Returns the PR template for this repository, falling back to the
822    /// built-in default when the repository has none.
823    fn resolve_pr_template(&self, repo_view: &crate::data::RepositoryView) -> String {
824        match &repo_view.pr_template {
825            Some(template) => template.clone(),
826            None => self.get_default_pr_template(),
827        }
828    }
829
830    /// Refuses to replace a populated PR description with template content
831    /// after the AI failed (issue #1333).
832    ///
833    /// Overwriting is irreversible from this tool's side, so a non-interactive
834    /// run stops rather than destroying a real description. Only applies when
835    /// the user cannot see and approve the content first — an interactive run
836    /// displays it and asks.
837    fn refuse_template_clobber(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
838        let Some(existing) = repo_view.branch_prs.as_ref().and_then(|prs| prs.first()) else {
839            return Ok(());
840        };
841
842        if Self::body_is_safe_to_replace(&existing.body, &self.resolve_pr_template(repo_view)) {
843            return Ok(());
844        }
845
846        bail!(
847            "Refusing to overwrite the description of PR #{} with template content \
848             after AI generation failed.\n\
849             The existing description would be lost and cannot be recovered by this tool.\n\
850             Re-run without --auto-apply to review the content first, or resolve the AI \
851             failure reported above.",
852            existing.number
853        );
854    }
855
856    /// Reports whether an existing PR body can be replaced without losing work.
857    ///
858    /// Safe only when the body is empty or is still the unfilled template.
859    /// Anything else — including a previously enhanced template — is treated as
860    /// worth keeping, since refusing is recoverable and overwriting is not.
861    fn body_is_safe_to_replace(body: &str, template: &str) -> bool {
862        let body = body.trim();
863        body.is_empty() || body == template.trim()
864    }
865
866    /// Returns the default PR template when none exists in the repository.
867    fn get_default_pr_template(&self) -> String {
868        r#"# Pull Request
869
870## Description
871<!-- Provide a brief description of what this PR does -->
872
873## Type of Change
874<!-- Mark the relevant option with an "x" -->
875- [ ] Bug fix (non-breaking change which fixes an issue)
876- [ ] New feature (non-breaking change which adds functionality)
877- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
878- [ ] Documentation update
879- [ ] Refactoring (no functional changes)
880- [ ] Performance improvement
881- [ ] Test coverage improvement
882
883## Changes Made
884<!-- List the specific changes made in this PR -->
885- 
886- 
887- 
888
889## Testing
890- [ ] All existing tests pass
891- [ ] New tests added for new functionality
892- [ ] Manual testing performed
893
894## Additional Notes
895<!-- Add any additional notes for reviewers -->
896"#.to_string()
897    }
898
899    /// Enhances the PR description with commit analysis.
900    fn enhance_description_with_commits(
901        &self,
902        description: &mut String,
903        repo_view: &crate::data::RepositoryView,
904    ) -> Result<()> {
905        if repo_view.commits.is_empty() {
906            return Ok(());
907        }
908
909        // Add commit summary section
910        description.push_str("\n---\n");
911        description.push_str("## 📝 Commit Summary\n");
912        description
913            .push_str("*This section was automatically generated based on commit analysis*\n\n");
914
915        // Analyze commit types and scopes
916        let mut types_found = std::collections::HashSet::new();
917        let mut scopes_found = std::collections::HashSet::new();
918        let mut has_breaking_changes = false;
919
920        for commit in &repo_view.commits {
921            let detected_type = &commit.analysis.detected_type;
922            types_found.insert(detected_type.clone());
923            if is_breaking_change(detected_type, &commit.original_message) {
924                has_breaking_changes = true;
925            }
926
927            let detected_scope = &commit.analysis.detected_scope;
928            if !detected_scope.is_empty() {
929                scopes_found.insert(detected_scope.clone());
930            }
931        }
932
933        // Update type checkboxes based on detected types
934        if types_found.contains("feat") {
935            check_checkbox(description, "- [ ] New feature");
936        }
937        if types_found.contains("fix") {
938            check_checkbox(description, "- [ ] Bug fix");
939        }
940        if types_found.contains("docs") {
941            check_checkbox(description, "- [ ] Documentation update");
942        }
943        if types_found.contains("refactor") {
944            check_checkbox(description, "- [ ] Refactoring");
945        }
946        if has_breaking_changes {
947            check_checkbox(description, "- [ ] Breaking change");
948        }
949
950        // Add detected scopes
951        let scopes_list: Vec<_> = scopes_found.into_iter().collect();
952        let scopes_section = format_scopes_section(&scopes_list);
953        if !scopes_section.is_empty() {
954            description.push_str(&scopes_section);
955        }
956
957        // Add commit list
958        let commit_entries: Vec<(&str, &str)> = repo_view
959            .commits
960            .iter()
961            .map(|c| {
962                let short = &c.hash[..crate::git::SHORT_HASH_LEN];
963                let first = extract_first_line(&c.original_message);
964                (short, first)
965            })
966            .collect();
967        description.push_str(&format_commit_list(&commit_entries));
968
969        // Add file change summary
970        let total_files: usize = repo_view
971            .commits
972            .iter()
973            .map(|c| c.analysis.file_changes.total_files)
974            .sum();
975
976        if total_files > 0 {
977            description.push_str(&format!("\n**Files changed:** {total_files} files\n"));
978        }
979
980        Ok(())
981    }
982
983    /// Handles the PR description file by showing the path and getting the user choice.
984    fn handle_pr_file(
985        &self,
986        pr_file: &std::path::Path,
987        repo_view: &crate::data::RepositoryView,
988    ) -> Result<PrAction> {
989        use std::io::{self, Write};
990
991        println!("\n📝 PR details generated.");
992        println!("💾 Details saved to: {}", pr_file.display());
993
994        // Show draft status
995        let is_draft = self.should_create_as_draft();
996        let (status_icon, status_text) = format_draft_status(is_draft);
997        println!("{status_icon} PR will be created as: {status_text}");
998        println!();
999
1000        // Check if there are existing PRs and show different options
1001        let has_existing_prs = repo_view
1002            .branch_prs
1003            .as_ref()
1004            .is_some_and(|prs| !prs.is_empty());
1005
1006        loop {
1007            if has_existing_prs {
1008                print!("❓ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
1009            } else {
1010                print!(
1011                    "❓ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
1012                );
1013            }
1014            io::stdout().flush()?;
1015
1016            let mut input = String::new();
1017            io::stdin().read_line(&mut input)?;
1018
1019            match input.trim().to_lowercase().as_str() {
1020                "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
1021                "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
1022                "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
1023                "s" | "show" => {
1024                    self.show_pr_file(pr_file)?;
1025                    println!();
1026                }
1027                "e" | "edit" => {
1028                    self.edit_pr_file(pr_file)?;
1029                    println!();
1030                }
1031                "q" | "quit" => return Ok(PrAction::Cancel),
1032                _ => {
1033                    if has_existing_prs {
1034                        println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
1035                    } else {
1036                        println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
1037                    }
1038                }
1039            }
1040        }
1041    }
1042
1043    /// Shows the contents of the PR details file.
1044    fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
1045        use std::fs;
1046
1047        println!("\n📄 PR details file contents:");
1048        println!("─────────────────────────────");
1049
1050        let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
1051        println!("{contents}");
1052        println!("─────────────────────────────");
1053
1054        Ok(())
1055    }
1056
1057    /// Opens the PR details file in an external editor.
1058    fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
1059        use std::env;
1060        use std::io::{self, Write};
1061        use std::process::Command;
1062
1063        // Try to get editor from environment variables
1064        let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
1065            e
1066        } else {
1067            // Prompt user for editor if neither environment variable is set
1068            println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
1069            print!("Please enter the command to use as your editor: ");
1070            io::stdout().flush().context("Failed to flush stdout")?;
1071
1072            let mut input = String::new();
1073            io::stdin()
1074                .read_line(&mut input)
1075                .context("Failed to read user input")?;
1076            input.trim().to_string()
1077        };
1078
1079        if editor.is_empty() {
1080            println!("❌ No editor specified. Returning to menu.");
1081            return Ok(());
1082        }
1083
1084        println!("📝 Opening PR details file in editor: {editor}");
1085
1086        let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
1087
1088        let mut command = Command::new(editor_cmd);
1089        command.args(args);
1090        command.arg(pr_file.to_string_lossy().as_ref());
1091
1092        match command.status() {
1093            Ok(status) => {
1094                if status.success() {
1095                    println!("✅ Editor session completed.");
1096                } else {
1097                    println!(
1098                        "⚠️  Editor exited with non-zero status: {:?}",
1099                        status.code()
1100                    );
1101                }
1102            }
1103            Err(e) => {
1104                println!("❌ Failed to execute editor '{editor}': {e}");
1105                println!("   Please check that the editor command is correct and available in your PATH.");
1106            }
1107        }
1108
1109        Ok(())
1110    }
1111
1112    /// Generates a concise title from commit analysis (fallback).
1113    fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
1114        if repo_view.commits.is_empty() {
1115            return "Pull Request".to_string();
1116        }
1117
1118        // For single commit, use its first line
1119        if repo_view.commits.len() == 1 {
1120            let first = extract_first_line(&repo_view.commits[0].original_message);
1121            let trimmed = first.trim();
1122            return if trimmed.is_empty() {
1123                "Pull Request".to_string()
1124            } else {
1125                trimmed.to_string()
1126            };
1127        }
1128
1129        // For multiple commits, generate from branch name
1130        let branch_name = repo_view
1131            .branch_info
1132            .as_ref()
1133            .map_or("feature", |bi| bi.branch.as_str());
1134
1135        format!("feat: {}", clean_branch_name(branch_name))
1136    }
1137
1138    /// Creates a new GitHub PR using gh CLI.
1139    fn create_github_pr(
1140        &self,
1141        repo_root: &std::path::Path,
1142        repo_view: &crate::data::RepositoryView,
1143        title: &str,
1144        description: &str,
1145        is_draft: bool,
1146        new_base: Option<&str>,
1147    ) -> Result<()> {
1148        // Get branch name
1149        let branch_name = repo_view
1150            .branch_info
1151            .as_ref()
1152            .map(|bi| &bi.branch)
1153            .context("Branch info not available")?;
1154
1155        let pr_status = if is_draft {
1156            "draft"
1157        } else {
1158            "ready for review"
1159        };
1160        println!("🚀 Creating pull request ({pr_status})...");
1161        println!("   📋 Title: {title}");
1162        println!("   🌿 Branch: {branch_name}");
1163        if let Some(base) = new_base {
1164            println!("   🎯 Base: {base}");
1165        }
1166
1167        // Push branch to remote unless --no-push was specified
1168        let push_action = if self.no_push {
1169            determine_push_action(true, false)
1170        } else {
1171            debug!("Opening git repository to check branch status");
1172            let git_repo = crate::git::GitRepository::open_at(repo_root)
1173                .context("Failed to open git repository at the given path")?;
1174
1175            debug!(
1176                "Checking if branch '{}' exists on remote 'origin'",
1177                branch_name
1178            );
1179            let branch_on_remote = git_repo.branch_exists_on_remote(branch_name, "origin")?;
1180            let action = determine_push_action(false, branch_on_remote);
1181
1182            debug!("Push action for branch '{}': {:?}", branch_name, action);
1183            println!("📤 Pushing branch to remote...");
1184            git_repo
1185                .push_branch(branch_name, "origin")
1186                .context("Failed to push branch to remote")?;
1187
1188            action
1189        };
1190
1191        if push_action == PushAction::Skip {
1192            debug!("Skipping push (--no-push flag set)");
1193        }
1194
1195        // Create PR using gh CLI with explicit head branch
1196        debug!("Creating PR with gh CLI - title: '{}'", title);
1197        debug!("PR description length: {} characters", description.len());
1198        debug!("PR draft status: {}", is_draft);
1199        if let Some(base) = new_base {
1200            debug!("PR base branch: {}", base);
1201        }
1202
1203        let mut args = vec![
1204            "pr",
1205            "create",
1206            "--head",
1207            branch_name,
1208            "--title",
1209            title,
1210            "--body",
1211            description,
1212        ];
1213
1214        if let Some(base) = new_base {
1215            args.push("--base");
1216            args.push(base);
1217        }
1218
1219        if is_draft {
1220            args.push("--draft");
1221        }
1222
1223        let pr_result = crate::github_metrics::run_gh(
1224            &crate::pr_status::resolve_gh_binary(),
1225            args,
1226            "pr create",
1227            Some(repo_root),
1228        )
1229        .context("Failed to create pull request")?;
1230
1231        if pr_result.status.success() {
1232            let pr_url = String::from_utf8_lossy(&pr_result.stdout);
1233            let pr_url = pr_url.trim();
1234            debug!("PR created successfully with URL: {}", pr_url);
1235            println!("🎉 Pull request created: {pr_url}");
1236        } else {
1237            let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1238            error!("gh CLI failed to create PR: {}", error_msg);
1239            anyhow::bail!("Failed to create pull request: {error_msg}");
1240        }
1241
1242        Ok(())
1243    }
1244
1245    /// Updates an existing GitHub PR using gh CLI.
1246    fn update_github_pr(
1247        &self,
1248        repo_root: &std::path::Path,
1249        repo_view: &crate::data::RepositoryView,
1250        title: &str,
1251        description: &str,
1252        new_base: Option<&str>,
1253    ) -> Result<()> {
1254        use std::io::{self, Write};
1255
1256        // Get the first existing PR (assuming we're updating the most recent one)
1257        let existing_pr = repo_view
1258            .branch_prs
1259            .as_ref()
1260            .and_then(|prs| prs.first())
1261            .context("No existing PR found to update")?;
1262
1263        let pr_number = existing_pr.number;
1264        let current_base = &existing_pr.base;
1265
1266        println!("🚀 Updating pull request #{pr_number}...");
1267        println!("   📋 Title: {title}");
1268
1269        // Check if base branch should be changed
1270        let change_base = if let Some(base) = new_base {
1271            if !current_base.is_empty() && current_base != base {
1272                print!("   🎯 Current base: {current_base} → New base: {base}. Change? [y/N]: ");
1273                io::stdout().flush()?;
1274
1275                let mut input = String::new();
1276                io::stdin().read_line(&mut input)?;
1277                let response = input.trim().to_lowercase();
1278                response == "y" || response == "yes"
1279            } else {
1280                false
1281            }
1282        } else {
1283            false
1284        };
1285
1286        debug!(
1287            pr_number = pr_number,
1288            title = %title,
1289            description_length = description.len(),
1290            description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1291            change_base = change_base,
1292            "Updating GitHub PR with title and description"
1293        );
1294
1295        // Update PR using gh CLI
1296        let pr_number_str = pr_number.to_string();
1297        let mut gh_args = vec![
1298            "pr",
1299            "edit",
1300            &pr_number_str,
1301            "--title",
1302            title,
1303            "--body",
1304            description,
1305        ];
1306
1307        if change_base {
1308            if let Some(base) = new_base {
1309                gh_args.push("--base");
1310                gh_args.push(base);
1311            }
1312        }
1313
1314        debug!(
1315            args = ?gh_args,
1316            "Executing gh command to update PR"
1317        );
1318
1319        let pr_result = crate::github_metrics::run_gh(
1320            &crate::pr_status::resolve_gh_binary(),
1321            gh_args,
1322            "pr edit",
1323            Some(repo_root),
1324        )
1325        .context("Failed to update pull request")?;
1326
1327        if pr_result.status.success() {
1328            // Get the PR URL using the existing PR data
1329            println!("🎉 Pull request updated: {}", existing_pr.url);
1330            if change_base {
1331                if let Some(base) = new_base {
1332                    println!("   🎯 Base branch changed to: {base}");
1333                }
1334            }
1335        } else {
1336            let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1337            anyhow::bail!("Failed to update pull request: {error_msg}");
1338        }
1339
1340        Ok(())
1341    }
1342
1343    /// Shows model information from the actual AI client.
1344    fn show_model_info_from_client(
1345        &self,
1346        client: &crate::claude::client::ClaudeClient,
1347    ) -> Result<()> {
1348        use crate::claude::model_config::get_model_registry;
1349
1350        println!("🤖 AI Model Configuration:");
1351
1352        // Get actual metadata from the client
1353        let metadata = client.get_ai_client_metadata();
1354        // NOTE (#967): this diagnostic banner reads the process-wide model
1355        // catalog (`get_model_registry` → CWD-relative project models.yaml),
1356        // not a `--repo`-scoped catalog. It is informational only and does not
1357        // affect the generated PR content, so it is left CWD-scoped until the
1358        // repo-aware `ModelRegistry::load_at` foundation lands.
1359        let registry = get_model_registry();
1360
1361        if let Some(spec) = registry.get_model_spec(&metadata.model) {
1362            // Highlight the API identifier portion in yellow
1363            if metadata.model != spec.api_identifier {
1364                println!(
1365                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
1366                    metadata.model, spec.api_identifier
1367                );
1368            } else {
1369                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1370            }
1371
1372            println!("   🏷️  Provider: {}", spec.provider);
1373            println!("   📊 Generation: {}", spec.generation);
1374            println!("   ⭐ Tier: {} ({})", spec.tier, {
1375                if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1376                    &tier_info.description
1377                } else {
1378                    "No description available"
1379                }
1380            });
1381            println!("   📤 Max output tokens: {}", metadata.max_response_length);
1382            println!("   📥 Input context: {}", metadata.max_context_length);
1383
1384            if let Some((ref key, ref value)) = metadata.active_beta {
1385                println!("   🔬 Beta header: {key}: {value}");
1386            }
1387
1388            if spec.legacy {
1389                println!("   ⚠️  Legacy model (consider upgrading to newer version)");
1390            }
1391        } else {
1392            // Fallback to client metadata if not in registry
1393            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1394            println!("   🏷️  Provider: {}", metadata.provider);
1395            println!("   ⚠️  Model not found in registry, using client metadata:");
1396            println!("   📤 Max output tokens: {}", metadata.max_response_length);
1397            println!("   📥 Input context: {}", metadata.max_context_length);
1398        }
1399
1400        println!();
1401        Ok(())
1402    }
1403}
1404
1405// --- Extracted pure functions ---
1406
1407/// Describes what push action should be taken before PR creation.
1408#[derive(Debug, PartialEq)]
1409enum PushAction {
1410    /// Skip pushing entirely (user passed `--no-push`).
1411    Skip,
1412    /// Push to sync with an existing remote branch.
1413    SyncExisting,
1414    /// Push a new branch to remote.
1415    PushNew,
1416}
1417
1418/// Determines what push action to take based on the `--no-push` flag and remote branch state.
1419fn determine_push_action(no_push: bool, branch_on_remote: bool) -> PushAction {
1420    if no_push {
1421        PushAction::Skip
1422    } else if branch_on_remote {
1423        PushAction::SyncExisting
1424    } else {
1425        PushAction::PushNew
1426    }
1427}
1428
1429/// Parses a boolean-like string value.
1430///
1431/// Accepts "true"/"1"/"yes" as `true` and "false"/"0"/"no" as `false`.
1432/// Returns `None` for unrecognized values.
1433fn parse_bool_string(val: &str) -> Option<bool> {
1434    match val.to_lowercase().as_str() {
1435        "true" | "1" | "yes" => Some(true),
1436        "false" | "0" | "no" => Some(false),
1437        _ => None,
1438    }
1439}
1440
1441/// Returns whether a commit represents a breaking change.
1442fn is_breaking_change(detected_type: &str, original_message: &str) -> bool {
1443    detected_type.contains("BREAKING") || original_message.contains("BREAKING CHANGE")
1444}
1445
1446/// Checks a markdown checkbox in the description by replacing `- [ ]` with `- [x]`.
1447fn check_checkbox(description: &mut String, search_text: &str) {
1448    if let Some(pos) = description.find(search_text) {
1449        description.replace_range(pos..pos + 5, "- [x]");
1450    }
1451}
1452
1453/// Formats a list of scopes as a markdown "Affected areas" section.
1454///
1455/// Returns an empty string if the list is empty.
1456fn format_scopes_section(scopes: &[String]) -> String {
1457    if scopes.is_empty() {
1458        return String::new();
1459    }
1460    format!("**Affected areas:** {}\n\n", scopes.join(", "))
1461}
1462
1463/// Formats commit entries as a markdown list with short hashes.
1464fn format_commit_list(entries: &[(&str, &str)]) -> String {
1465    let mut output = String::from("### Commits in this PR:\n");
1466    for (hash, message) in entries {
1467        output.push_str(&format!("- `{hash}` {message}\n"));
1468    }
1469    output
1470}
1471
1472/// Replaces path separators (`/`, `-`, `_`) in a branch name with spaces.
1473fn clean_branch_name(branch: &str) -> String {
1474    branch.replace(['/', '-', '_'], " ")
1475}
1476
1477/// Returns the first line of a text block, trimmed.
1478fn extract_first_line(text: &str) -> &str {
1479    text.lines().next().unwrap_or("").trim()
1480}
1481
1482/// Returns an (icon, label) pair for a PR's draft status.
1483fn format_draft_status(is_draft: bool) -> (&'static str, &'static str) {
1484    if is_draft {
1485        ("\u{1f4cb}", "draft")
1486    } else {
1487        ("\u{2705}", "ready for review")
1488    }
1489}
1490
1491/// Structured output from [`run_create_pr`] for programmatic consumers (MCP).
1492#[derive(Debug, Clone)]
1493pub struct CreatePrOutcome {
1494    /// Title as produced by the AI (or the fallback heuristic).
1495    pub title: String,
1496    /// Description body as produced by the AI (or the fallback heuristic).
1497    pub description: String,
1498    /// YAML serialisation of the [`PrContent`].
1499    pub pr_yaml: String,
1500}
1501
1502/// Non-interactive core for `omni-dev git branch create pr`.
1503///
1504/// Generates PR title + description via the AI but does NOT push the branch
1505/// or call `gh pr create`. The MCP boundary should expose the proposed PR
1506/// content so the assistant can decide what to do with it; actually pushing
1507/// a branch or creating a PR is out of scope for a single tool call. This
1508/// function must produce no stdout output — the MCP server uses stdout for
1509/// the JSON-RPC protocol.
1510pub async fn run_create_pr(
1511    model: Option<String>,
1512    base_branch: Option<&str>,
1513    repo_path: Option<&std::path::Path>,
1514) -> Result<CreatePrOutcome> {
1515    // Resolve the repo root once; the repository view and context discovery
1516    // anchor to it (the CWD is the default when no path is injected), so no
1517    // read resolves against the process working directory.
1518    let repo_root = match repo_path {
1519        Some(p) => p.to_path_buf(),
1520        None => std::env::current_dir().context("Failed to determine current directory")?,
1521    };
1522
1523    crate::utils::check_pr_command_prerequisites(model.as_deref(), &repo_root)?;
1524
1525    let cmd = CreatePrCommand {
1526        base: base_branch.map(str::to_string),
1527        auto_apply: true,
1528        save_only: None,
1529        ready: false,
1530        draft: false,
1531        context_dir: None,
1532        no_push: true,
1533        from_commits: false,
1534        ai: crate::cli::ai_backend_args::AiBackendArgs::default(),
1535    };
1536
1537    let repo_view = cmd.generate_repository_view(&repo_root)?;
1538    let context = cmd.collect_context(&repo_root, &repo_view)?;
1539    let claude_client = crate::claude::create_default_claude_client(model, None).await?;
1540    run_create_pr_with_client(&cmd, &repo_view, &context, &claude_client).await
1541}
1542
1543/// Non-credential-gated inner core of [`run_create_pr`] for unit tests.
1544///
1545/// Takes an already-built [`CreatePrCommand`], [`crate::data::RepositoryView`],
1546/// and [`crate::data::context::CommitContext`] so tests can construct those
1547/// in-memory (avoiding the git-remote setup `generate_repository_view`
1548/// requires). Callers are responsible for preflight, CWD, and context
1549/// assembly.
1550pub(crate) async fn run_create_pr_with_client(
1551    cmd: &CreatePrCommand,
1552    repo_view: &crate::data::RepositoryView,
1553    context: &crate::data::context::CommitContext,
1554    claude_client: &crate::claude::client::ClaudeClient,
1555) -> Result<CreatePrOutcome> {
1556    let pr_template = cmd.resolve_pr_template(repo_view);
1557
1558    let ai_result = if cmd.from_commits {
1559        claude_client
1560            .generate_pr_content_with_context_from_commits(repo_view, &pr_template, context)
1561            .await
1562    } else {
1563        claude_client
1564            .generate_pr_content_with_context(repo_view, &pr_template, context)
1565            .await
1566    };
1567    let pr_content = match ai_result {
1568        Ok(content) => content,
1569        // A permanent failure is reported rather than papered over; this call
1570        // does not mutate the PR, so a transient failure only needs the warning
1571        // `fallback_pr_content` emits (issue #1333).
1572        Err(e) if !ai_error_is_transient(&e) => {
1573            return Err(e).context("AI PR generation failed with a non-retryable error");
1574        }
1575        Err(e) => cmd.fallback_pr_content(&e, pr_template, repo_view)?,
1576    };
1577
1578    let pr_yaml = crate::data::to_yaml(&pr_content).context("Failed to serialise PrContent")?;
1579
1580    Ok(CreatePrOutcome {
1581        title: pr_content.title,
1582        description: pr_content.description,
1583        pr_yaml,
1584    })
1585}
1586
1587#[cfg(test)]
1588#[allow(clippy::unwrap_used, clippy::expect_used)]
1589mod run_create_pr_tests {
1590    use super::*;
1591    use crate::claude::client::ClaudeClient;
1592    use crate::claude::error::ClaudeError;
1593    use crate::claude::test_utils::ConfigurableMockAiClient;
1594    use crate::data::context::CommitContext;
1595    use crate::data::{
1596        AiInfo, BranchInfo, FieldExplanation, RepositoryView, VersionInfo, WorkingDirectoryInfo,
1597    };
1598    use crate::git::commit::FileChanges;
1599    use crate::git::{CommitAnalysis, CommitInfo};
1600
1601    #[tokio::test]
1602    async fn run_create_pr_invalid_repo_path_errors_before_ai() {
1603        let err = run_create_pr(
1604            None,
1605            None,
1606            Some(std::path::Path::new("/no/such/path/exists")),
1607        )
1608        .await
1609        .unwrap_err();
1610        let msg = format!("{err:#}").to_lowercase();
1611        // Preflight may surface a credentials error first, or (with creds
1612        // present) `generate_repository_view` opens the injected path via
1613        // `open_at` and fails with a git/repository error. Either proves the
1614        // injected path is honored without mutating the process CWD.
1615        assert!(
1616            msg.contains("git")
1617                || msg.contains("repository")
1618                || msg.contains("credential")
1619                || msg.contains("api")
1620                || msg.contains("directory"),
1621            "expected git/repository or preflight error, got: {msg}"
1622        );
1623    }
1624
1625    fn fresh_cmd() -> CreatePrCommand {
1626        CreatePrCommand {
1627            base: None,
1628            auto_apply: true,
1629            save_only: None,
1630            ready: false,
1631            draft: false,
1632            context_dir: None,
1633            no_push: true,
1634            from_commits: false,
1635            ai: crate::cli::ai_backend_args::AiBackendArgs::default(),
1636        }
1637    }
1638
1639    fn sample_commit(hash: &str, message: &str) -> (CommitInfo, tempfile::NamedTempFile) {
1640        let tmp = tempfile::NamedTempFile::new().unwrap();
1641        let commit = CommitInfo {
1642            hash: hash.to_string(),
1643            author: "Test <test@test.com>".to_string(),
1644            date: chrono::Utc::now().fixed_offset(),
1645            original_message: message.to_string(),
1646            in_main_branches: vec![],
1647            analysis: CommitAnalysis {
1648                detected_type: "feat".to_string(),
1649                detected_scope: String::new(),
1650                proposed_message: message.to_string(),
1651                file_changes: FileChanges {
1652                    total_files: 0,
1653                    files_added: 0,
1654                    files_deleted: 0,
1655                    file_list: vec![],
1656                },
1657                diff_summary: String::new(),
1658                diff_file: tmp.path().to_string_lossy().to_string(),
1659                file_diffs: Vec::new(),
1660            },
1661        };
1662        (commit, tmp)
1663    }
1664
1665    /// Builds a repo view carrying one existing PR with the given body, as
1666    /// `gh pr list --json …,body` would populate it.
1667    fn repo_view_with_existing_pr(pr_template: Option<String>, body: &str) -> RepositoryView {
1668        let mut view = sample_repo_view(vec![], pr_template);
1669        view.branch_prs = Some(vec![crate::data::PullRequest {
1670            number: 42,
1671            title: "Existing PR".to_string(),
1672            state: "open".to_string(),
1673            url: "https://example.invalid/pr/42".to_string(),
1674            body: body.to_string(),
1675            base: "main".to_string(),
1676        }]);
1677        view
1678    }
1679
1680    #[test]
1681    fn body_is_safe_to_replace_only_when_empty_or_template() {
1682        let template = "# Pull Request\n\n## Description\n";
1683        assert!(CreatePrCommand::body_is_safe_to_replace("", template));
1684        assert!(CreatePrCommand::body_is_safe_to_replace(
1685            "   \n  ", template
1686        ));
1687        // Whitespace differences must not make a template look like real content.
1688        assert!(CreatePrCommand::body_is_safe_to_replace(
1689            "\n# Pull Request\n\n## Description\n\n",
1690            template
1691        ));
1692        assert!(!CreatePrCommand::body_is_safe_to_replace(
1693            "A real, hand-written description.",
1694            template
1695        ));
1696    }
1697
1698    /// Issue #1333: the original bug replaced a populated description with an
1699    /// unfilled template and reported success.
1700    #[test]
1701    fn refuse_template_clobber_rejects_populated_body() {
1702        let cmd = fresh_cmd();
1703        let view = repo_view_with_existing_pr(None, "A real description worth keeping.");
1704
1705        let err = cmd
1706            .refuse_template_clobber(&view)
1707            .expect_err("must refuse to destroy a populated description");
1708        let chain = format!("{err:#}");
1709        assert!(chain.contains("#42"), "should name the PR: {chain}");
1710        assert!(
1711            chain.contains("Refusing to overwrite"),
1712            "should say what it refused: {chain}"
1713        );
1714    }
1715
1716    #[test]
1717    fn refuse_template_clobber_allows_empty_body() {
1718        let cmd = fresh_cmd();
1719        let view = repo_view_with_existing_pr(None, "   ");
1720        assert!(cmd.refuse_template_clobber(&view).is_ok());
1721    }
1722
1723    #[test]
1724    fn refuse_template_clobber_allows_unfilled_template_body() {
1725        let cmd = fresh_cmd();
1726        let template = "# Custom template\n";
1727        let view = repo_view_with_existing_pr(Some(template.to_string()), template);
1728        assert!(cmd.refuse_template_clobber(&view).is_ok());
1729    }
1730
1731    #[test]
1732    fn refuse_template_clobber_allows_when_no_existing_pr() {
1733        let cmd = fresh_cmd();
1734        let view = sample_repo_view(vec![], None);
1735        assert!(cmd.refuse_template_clobber(&view).is_ok());
1736    }
1737
1738    fn sample_repo_view(commits: Vec<CommitInfo>, pr_template: Option<String>) -> RepositoryView {
1739        RepositoryView {
1740            versions: Some(VersionInfo {
1741                omni_dev: "0.0.0".to_string(),
1742            }),
1743            explanation: FieldExplanation::default(),
1744            working_directory: WorkingDirectoryInfo {
1745                clean: true,
1746                untracked_changes: vec![],
1747            },
1748            remotes: vec![],
1749            ai: AiInfo {
1750                scratch: String::new(),
1751            },
1752            branch_info: Some(BranchInfo {
1753                branch: "feature/test".to_string(),
1754            }),
1755            pr_template,
1756            pr_template_location: None,
1757            branch_prs: None,
1758            commits,
1759        }
1760    }
1761
1762    #[tokio::test]
1763    async fn run_create_pr_with_client_ai_success_returns_content() {
1764        let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1765        let repo_view = sample_repo_view(vec![c1], None);
1766        let context = CommitContext::new();
1767        let cmd = fresh_cmd();
1768
1769        let yaml = "title: My PR\ndescription: |\n  Body text\n".to_string();
1770        let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1771        let client = ClaudeClient::new(Box::new(mock));
1772
1773        let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1774            .await
1775            .unwrap();
1776        assert_eq!(outcome.title, "My PR");
1777        assert!(outcome.description.contains("Body text"));
1778        assert!(outcome.pr_yaml.contains("title:"));
1779    }
1780
1781    #[tokio::test]
1782    async fn run_create_pr_with_client_ai_failure_falls_back_to_commit_summary() {
1783        let (c1, _tmp) = sample_commit("abcdef00", "feat: single commit subject");
1784        let repo_view = sample_repo_view(vec![c1], None);
1785        let context = CommitContext::new();
1786        let cmd = fresh_cmd();
1787
1788        // Empty mock → AI call exhausts retries → fallback path triggered.
1789        let mock = ConfigurableMockAiClient::new(vec![]);
1790        let client = ClaudeClient::new(Box::new(mock));
1791
1792        let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1793            .await
1794            .unwrap();
1795        assert!(
1796            outcome.title.contains("feat: single commit subject")
1797                || outcome.title.contains("Pull Request")
1798                || outcome.title.contains("feature/test"),
1799            "fallback title unexpected: {}",
1800            outcome.title
1801        );
1802    }
1803
1804    /// Issue #1333: a 404 (a model that does not exist) can never succeed, so
1805    /// it must surface as an error rather than a template-filled "success".
1806    #[tokio::test]
1807    async fn run_create_pr_with_client_permanent_ai_failure_errors() {
1808        let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1809        let repo_view = sample_repo_view(vec![c1], None);
1810        let context = CommitContext::new();
1811        let cmd = fresh_cmd();
1812
1813        let mock = ConfigurableMockAiClient::new(vec![Err(ClaudeError::ApiHttpError {
1814            status: 404,
1815            body: "model: claude-sonnet-4-8 not found".to_string(),
1816        }
1817        .into())]);
1818        let client = ClaudeClient::new(Box::new(mock));
1819
1820        let err = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1821            .await
1822            .expect_err("a 404 must not be reported as success");
1823        let chain = format!("{err:#}");
1824        assert!(
1825            chain.contains("non-retryable"),
1826            "error should explain why it did not fall back: {chain}"
1827        );
1828        assert!(
1829            chain.contains("404"),
1830            "error should name the underlying failure: {chain}"
1831        );
1832    }
1833
1834    /// A transient failure still degrades to the template, since a retry (or a
1835    /// later run) could plausibly succeed.
1836    #[tokio::test]
1837    async fn run_create_pr_with_client_transient_ai_failure_falls_back() {
1838        let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1839        let repo_view = sample_repo_view(vec![c1], None);
1840        let context = CommitContext::new();
1841        let cmd = fresh_cmd();
1842
1843        let mock = ConfigurableMockAiClient::new(vec![Err(ClaudeError::ApiHttpError {
1844            status: 503,
1845            body: "upstream unavailable".to_string(),
1846        }
1847        .into())]);
1848        let client = ClaudeClient::new(Box::new(mock));
1849
1850        let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1851            .await
1852            .expect("a 5xx should still fall back to the template");
1853        assert!(!outcome.title.is_empty());
1854    }
1855
1856    #[tokio::test]
1857    async fn run_create_pr_with_client_uses_repo_template_when_present() {
1858        let (c1, _tmp) = sample_commit("abcdef00", "feat: x");
1859        let repo_view = sample_repo_view(vec![c1], Some("# Custom template\n".to_string()));
1860        let context = CommitContext::new();
1861        let cmd = fresh_cmd();
1862
1863        // AI fails → fallback uses the repo template as the description base.
1864        let mock = ConfigurableMockAiClient::new(vec![]);
1865        let client = ClaudeClient::new(Box::new(mock));
1866
1867        let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1868            .await
1869            .unwrap();
1870        assert!(
1871            outcome.description.contains("# Custom template"),
1872            "fallback description should include repo template: {}",
1873            outcome.description
1874        );
1875    }
1876
1877    #[tokio::test]
1878    async fn run_create_pr_with_client_from_commits_omits_diff() {
1879        // Write a recognisable diff payload so we can prove it never reaches
1880        // the AI when from_commits is set.
1881        let dir = tempfile::tempdir().unwrap();
1882        let diff_path = dir.path().join("recognisable.diff");
1883        std::fs::write(
1884            &diff_path,
1885            "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+UNIQUE_DIFF_MARKER\n",
1886        )
1887        .unwrap();
1888
1889        let commit = CommitInfo {
1890            hash: format!("{:0>40}", 0),
1891            author: "Test <test@test.com>".to_string(),
1892            date: chrono::Utc::now().fixed_offset(),
1893            original_message: "feat: UNIQUE_COMMIT_SUBJECT_MARKER".to_string(),
1894            in_main_branches: vec![],
1895            analysis: CommitAnalysis {
1896                detected_type: "feat".to_string(),
1897                detected_scope: String::new(),
1898                proposed_message: "feat: t".to_string(),
1899                file_changes: FileChanges {
1900                    total_files: 1,
1901                    files_added: 0,
1902                    files_deleted: 0,
1903                    file_list: vec![],
1904                },
1905                diff_summary: String::new(),
1906                diff_file: diff_path.to_string_lossy().to_string(),
1907                file_diffs: Vec::new(),
1908            },
1909        };
1910        let repo_view = sample_repo_view(vec![commit], None);
1911        let context = CommitContext::new();
1912        let mut cmd = fresh_cmd();
1913        cmd.from_commits = true;
1914
1915        let yaml = "title: feat: x\ndescription: y\n".to_string();
1916        let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1917        let prompt_handle = mock.prompt_handle();
1918        let client = ClaudeClient::new(Box::new(mock));
1919
1920        run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1921            .await
1922            .unwrap();
1923
1924        let prompts = prompt_handle.prompts();
1925        assert_eq!(prompts.len(), 1, "expected one AI call");
1926        let (_, user_prompt) = &prompts[0];
1927        assert!(
1928            user_prompt.contains("UNIQUE_COMMIT_SUBJECT_MARKER"),
1929            "commit subject should be in the prompt"
1930        );
1931        assert!(
1932            !user_prompt.contains("UNIQUE_DIFF_MARKER"),
1933            "diff content must NOT appear in the prompt when from_commits is set"
1934        );
1935        assert!(
1936            !user_prompt.contains("diff --git"),
1937            "diff hunks must NOT appear in the prompt when from_commits is set"
1938        );
1939    }
1940
1941    #[test]
1942    fn create_pr_outcome_clone_and_debug() {
1943        let outcome = CreatePrOutcome {
1944            title: "t".to_string(),
1945            description: "d".to_string(),
1946            pr_yaml: "y".to_string(),
1947        };
1948        let cloned = outcome.clone();
1949        assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
1950    }
1951
1952    /// Builds a temp repo with `origin/main`, a feature branch one commit
1953    /// ahead, and a distinctive `.github/pull_request_template.md`. Returns the
1954    /// temp dir so callers can drive `generate_repository_view` against it.
1955    fn init_repo_with_remote_and_template(template_marker: &str) -> tempfile::TempDir {
1956        use git2::{Repository, Signature};
1957        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1958        std::fs::create_dir_all(&tmp_root).unwrap();
1959        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1960        let repo_path = temp_dir.path();
1961        let repo = Repository::init(repo_path).unwrap();
1962        {
1963            let mut config = repo.config().unwrap();
1964            config.set_str("user.name", "Test").unwrap();
1965            config.set_str("user.email", "test@example.com").unwrap();
1966            config.set_str("init.defaultBranch", "main").unwrap();
1967        }
1968        repo.set_head("refs/heads/main").unwrap();
1969
1970        let signature = Signature::now("Test", "test@example.com").unwrap();
1971
1972        // Base commit on main.
1973        std::fs::write(repo_path.join("f.txt"), "base").unwrap();
1974        let mut idx = repo.index().unwrap();
1975        idx.add_path(std::path::Path::new("f.txt")).unwrap();
1976        idx.write().unwrap();
1977        let tree_id = idx.write_tree().unwrap();
1978        let tree = repo.find_tree(tree_id).unwrap();
1979        let base_oid = repo
1980            .commit(
1981                Some("HEAD"),
1982                &signature,
1983                &signature,
1984                "base: init",
1985                &tree,
1986                &[],
1987            )
1988            .unwrap();
1989
1990        // A non-github remote so detection never shells out to gh.
1991        repo.remote("origin", "https://example.com/test/repo.git")
1992            .unwrap();
1993        // origin/main tracking ref at the base commit.
1994        repo.reference(
1995            "refs/remotes/origin/main",
1996            base_oid,
1997            true,
1998            "set origin/main",
1999        )
2000        .unwrap();
2001
2002        // Feature branch one commit ahead of origin/main.
2003        let base_commit = repo.find_commit(base_oid).unwrap();
2004        repo.branch("feature/test", &base_commit, true).unwrap();
2005        repo.set_head("refs/heads/feature/test").unwrap();
2006        std::fs::write(repo_path.join("f.txt"), "feature").unwrap();
2007        let mut idx = repo.index().unwrap();
2008        idx.add_path(std::path::Path::new("f.txt")).unwrap();
2009        idx.write().unwrap();
2010        let tree_id = idx.write_tree().unwrap();
2011        let tree = repo.find_tree(tree_id).unwrap();
2012        repo.commit(
2013            Some("HEAD"),
2014            &signature,
2015            &signature,
2016            "feat: feature work",
2017            &tree,
2018            &[&base_commit],
2019        )
2020        .unwrap();
2021
2022        // Distinctive PR template inside the injected repo's .github/.
2023        let github_dir = repo_path.join(".github");
2024        std::fs::create_dir_all(&github_dir).unwrap();
2025        std::fs::write(github_dir.join("pull_request_template.md"), template_marker).unwrap();
2026
2027        temp_dir
2028    }
2029
2030    /// "No silent mix" anchoring guard: `generate_repository_view` resolves the
2031    /// PR template, branch, and commits from the INJECTED repo root, not the
2032    /// process CWD (the omni-dev checkout, which ships its own
2033    /// `.github/pull_request_template.md`). We leave the process CWD untouched
2034    /// and assert the returned view reflects the injected repo's distinctive
2035    /// template and feature branch.
2036    #[test]
2037    fn generate_repository_view_anchors_to_injected_repo() {
2038        let marker = "## INJECTED_PR_TEMPLATE_MARKER_42";
2039        let temp_dir = init_repo_with_remote_and_template(marker);
2040        let cmd = fresh_cmd();
2041
2042        let repo_view = cmd.generate_repository_view(temp_dir.path()).unwrap();
2043
2044        // PR template came from the injected repo, not the ambient CWD.
2045        assert_eq!(
2046            repo_view.pr_template.as_deref(),
2047            Some(marker),
2048            "PR template must be read from the injected repo root"
2049        );
2050        // Branch + commits reflect the injected repo's feature branch.
2051        assert_eq!(
2052            repo_view.branch_info.as_ref().map(|b| b.branch.as_str()),
2053            Some("feature/test")
2054        );
2055        assert_eq!(
2056            repo_view.commits.len(),
2057            1,
2058            "exactly the one commit ahead of origin/main"
2059        );
2060        assert!(repo_view.commits[0]
2061            .original_message
2062            .contains("feature work"));
2063    }
2064}
2065
2066#[cfg(test)]
2067mod tests {
2068    use super::*;
2069
2070    // --- parse_bool_string ---
2071
2072    #[test]
2073    fn parse_bool_true_variants() {
2074        assert_eq!(parse_bool_string("true"), Some(true));
2075        assert_eq!(parse_bool_string("1"), Some(true));
2076        assert_eq!(parse_bool_string("yes"), Some(true));
2077    }
2078
2079    #[test]
2080    fn parse_bool_false_variants() {
2081        assert_eq!(parse_bool_string("false"), Some(false));
2082        assert_eq!(parse_bool_string("0"), Some(false));
2083        assert_eq!(parse_bool_string("no"), Some(false));
2084    }
2085
2086    #[test]
2087    fn parse_bool_invalid() {
2088        assert_eq!(parse_bool_string("maybe"), None);
2089        assert_eq!(parse_bool_string(""), None);
2090    }
2091
2092    #[test]
2093    fn parse_bool_case_insensitive() {
2094        assert_eq!(parse_bool_string("TRUE"), Some(true));
2095        assert_eq!(parse_bool_string("Yes"), Some(true));
2096        assert_eq!(parse_bool_string("FALSE"), Some(false));
2097        assert_eq!(parse_bool_string("No"), Some(false));
2098    }
2099
2100    // --- is_breaking_change ---
2101
2102    #[test]
2103    fn breaking_change_type_contains() {
2104        assert!(is_breaking_change("BREAKING", "normal message"));
2105    }
2106
2107    #[test]
2108    fn breaking_change_message_contains() {
2109        assert!(is_breaking_change("feat", "BREAKING CHANGE: removed API"));
2110    }
2111
2112    #[test]
2113    fn breaking_change_none() {
2114        assert!(!is_breaking_change("feat", "add new feature"));
2115    }
2116
2117    // --- check_checkbox ---
2118
2119    #[test]
2120    fn check_checkbox_found() {
2121        let mut desc = "- [ ] New feature\n- [ ] Bug fix".to_string();
2122        check_checkbox(&mut desc, "- [ ] New feature");
2123        assert!(desc.contains("- [x] New feature"));
2124        assert!(desc.contains("- [ ] Bug fix"));
2125    }
2126
2127    #[test]
2128    fn check_checkbox_not_found() {
2129        let mut desc = "- [ ] Bug fix".to_string();
2130        let original = desc.clone();
2131        check_checkbox(&mut desc, "- [ ] New feature");
2132        assert_eq!(desc, original);
2133    }
2134
2135    // --- format_scopes_section ---
2136
2137    #[test]
2138    fn scopes_section_single() {
2139        let scopes = vec!["cli".to_string()];
2140        assert_eq!(
2141            format_scopes_section(&scopes),
2142            "**Affected areas:** cli\n\n"
2143        );
2144    }
2145
2146    #[test]
2147    fn scopes_section_multiple() {
2148        let scopes = vec!["cli".to_string(), "git".to_string()];
2149        let result = format_scopes_section(&scopes);
2150        assert!(result.contains("cli"));
2151        assert!(result.contains("git"));
2152        assert!(result.starts_with("**Affected areas:**"));
2153    }
2154
2155    #[test]
2156    fn scopes_section_empty() {
2157        assert_eq!(format_scopes_section(&[]), "");
2158    }
2159
2160    // --- format_commit_list ---
2161
2162    #[test]
2163    fn commit_list_formatting() {
2164        let entries = vec![
2165            ("abc12345", "feat: add feature"),
2166            ("def67890", "fix: resolve bug"),
2167        ];
2168        let result = format_commit_list(&entries);
2169        assert!(result.contains("### Commits in this PR:"));
2170        assert!(result.contains("- `abc12345` feat: add feature"));
2171        assert!(result.contains("- `def67890` fix: resolve bug"));
2172    }
2173
2174    // --- clean_branch_name ---
2175
2176    #[test]
2177    fn clean_branch_simple() {
2178        assert_eq!(clean_branch_name("feat/add-login"), "feat add login");
2179    }
2180
2181    #[test]
2182    fn clean_branch_underscores() {
2183        assert_eq!(clean_branch_name("user_name/fix_bug"), "user name fix bug");
2184    }
2185
2186    // --- extract_first_line ---
2187
2188    #[test]
2189    fn first_line_multiline() {
2190        assert_eq!(extract_first_line("first\nsecond\nthird"), "first");
2191    }
2192
2193    #[test]
2194    fn first_line_single() {
2195        assert_eq!(extract_first_line("only line"), "only line");
2196    }
2197
2198    #[test]
2199    fn first_line_empty() {
2200        assert_eq!(extract_first_line(""), "");
2201    }
2202
2203    // --- format_draft_status ---
2204
2205    #[test]
2206    fn draft_status_true() {
2207        let (icon, text) = format_draft_status(true);
2208        assert_eq!(text, "draft");
2209        assert!(!icon.is_empty());
2210    }
2211
2212    #[test]
2213    fn draft_status_false() {
2214        let (icon, text) = format_draft_status(false);
2215        assert_eq!(text, "ready for review");
2216        assert!(!icon.is_empty());
2217    }
2218
2219    // --- determine_push_action ---
2220
2221    #[test]
2222    fn push_action_skip_when_no_push() {
2223        assert_eq!(determine_push_action(true, false), PushAction::Skip);
2224        assert_eq!(determine_push_action(true, true), PushAction::Skip);
2225    }
2226
2227    #[test]
2228    fn push_action_sync_existing_branch() {
2229        assert_eq!(determine_push_action(false, true), PushAction::SyncExisting);
2230    }
2231
2232    #[test]
2233    fn push_action_push_new_branch() {
2234        assert_eq!(determine_push_action(false, false), PushAction::PushNew);
2235    }
2236}