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