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