Skip to main content

omni_dev/cli/git/
create_pr.rs

1//! Create PR command — AI-powered pull request creation.
2
3use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::{debug, error};
6
7use super::info::InfoCommand;
8
9/// Create PR command options.
10#[derive(Parser)]
11pub struct CreatePrCommand {
12    /// Base branch for the PR to be merged into (defaults to main/master).
13    #[arg(long, value_name = "BRANCH")]
14    pub base: Option<String>,
15
16    /// Claude API model to use (if not specified, uses settings or default).
17    #[arg(long)]
18    pub model: Option<String>,
19
20    /// Skips confirmation prompt and creates PR automatically.
21    #[arg(long)]
22    pub auto_apply: bool,
23
24    /// Saves generated PR details to file without creating PR.
25    #[arg(long, value_name = "FILE")]
26    pub save_only: Option<String>,
27
28    /// Creates PR as ready for review (overrides default).
29    #[arg(long, conflicts_with = "draft")]
30    pub ready: bool,
31
32    /// Creates PR as draft (overrides default).
33    #[arg(long, conflicts_with = "ready")]
34    pub draft: bool,
35
36    /// Path to custom context directory (defaults to .omni-dev/).
37    #[arg(long)]
38    pub context_dir: Option<std::path::PathBuf>,
39
40    /// Skip pushing the branch to remote before creating the PR.
41    #[arg(long)]
42    pub no_push: bool,
43}
44
45/// PR action choices.
46#[derive(Debug, PartialEq)]
47enum PrAction {
48    CreateNew,
49    UpdateExisting,
50    Cancel,
51}
52
53/// AI-generated PR content with structured fields.
54#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
55#[schemars(deny_unknown_fields)]
56pub struct PrContent {
57    /// Concise PR title (ideally 50-80 characters).
58    pub title: String,
59    /// Full PR description in markdown format.
60    pub description: String,
61}
62
63impl CreatePrCommand {
64    /// Determines if the PR should be created as draft.
65    ///
66    /// Priority order:
67    /// 1. --ready flag (not draft)
68    /// 2. --draft flag (draft)
69    /// 3. OMNI_DEV_DEFAULT_DRAFT_PR env/config setting
70    /// 4. Hard-coded default (draft)
71    fn should_create_as_draft(&self) -> bool {
72        use crate::utils::settings::get_env_var;
73
74        // Explicit flags take precedence
75        if self.ready {
76            return false;
77        }
78        if self.draft {
79            return true;
80        }
81
82        // Check configuration setting
83        get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
84            .ok()
85            .and_then(|val| parse_bool_string(&val))
86            .unwrap_or(true) // Default to draft if not configured
87    }
88
89    /// Executes the create PR command.
90    pub async fn execute(self) -> Result<()> {
91        // Preflight check: validate all prerequisites before any processing
92        // This catches missing credentials/tools early before wasting time
93        let ai_info = crate::utils::check_pr_command_prerequisites(self.model.as_deref())?;
94        println!(
95            "✓ {} credentials verified (model: {})",
96            ai_info.provider, ai_info.model
97        );
98        println!("✓ GitHub CLI verified");
99
100        println!("🔄 Starting pull request creation process...");
101
102        // 1. Generate repository view (reuse InfoCommand logic)
103        let repo_view = self.generate_repository_view()?;
104
105        // 2. Validate branch state (always needed)
106        self.validate_branch_state(&repo_view)?;
107
108        // 3. Show guidance files status early (before AI processing)
109        use crate::claude::context::ProjectDiscovery;
110        let repo_root = std::path::PathBuf::from(".");
111        let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
112        let discovery = ProjectDiscovery::new(repo_root, context_dir);
113        let project_context = discovery.discover().unwrap_or_default();
114        self.show_guidance_files_status(&project_context)?;
115
116        // 4. Show AI model configuration before generation
117        let claude_client =
118            crate::claude::create_default_claude_client(self.model.clone(), None).await?;
119        self.show_model_info_from_client(&claude_client)?;
120
121        // 5. Show branch analysis and commit information
122        self.show_commit_range_info(&repo_view)?;
123
124        // 6. Show context analysis (quick collection for display only)
125        let context = {
126            use crate::claude::context::{BranchAnalyzer, FileAnalyzer, WorkPatternAnalyzer};
127            use crate::data::context::CommitContext;
128            let mut context = CommitContext::new();
129            context.project = project_context;
130
131            // Quick analysis for display
132            if let Some(branch_info) = &repo_view.branch_info {
133                context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
134            }
135
136            if !repo_view.commits.is_empty() {
137                context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
138                context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
139            }
140            context
141        };
142        self.show_context_summary(&context)?;
143
144        // 7. Generate AI-powered PR content (title + description)
145        debug!("About to generate PR content from AI");
146        let (pr_content, _claude_client) = self
147            .generate_pr_content_with_client_internal(&repo_view, claude_client)
148            .await?;
149
150        // 8. Show detailed context information (like twiddle command)
151        self.show_context_information(&repo_view).await?;
152        debug!(
153            generated_title = %pr_content.title,
154            generated_description_length = pr_content.description.len(),
155            generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
156            "Generated PR content from AI"
157        );
158
159        // 5. Handle different output modes
160        if let Some(save_path) = self.save_only {
161            let pr_yaml = crate::data::to_yaml(&pr_content)
162                .context("Failed to serialize PR content to YAML")?;
163            std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
164            println!("💾 PR details saved to: {save_path}");
165            return Ok(());
166        }
167
168        // 6. Create temporary file for PR details
169        debug!("About to serialize PR content to YAML");
170        let temp_dir = tempfile::tempdir()?;
171        let pr_file = temp_dir.path().join("pr-details.yaml");
172
173        debug!(
174            pre_serialize_title = %pr_content.title,
175            pre_serialize_description_length = pr_content.description.len(),
176            pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
177            "About to serialize PR content with to_yaml"
178        );
179
180        let pr_yaml =
181            crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
182
183        debug!(
184            file_path = %pr_file.display(),
185            yaml_content_length = pr_yaml.len(),
186            yaml_content = %pr_yaml,
187            original_title = %pr_content.title,
188            original_description_length = pr_content.description.len(),
189            "Writing PR details to temporary YAML file"
190        );
191
192        std::fs::write(&pr_file, &pr_yaml)?;
193
194        // 7. Handle PR details file - show path and get user choice
195        let pr_action = if self.auto_apply {
196            // For auto-apply, default to update if PR exists, otherwise create new
197            if repo_view
198                .branch_prs
199                .as_ref()
200                .is_some_and(|prs| !prs.is_empty())
201            {
202                PrAction::UpdateExisting
203            } else {
204                PrAction::CreateNew
205            }
206        } else {
207            self.handle_pr_file(&pr_file, &repo_view)?
208        };
209
210        if pr_action == PrAction::Cancel {
211            println!("❌ PR operation cancelled by user");
212            return Ok(());
213        }
214
215        // 8. Create or update PR (re-read from file to capture any user edits)
216        let final_pr_yaml =
217            std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
218
219        debug!(
220            yaml_length = final_pr_yaml.len(),
221            yaml_content = %final_pr_yaml,
222            "Read PR details YAML from file"
223        );
224
225        let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
226            .context("Failed to parse PR details YAML. Please check the file format.")?;
227
228        debug!(
229            title = %final_pr_content.title,
230            description_length = final_pr_content.description.len(),
231            description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
232            "Parsed PR content from YAML"
233        );
234
235        // Determine draft status
236        let is_draft = self.should_create_as_draft();
237
238        match pr_action {
239            PrAction::CreateNew => {
240                self.create_github_pr(
241                    &repo_view,
242                    &final_pr_content.title,
243                    &final_pr_content.description,
244                    is_draft,
245                    self.base.as_deref(),
246                )?;
247                println!("✅ Pull request created successfully!");
248            }
249            PrAction::UpdateExisting => {
250                self.update_github_pr(
251                    &repo_view,
252                    &final_pr_content.title,
253                    &final_pr_content.description,
254                    self.base.as_deref(),
255                )?;
256                println!("✅ Pull request updated successfully!");
257            }
258            PrAction::Cancel => unreachable!(), // Already handled above
259        }
260
261        Ok(())
262    }
263
264    /// Generates the repository view (reuses InfoCommand logic).
265    fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
266        use crate::data::{
267            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
268            WorkingDirectoryInfo,
269        };
270        use crate::git::{GitRepository, RemoteInfo};
271        use crate::utils::ai_scratch;
272
273        // Open git repository
274        let repo = GitRepository::open()
275            .context("Failed to open git repository. Make sure you're in a git repository.")?;
276
277        // Get current branch name
278        let current_branch = repo.get_current_branch().context(
279            "Failed to get current branch. Make sure you're not in detached HEAD state.",
280        )?;
281
282        // Get remote information to determine proper remote and main branch
283        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
284
285        // Find the primary remote (prefer origin, fallback to first available)
286        let primary_remote = remotes
287            .iter()
288            .find(|r| r.name == "origin")
289            .or_else(|| remotes.first())
290            .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
291
292        // Determine base branch (with remote prefix)
293        let base_branch = if let Some(branch) = self.base.as_ref() {
294            // User specified base branch - try to resolve it
295            // First, check if it's already a valid remote ref (e.g., "origin/main")
296            let remote_ref = format!("refs/remotes/{branch}");
297            if repo.repository().find_reference(&remote_ref).is_ok() {
298                branch.clone()
299            } else {
300                // Try prepending the primary remote name (e.g., "main" -> "origin/main")
301                let with_remote = format!("{}/{}", primary_remote.name, branch);
302                let remote_ref = format!("refs/remotes/{with_remote}");
303                if repo.repository().find_reference(&remote_ref).is_ok() {
304                    with_remote
305                } else {
306                    anyhow::bail!(
307                        "Remote branch '{branch}' does not exist (also tried '{with_remote}')"
308                    );
309                }
310            }
311        } else {
312            // Auto-detect using the primary remote's main branch
313            let main_branch = &primary_remote.main_branch;
314            if main_branch == "unknown" {
315                let remote_name = &primary_remote.name;
316                anyhow::bail!("Could not determine main branch for remote '{remote_name}'");
317            }
318
319            let remote_main = format!("{}/{}", primary_remote.name, main_branch);
320
321            // Validate that the remote main branch exists
322            let remote_ref = format!("refs/remotes/{remote_main}");
323            if repo.repository().find_reference(&remote_ref).is_err() {
324                anyhow::bail!(
325                    "Remote main branch '{remote_main}' does not exist. Try running 'git fetch' first."
326                );
327            }
328
329            remote_main
330        };
331
332        // Calculate commit range: [remote_base]..HEAD
333        let commit_range = format!("{base_branch}..HEAD");
334
335        // Get working directory status
336        let wd_status = repo.get_working_directory_status()?;
337        let working_directory = WorkingDirectoryInfo {
338            clean: wd_status.clean,
339            untracked_changes: wd_status
340                .untracked_changes
341                .into_iter()
342                .map(|fs| FileStatusInfo {
343                    status: fs.status,
344                    file: fs.file,
345                })
346                .collect(),
347        };
348
349        // Get remote information
350        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
351
352        // Parse commit range and get commits
353        let commits = repo.get_commits_in_range(&commit_range)?;
354
355        // Check for PR template
356        let pr_template_result = InfoCommand::read_pr_template().ok();
357        let (pr_template, pr_template_location) = match pr_template_result {
358            Some((content, location)) => (Some(content), Some(location)),
359            None => (None, None),
360        };
361
362        // Get PRs for current branch
363        let branch_prs = InfoCommand::get_branch_prs(&current_branch)
364            .ok()
365            .filter(|prs| !prs.is_empty());
366
367        // Create version information
368        let versions = Some(VersionInfo {
369            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
370        });
371
372        // Get AI scratch directory
373        let ai_scratch_path =
374            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
375        let ai_info = AiInfo {
376            scratch: ai_scratch_path.to_string_lossy().to_string(),
377        };
378
379        // Build repository view with branch info
380        let mut repo_view = RepositoryView {
381            versions,
382            explanation: FieldExplanation::default(),
383            working_directory,
384            remotes,
385            ai: ai_info,
386            branch_info: Some(BranchInfo {
387                branch: current_branch,
388            }),
389            pr_template,
390            pr_template_location,
391            branch_prs,
392            commits,
393        };
394
395        // Update field presence based on actual data
396        repo_view.update_field_presence();
397
398        Ok(repo_view)
399    }
400
401    /// Validates the branch state for PR creation.
402    fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
403        // Check if working directory is clean
404        if !repo_view.working_directory.clean {
405            anyhow::bail!(
406                "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
407            );
408        }
409
410        // Check if there are any untracked changes
411        if !repo_view.working_directory.untracked_changes.is_empty() {
412            let file_list: Vec<&str> = repo_view
413                .working_directory
414                .untracked_changes
415                .iter()
416                .map(|f| f.file.as_str())
417                .collect();
418            anyhow::bail!(
419                "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
420                file_list.join(", ")
421            );
422        }
423
424        // Check if commits exist
425        if repo_view.commits.is_empty() {
426            anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
427        }
428
429        // Check if PR already exists for this branch
430        if let Some(existing_prs) = &repo_view.branch_prs {
431            if !existing_prs.is_empty() {
432                let pr_info: Vec<String> = existing_prs
433                    .iter()
434                    .map(|pr| format!("#{} ({})", pr.number, pr.state))
435                    .collect();
436
437                println!(
438                    "📋 Existing PR(s) found for this branch: {}",
439                    pr_info.join(", ")
440                );
441                // Don't bail - we'll handle this in the main flow
442            }
443        }
444
445        Ok(())
446    }
447
448    /// Shows detailed context information (similar to twiddle command).
449    async fn show_context_information(
450        &self,
451        _repo_view: &crate::data::RepositoryView,
452    ) -> Result<()> {
453        // Note: commit range info and context summary are now shown earlier
454        // This method is kept for potential future detailed information
455        // that should be shown after AI generation
456
457        Ok(())
458    }
459
460    /// Shows commit range and count information.
461    fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
462        // Recreate the base branch determination logic from generate_repository_view
463        let base_branch = match self.base.as_ref() {
464            Some(branch) => {
465                // User specified base branch
466                // Get the primary remote name from repo_view
467                let primary_remote_name = repo_view
468                    .remotes
469                    .iter()
470                    .find(|r| r.name == "origin")
471                    .or_else(|| repo_view.remotes.first())
472                    .map_or("origin", |r| r.name.as_str());
473                // Check if already has remote prefix
474                if branch.starts_with(&format!("{primary_remote_name}/")) {
475                    branch.clone()
476                } else {
477                    format!("{primary_remote_name}/{branch}")
478                }
479            }
480            None => {
481                // Auto-detected base branch from remotes
482                repo_view
483                    .remotes
484                    .iter()
485                    .find(|r| r.name == "origin")
486                    .or_else(|| repo_view.remotes.first())
487                    .map_or_else(
488                        || "unknown".to_string(),
489                        |r| format!("{}/{}", r.name, r.main_branch),
490                    )
491            }
492        };
493
494        let commit_range = format!("{base_branch}..HEAD");
495        let commit_count = repo_view.commits.len();
496
497        // Get current branch name
498        let current_branch = repo_view
499            .branch_info
500            .as_ref()
501            .map_or("unknown", |bi| bi.branch.as_str());
502
503        println!("📊 Branch Analysis:");
504        println!("   🌿 Current branch: {current_branch}");
505        println!("   📏 Commit range: {commit_range}");
506        println!("   📝 Commits found: {commit_count} commits");
507        println!();
508
509        Ok(())
510    }
511
512    /// Collects contextual information for enhanced PR generation (adapted from twiddle).
513    async fn collect_context(
514        &self,
515        repo_view: &crate::data::RepositoryView,
516    ) -> Result<crate::data::context::CommitContext> {
517        use crate::claude::context::{
518            BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
519        };
520        use crate::data::context::{CommitContext, ProjectContext};
521        use crate::git::GitRepository;
522
523        let mut context = CommitContext::new();
524
525        // 1. Discover project context
526        let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
527
528        // ProjectDiscovery takes repo root and context directory
529        let repo_root = std::path::PathBuf::from(".");
530        let discovery = ProjectDiscovery::new(repo_root, context_dir);
531        match discovery.discover() {
532            Ok(project_context) => {
533                context.project = project_context;
534            }
535            Err(_e) => {
536                context.project = ProjectContext::default();
537            }
538        }
539
540        // 2. Analyze current branch
541        let repo = GitRepository::open()?;
542        let current_branch = repo
543            .get_current_branch()
544            .unwrap_or_else(|_| "HEAD".to_string());
545        context.branch = BranchAnalyzer::analyze(&current_branch).unwrap_or_default();
546
547        // 3. Analyze commit range patterns
548        if !repo_view.commits.is_empty() {
549            context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
550        }
551
552        // 3.5. Analyze file-level context
553        if !repo_view.commits.is_empty() {
554            context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
555        }
556
557        Ok(context)
558    }
559
560    /// Shows guidance files status (adapted from twiddle).
561    fn show_guidance_files_status(
562        &self,
563        project_context: &crate::data::context::ProjectContext,
564    ) -> Result<()> {
565        use crate::claude::context::{
566            config_source_label, resolve_context_dir_with_source, ConfigSourceLabel,
567        };
568
569        let (context_dir, dir_source) =
570            resolve_context_dir_with_source(self.context_dir.as_deref());
571
572        println!("📋 Project guidance files status:");
573        println!("   📂 Config dir: {} ({dir_source})", context_dir.display());
574
575        // Check PR guidelines (for PR commands)
576        let pr_guidelines_source = if project_context.pr_guidelines.is_some() {
577            match config_source_label(&context_dir, "pr-guidelines.md") {
578                ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
579                label => format!("✅ {label}"),
580            }
581        } else {
582            "❌ None found".to_string()
583        };
584        println!("   🔀 PR guidelines: {pr_guidelines_source}");
585
586        // Check scopes
587        let scopes_count = project_context.valid_scopes.len();
588        let scopes_source = if scopes_count > 0 {
589            match config_source_label(&context_dir, "scopes.yaml") {
590                ConfigSourceLabel::NotFound => {
591                    format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
592                }
593                label => format!("✅ {label} ({scopes_count} scopes)"),
594            }
595        } else {
596            "❌ None found".to_string()
597        };
598        println!("   🎯 Valid scopes: {scopes_source}");
599
600        // Check PR template
601        let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
602        let pr_template_status = if pr_template_path.exists() {
603            format!("✅ Project: {}", pr_template_path.display())
604        } else {
605            "❌ None found".to_string()
606        };
607        println!("   📋 PR template: {pr_template_status}");
608
609        println!();
610        Ok(())
611    }
612
613    /// Shows the context summary (adapted from twiddle).
614    fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
615        use crate::data::context::{VerbosityLevel, WorkPattern};
616
617        println!("🔍 Context Analysis:");
618
619        // Project context
620        if !context.project.valid_scopes.is_empty() {
621            let scope_names: Vec<&str> = context
622                .project
623                .valid_scopes
624                .iter()
625                .map(|s| s.name.as_str())
626                .collect();
627            println!("   📁 Valid scopes: {}", scope_names.join(", "));
628        }
629
630        // Branch context
631        if context.branch.is_feature_branch {
632            println!(
633                "   🌿 Branch: {} ({})",
634                context.branch.description, context.branch.work_type
635            );
636            if let Some(ref ticket) = context.branch.ticket_id {
637                println!("   🎫 Ticket: {ticket}");
638            }
639        }
640
641        // Work pattern
642        match context.range.work_pattern {
643            WorkPattern::Sequential => println!("   🔄 Pattern: Sequential development"),
644            WorkPattern::Refactoring => println!("   🧹 Pattern: Refactoring work"),
645            WorkPattern::BugHunt => println!("   🐛 Pattern: Bug investigation"),
646            WorkPattern::Documentation => println!("   📖 Pattern: Documentation updates"),
647            WorkPattern::Configuration => println!("   ⚙️  Pattern: Configuration changes"),
648            WorkPattern::Unknown => {}
649        }
650
651        // File analysis
652        if let Some(label) = super::formatting::format_file_analysis(&context.files) {
653            println!("   {label}");
654        }
655
656        // Verbosity level
657        match context.suggested_verbosity() {
658            VerbosityLevel::Comprehensive => {
659                println!("   📝 Detail level: Comprehensive (significant changes detected)");
660            }
661            VerbosityLevel::Detailed => println!("   📝 Detail level: Detailed"),
662            VerbosityLevel::Concise => println!("   📝 Detail level: Concise"),
663        }
664
665        println!();
666        Ok(())
667    }
668
669    /// Generates PR content with a pre-created client (internal method that does not show model info).
670    async fn generate_pr_content_with_client_internal(
671        &self,
672        repo_view: &crate::data::RepositoryView,
673        claude_client: crate::claude::client::ClaudeClient,
674    ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
675        use tracing::debug;
676
677        // Get PR template (either from repo or default)
678        let pr_template = match &repo_view.pr_template {
679            Some(template) => template.clone(),
680            None => self.get_default_pr_template(),
681        };
682
683        debug!(
684            pr_template_length = pr_template.len(),
685            pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
686            "Using PR template for generation"
687        );
688
689        println!("🤖 Generating AI-powered PR description...");
690
691        // Collect project context for PR guidelines
692        debug!("Collecting context for PR generation");
693        let context = self.collect_context(repo_view).await?;
694        debug!("Context collection completed");
695
696        // Generate AI-powered PR content with context
697        debug!("About to call Claude AI for PR content generation");
698        match claude_client
699            .generate_pr_content_with_context(repo_view, &pr_template, &context)
700            .await
701        {
702            Ok(pr_content) => {
703                debug!(
704                    ai_generated_title = %pr_content.title,
705                    ai_generated_description_length = pr_content.description.len(),
706                    ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
707                    "AI successfully generated PR content"
708                );
709                Ok((pr_content, claude_client))
710            }
711            Err(e) => {
712                debug!(error = %e, "AI PR generation failed, falling back to basic description");
713                // Fallback to basic description with commit analysis (silently)
714                let mut description = pr_template;
715                self.enhance_description_with_commits(&mut description, repo_view)?;
716
717                // Generate fallback title from commits
718                let title = self.generate_title_from_commits(repo_view);
719
720                debug!(
721                    fallback_title = %title,
722                    fallback_description_length = description.len(),
723                    "Created fallback PR content"
724                );
725
726                Ok((PrContent { title, description }, claude_client))
727            }
728        }
729    }
730
731    /// Returns the default PR template when none exists in the repository.
732    fn get_default_pr_template(&self) -> String {
733        r#"# Pull Request
734
735## Description
736<!-- Provide a brief description of what this PR does -->
737
738## Type of Change
739<!-- Mark the relevant option with an "x" -->
740- [ ] Bug fix (non-breaking change which fixes an issue)
741- [ ] New feature (non-breaking change which adds functionality)
742- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
743- [ ] Documentation update
744- [ ] Refactoring (no functional changes)
745- [ ] Performance improvement
746- [ ] Test coverage improvement
747
748## Changes Made
749<!-- List the specific changes made in this PR -->
750- 
751- 
752- 
753
754## Testing
755- [ ] All existing tests pass
756- [ ] New tests added for new functionality
757- [ ] Manual testing performed
758
759## Additional Notes
760<!-- Add any additional notes for reviewers -->
761"#.to_string()
762    }
763
764    /// Enhances the PR description with commit analysis.
765    fn enhance_description_with_commits(
766        &self,
767        description: &mut String,
768        repo_view: &crate::data::RepositoryView,
769    ) -> Result<()> {
770        if repo_view.commits.is_empty() {
771            return Ok(());
772        }
773
774        // Add commit summary section
775        description.push_str("\n---\n");
776        description.push_str("## 📝 Commit Summary\n");
777        description
778            .push_str("*This section was automatically generated based on commit analysis*\n\n");
779
780        // Analyze commit types and scopes
781        let mut types_found = std::collections::HashSet::new();
782        let mut scopes_found = std::collections::HashSet::new();
783        let mut has_breaking_changes = false;
784
785        for commit in &repo_view.commits {
786            let detected_type = &commit.analysis.detected_type;
787            types_found.insert(detected_type.clone());
788            if is_breaking_change(detected_type, &commit.original_message) {
789                has_breaking_changes = true;
790            }
791
792            let detected_scope = &commit.analysis.detected_scope;
793            if !detected_scope.is_empty() {
794                scopes_found.insert(detected_scope.clone());
795            }
796        }
797
798        // Update type checkboxes based on detected types
799        if types_found.contains("feat") {
800            check_checkbox(description, "- [ ] New feature");
801        }
802        if types_found.contains("fix") {
803            check_checkbox(description, "- [ ] Bug fix");
804        }
805        if types_found.contains("docs") {
806            check_checkbox(description, "- [ ] Documentation update");
807        }
808        if types_found.contains("refactor") {
809            check_checkbox(description, "- [ ] Refactoring");
810        }
811        if has_breaking_changes {
812            check_checkbox(description, "- [ ] Breaking change");
813        }
814
815        // Add detected scopes
816        let scopes_list: Vec<_> = scopes_found.into_iter().collect();
817        let scopes_section = format_scopes_section(&scopes_list);
818        if !scopes_section.is_empty() {
819            description.push_str(&scopes_section);
820        }
821
822        // Add commit list
823        let commit_entries: Vec<(&str, &str)> = repo_view
824            .commits
825            .iter()
826            .map(|c| {
827                let short = &c.hash[..crate::git::SHORT_HASH_LEN];
828                let first = extract_first_line(&c.original_message);
829                (short, first)
830            })
831            .collect();
832        description.push_str(&format_commit_list(&commit_entries));
833
834        // Add file change summary
835        let total_files: usize = repo_view
836            .commits
837            .iter()
838            .map(|c| c.analysis.file_changes.total_files)
839            .sum();
840
841        if total_files > 0 {
842            description.push_str(&format!("\n**Files changed:** {total_files} files\n"));
843        }
844
845        Ok(())
846    }
847
848    /// Handles the PR description file by showing the path and getting the user choice.
849    fn handle_pr_file(
850        &self,
851        pr_file: &std::path::Path,
852        repo_view: &crate::data::RepositoryView,
853    ) -> Result<PrAction> {
854        use std::io::{self, Write};
855
856        println!("\n📝 PR details generated.");
857        println!("💾 Details saved to: {}", pr_file.display());
858
859        // Show draft status
860        let is_draft = self.should_create_as_draft();
861        let (status_icon, status_text) = format_draft_status(is_draft);
862        println!("{status_icon} PR will be created as: {status_text}");
863        println!();
864
865        // Check if there are existing PRs and show different options
866        let has_existing_prs = repo_view
867            .branch_prs
868            .as_ref()
869            .is_some_and(|prs| !prs.is_empty());
870
871        loop {
872            if has_existing_prs {
873                print!("❓ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
874            } else {
875                print!(
876                    "❓ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
877                );
878            }
879            io::stdout().flush()?;
880
881            let mut input = String::new();
882            io::stdin().read_line(&mut input)?;
883
884            match input.trim().to_lowercase().as_str() {
885                "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
886                "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
887                "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
888                "s" | "show" => {
889                    self.show_pr_file(pr_file)?;
890                    println!();
891                }
892                "e" | "edit" => {
893                    self.edit_pr_file(pr_file)?;
894                    println!();
895                }
896                "q" | "quit" => return Ok(PrAction::Cancel),
897                _ => {
898                    if has_existing_prs {
899                        println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
900                    } else {
901                        println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
902                    }
903                }
904            }
905        }
906    }
907
908    /// Shows the contents of the PR details file.
909    fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
910        use std::fs;
911
912        println!("\n📄 PR details file contents:");
913        println!("─────────────────────────────");
914
915        let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
916        println!("{contents}");
917        println!("─────────────────────────────");
918
919        Ok(())
920    }
921
922    /// Opens the PR details file in an external editor.
923    fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
924        use std::env;
925        use std::io::{self, Write};
926        use std::process::Command;
927
928        // Try to get editor from environment variables
929        let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
930            e
931        } else {
932            // Prompt user for editor if neither environment variable is set
933            println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
934            print!("Please enter the command to use as your editor: ");
935            io::stdout().flush().context("Failed to flush stdout")?;
936
937            let mut input = String::new();
938            io::stdin()
939                .read_line(&mut input)
940                .context("Failed to read user input")?;
941            input.trim().to_string()
942        };
943
944        if editor.is_empty() {
945            println!("❌ No editor specified. Returning to menu.");
946            return Ok(());
947        }
948
949        println!("📝 Opening PR details file in editor: {editor}");
950
951        let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
952
953        let mut command = Command::new(editor_cmd);
954        command.args(args);
955        command.arg(pr_file.to_string_lossy().as_ref());
956
957        match command.status() {
958            Ok(status) => {
959                if status.success() {
960                    println!("✅ Editor session completed.");
961                } else {
962                    println!(
963                        "⚠️  Editor exited with non-zero status: {:?}",
964                        status.code()
965                    );
966                }
967            }
968            Err(e) => {
969                println!("❌ Failed to execute editor '{editor}': {e}");
970                println!("   Please check that the editor command is correct and available in your PATH.");
971            }
972        }
973
974        Ok(())
975    }
976
977    /// Generates a concise title from commit analysis (fallback).
978    fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
979        if repo_view.commits.is_empty() {
980            return "Pull Request".to_string();
981        }
982
983        // For single commit, use its first line
984        if repo_view.commits.len() == 1 {
985            let first = extract_first_line(&repo_view.commits[0].original_message);
986            let trimmed = first.trim();
987            return if trimmed.is_empty() {
988                "Pull Request".to_string()
989            } else {
990                trimmed.to_string()
991            };
992        }
993
994        // For multiple commits, generate from branch name
995        let branch_name = repo_view
996            .branch_info
997            .as_ref()
998            .map_or("feature", |bi| bi.branch.as_str());
999
1000        format!("feat: {}", clean_branch_name(branch_name))
1001    }
1002
1003    /// Creates a new GitHub PR using gh CLI.
1004    fn create_github_pr(
1005        &self,
1006        repo_view: &crate::data::RepositoryView,
1007        title: &str,
1008        description: &str,
1009        is_draft: bool,
1010        new_base: Option<&str>,
1011    ) -> Result<()> {
1012        use std::process::Command;
1013
1014        // Get branch name
1015        let branch_name = repo_view
1016            .branch_info
1017            .as_ref()
1018            .map(|bi| &bi.branch)
1019            .context("Branch info not available")?;
1020
1021        let pr_status = if is_draft {
1022            "draft"
1023        } else {
1024            "ready for review"
1025        };
1026        println!("🚀 Creating pull request ({pr_status})...");
1027        println!("   📋 Title: {title}");
1028        println!("   🌿 Branch: {branch_name}");
1029        if let Some(base) = new_base {
1030            println!("   🎯 Base: {base}");
1031        }
1032
1033        // Push branch to remote unless --no-push was specified
1034        let push_action = if self.no_push {
1035            determine_push_action(true, false)
1036        } else {
1037            debug!("Opening git repository to check branch status");
1038            let git_repo =
1039                crate::git::GitRepository::open().context("Failed to open git repository")?;
1040
1041            debug!(
1042                "Checking if branch '{}' exists on remote 'origin'",
1043                branch_name
1044            );
1045            let branch_on_remote = git_repo.branch_exists_on_remote(branch_name, "origin")?;
1046            let action = determine_push_action(false, branch_on_remote);
1047
1048            debug!("Push action for branch '{}': {:?}", branch_name, action);
1049            println!("📤 Pushing branch to remote...");
1050            git_repo
1051                .push_branch(branch_name, "origin")
1052                .context("Failed to push branch to remote")?;
1053
1054            action
1055        };
1056
1057        if push_action == PushAction::Skip {
1058            debug!("Skipping push (--no-push flag set)");
1059        }
1060
1061        // Create PR using gh CLI with explicit head branch
1062        debug!("Creating PR with gh CLI - title: '{}'", title);
1063        debug!("PR description length: {} characters", description.len());
1064        debug!("PR draft status: {}", is_draft);
1065        if let Some(base) = new_base {
1066            debug!("PR base branch: {}", base);
1067        }
1068
1069        let mut args = vec![
1070            "pr",
1071            "create",
1072            "--head",
1073            branch_name,
1074            "--title",
1075            title,
1076            "--body",
1077            description,
1078        ];
1079
1080        if let Some(base) = new_base {
1081            args.push("--base");
1082            args.push(base);
1083        }
1084
1085        if is_draft {
1086            args.push("--draft");
1087        }
1088
1089        let pr_result = Command::new("gh")
1090            .args(&args)
1091            .output()
1092            .context("Failed to create pull request")?;
1093
1094        if pr_result.status.success() {
1095            let pr_url = String::from_utf8_lossy(&pr_result.stdout);
1096            let pr_url = pr_url.trim();
1097            debug!("PR created successfully with URL: {}", pr_url);
1098            println!("🎉 Pull request created: {pr_url}");
1099        } else {
1100            let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1101            error!("gh CLI failed to create PR: {}", error_msg);
1102            anyhow::bail!("Failed to create pull request: {error_msg}");
1103        }
1104
1105        Ok(())
1106    }
1107
1108    /// Updates an existing GitHub PR using gh CLI.
1109    fn update_github_pr(
1110        &self,
1111        repo_view: &crate::data::RepositoryView,
1112        title: &str,
1113        description: &str,
1114        new_base: Option<&str>,
1115    ) -> Result<()> {
1116        use std::io::{self, Write};
1117        use std::process::Command;
1118
1119        // Get the first existing PR (assuming we're updating the most recent one)
1120        let existing_pr = repo_view
1121            .branch_prs
1122            .as_ref()
1123            .and_then(|prs| prs.first())
1124            .context("No existing PR found to update")?;
1125
1126        let pr_number = existing_pr.number;
1127        let current_base = &existing_pr.base;
1128
1129        println!("🚀 Updating pull request #{pr_number}...");
1130        println!("   📋 Title: {title}");
1131
1132        // Check if base branch should be changed
1133        let change_base = if let Some(base) = new_base {
1134            if !current_base.is_empty() && current_base != base {
1135                print!("   🎯 Current base: {current_base} → New base: {base}. Change? [y/N]: ");
1136                io::stdout().flush()?;
1137
1138                let mut input = String::new();
1139                io::stdin().read_line(&mut input)?;
1140                let response = input.trim().to_lowercase();
1141                response == "y" || response == "yes"
1142            } else {
1143                false
1144            }
1145        } else {
1146            false
1147        };
1148
1149        debug!(
1150            pr_number = pr_number,
1151            title = %title,
1152            description_length = description.len(),
1153            description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1154            change_base = change_base,
1155            "Updating GitHub PR with title and description"
1156        );
1157
1158        // Update PR using gh CLI
1159        let pr_number_str = pr_number.to_string();
1160        let mut gh_args = vec![
1161            "pr",
1162            "edit",
1163            &pr_number_str,
1164            "--title",
1165            title,
1166            "--body",
1167            description,
1168        ];
1169
1170        if change_base {
1171            if let Some(base) = new_base {
1172                gh_args.push("--base");
1173                gh_args.push(base);
1174            }
1175        }
1176
1177        debug!(
1178            args = ?gh_args,
1179            "Executing gh command to update PR"
1180        );
1181
1182        let pr_result = Command::new("gh")
1183            .args(&gh_args)
1184            .output()
1185            .context("Failed to update pull request")?;
1186
1187        if pr_result.status.success() {
1188            // Get the PR URL using the existing PR data
1189            println!("🎉 Pull request updated: {}", existing_pr.url);
1190            if change_base {
1191                if let Some(base) = new_base {
1192                    println!("   🎯 Base branch changed to: {base}");
1193                }
1194            }
1195        } else {
1196            let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1197            anyhow::bail!("Failed to update pull request: {error_msg}");
1198        }
1199
1200        Ok(())
1201    }
1202
1203    /// Shows model information from the actual AI client.
1204    fn show_model_info_from_client(
1205        &self,
1206        client: &crate::claude::client::ClaudeClient,
1207    ) -> Result<()> {
1208        use crate::claude::model_config::get_model_registry;
1209
1210        println!("🤖 AI Model Configuration:");
1211
1212        // Get actual metadata from the client
1213        let metadata = client.get_ai_client_metadata();
1214        let registry = get_model_registry();
1215
1216        if let Some(spec) = registry.get_model_spec(&metadata.model) {
1217            // Highlight the API identifier portion in yellow
1218            if metadata.model != spec.api_identifier {
1219                println!(
1220                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
1221                    metadata.model, spec.api_identifier
1222                );
1223            } else {
1224                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1225            }
1226
1227            println!("   🏷️  Provider: {}", spec.provider);
1228            println!("   📊 Generation: {}", spec.generation);
1229            println!("   ⭐ Tier: {} ({})", spec.tier, {
1230                if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1231                    &tier_info.description
1232                } else {
1233                    "No description available"
1234                }
1235            });
1236            println!("   📤 Max output tokens: {}", metadata.max_response_length);
1237            println!("   📥 Input context: {}", metadata.max_context_length);
1238
1239            if let Some((ref key, ref value)) = metadata.active_beta {
1240                println!("   🔬 Beta header: {key}: {value}");
1241            }
1242
1243            if spec.legacy {
1244                println!("   ⚠️  Legacy model (consider upgrading to newer version)");
1245            }
1246        } else {
1247            // Fallback to client metadata if not in registry
1248            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1249            println!("   🏷️  Provider: {}", metadata.provider);
1250            println!("   ⚠️  Model not found in registry, using client metadata:");
1251            println!("   📤 Max output tokens: {}", metadata.max_response_length);
1252            println!("   📥 Input context: {}", metadata.max_context_length);
1253        }
1254
1255        println!();
1256        Ok(())
1257    }
1258}
1259
1260// --- Extracted pure functions ---
1261
1262/// Describes what push action should be taken before PR creation.
1263#[derive(Debug, PartialEq)]
1264enum PushAction {
1265    /// Skip pushing entirely (user passed `--no-push`).
1266    Skip,
1267    /// Push to sync with an existing remote branch.
1268    SyncExisting,
1269    /// Push a new branch to remote.
1270    PushNew,
1271}
1272
1273/// Determines what push action to take based on the `--no-push` flag and remote branch state.
1274fn determine_push_action(no_push: bool, branch_on_remote: bool) -> PushAction {
1275    if no_push {
1276        PushAction::Skip
1277    } else if branch_on_remote {
1278        PushAction::SyncExisting
1279    } else {
1280        PushAction::PushNew
1281    }
1282}
1283
1284/// Parses a boolean-like string value.
1285///
1286/// Accepts "true"/"1"/"yes" as `true` and "false"/"0"/"no" as `false`.
1287/// Returns `None` for unrecognized values.
1288fn parse_bool_string(val: &str) -> Option<bool> {
1289    match val.to_lowercase().as_str() {
1290        "true" | "1" | "yes" => Some(true),
1291        "false" | "0" | "no" => Some(false),
1292        _ => None,
1293    }
1294}
1295
1296/// Returns whether a commit represents a breaking change.
1297fn is_breaking_change(detected_type: &str, original_message: &str) -> bool {
1298    detected_type.contains("BREAKING") || original_message.contains("BREAKING CHANGE")
1299}
1300
1301/// Checks a markdown checkbox in the description by replacing `- [ ]` with `- [x]`.
1302fn check_checkbox(description: &mut String, search_text: &str) {
1303    if let Some(pos) = description.find(search_text) {
1304        description.replace_range(pos..pos + 5, "- [x]");
1305    }
1306}
1307
1308/// Formats a list of scopes as a markdown "Affected areas" section.
1309///
1310/// Returns an empty string if the list is empty.
1311fn format_scopes_section(scopes: &[String]) -> String {
1312    if scopes.is_empty() {
1313        return String::new();
1314    }
1315    format!("**Affected areas:** {}\n\n", scopes.join(", "))
1316}
1317
1318/// Formats commit entries as a markdown list with short hashes.
1319fn format_commit_list(entries: &[(&str, &str)]) -> String {
1320    let mut output = String::from("### Commits in this PR:\n");
1321    for (hash, message) in entries {
1322        output.push_str(&format!("- `{hash}` {message}\n"));
1323    }
1324    output
1325}
1326
1327/// Replaces path separators (`/`, `-`, `_`) in a branch name with spaces.
1328fn clean_branch_name(branch: &str) -> String {
1329    branch.replace(['/', '-', '_'], " ")
1330}
1331
1332/// Returns the first line of a text block, trimmed.
1333fn extract_first_line(text: &str) -> &str {
1334    text.lines().next().unwrap_or("").trim()
1335}
1336
1337/// Returns an (icon, label) pair for a PR's draft status.
1338fn format_draft_status(is_draft: bool) -> (&'static str, &'static str) {
1339    if is_draft {
1340        ("\u{1f4cb}", "draft")
1341    } else {
1342        ("\u{2705}", "ready for review")
1343    }
1344}
1345
1346/// Structured output from [`run_create_pr`] for programmatic consumers (MCP).
1347#[derive(Debug, Clone)]
1348pub struct CreatePrOutcome {
1349    /// Title as produced by the AI (or the fallback heuristic).
1350    pub title: String,
1351    /// Description body as produced by the AI (or the fallback heuristic).
1352    pub description: String,
1353    /// YAML serialisation of the [`PrContent`].
1354    pub pr_yaml: String,
1355}
1356
1357/// Non-interactive core for `omni-dev git branch create pr`.
1358///
1359/// Generates PR title + description via the AI but does NOT push the branch
1360/// or call `gh pr create`. The MCP boundary should expose the proposed PR
1361/// content so the assistant can decide what to do with it; actually pushing
1362/// a branch or creating a PR is out of scope for a single tool call. This
1363/// function must produce no stdout output — the MCP server uses stdout for
1364/// the JSON-RPC protocol.
1365pub async fn run_create_pr(
1366    model: Option<String>,
1367    base_branch: Option<&str>,
1368    repo_path: Option<&std::path::Path>,
1369) -> Result<CreatePrOutcome> {
1370    let _cwd_guard = match repo_path {
1371        Some(p) => Some(super::CwdGuard::enter(p).await?),
1372        None => None,
1373    };
1374
1375    crate::utils::check_pr_command_prerequisites(model.as_deref())?;
1376
1377    let cmd = CreatePrCommand {
1378        base: base_branch.map(str::to_string),
1379        model: model.clone(),
1380        auto_apply: true,
1381        save_only: None,
1382        ready: false,
1383        draft: false,
1384        context_dir: None,
1385        no_push: true,
1386    };
1387
1388    let repo_view = cmd.generate_repository_view()?;
1389    let context = cmd.collect_context(&repo_view).await?;
1390    let claude_client = crate::claude::create_default_claude_client(model, None).await?;
1391    run_create_pr_with_client(&cmd, &repo_view, &context, &claude_client).await
1392}
1393
1394/// Non-credential-gated inner core of [`run_create_pr`] for unit tests.
1395///
1396/// Takes an already-built [`CreatePrCommand`], [`crate::data::RepositoryView`],
1397/// and [`crate::data::context::CommitContext`] so tests can construct those
1398/// in-memory (avoiding the git-remote setup `generate_repository_view`
1399/// requires). Callers are responsible for preflight, CWD, and context
1400/// assembly.
1401pub(crate) async fn run_create_pr_with_client(
1402    cmd: &CreatePrCommand,
1403    repo_view: &crate::data::RepositoryView,
1404    context: &crate::data::context::CommitContext,
1405    claude_client: &crate::claude::client::ClaudeClient,
1406) -> Result<CreatePrOutcome> {
1407    let pr_template = match &repo_view.pr_template {
1408        Some(template) => template.clone(),
1409        None => cmd.get_default_pr_template(),
1410    };
1411
1412    let pr_content = match claude_client
1413        .generate_pr_content_with_context(repo_view, &pr_template, context)
1414        .await
1415    {
1416        Ok(content) => content,
1417        Err(_e) => {
1418            let mut description = pr_template;
1419            cmd.enhance_description_with_commits(&mut description, repo_view)?;
1420            let title = cmd.generate_title_from_commits(repo_view);
1421            PrContent { title, description }
1422        }
1423    };
1424
1425    let pr_yaml = crate::data::to_yaml(&pr_content).context("Failed to serialise PrContent")?;
1426
1427    Ok(CreatePrOutcome {
1428        title: pr_content.title,
1429        description: pr_content.description,
1430        pr_yaml,
1431    })
1432}
1433
1434#[cfg(test)]
1435#[allow(clippy::unwrap_used, clippy::expect_used)]
1436mod run_create_pr_tests {
1437    use super::*;
1438    use crate::claude::client::ClaudeClient;
1439    use crate::claude::test_utils::ConfigurableMockAiClient;
1440    use crate::data::context::CommitContext;
1441    use crate::data::{
1442        AiInfo, BranchInfo, FieldExplanation, RepositoryView, VersionInfo, WorkingDirectoryInfo,
1443    };
1444    use crate::git::commit::FileChanges;
1445    use crate::git::{CommitAnalysis, CommitInfo};
1446
1447    #[tokio::test]
1448    async fn run_create_pr_invalid_repo_path_errors_before_ai() {
1449        let err = run_create_pr(
1450            None,
1451            None,
1452            Some(std::path::Path::new("/no/such/path/exists")),
1453        )
1454        .await
1455        .unwrap_err();
1456        let msg = format!("{err:#}");
1457        assert!(
1458            msg.to_lowercase().contains("set_current_dir")
1459                || msg.to_lowercase().contains("no such")
1460                || msg.to_lowercase().contains("directory"),
1461            "expected cwd-related error, got: {msg}"
1462        );
1463    }
1464
1465    fn fresh_cmd() -> CreatePrCommand {
1466        CreatePrCommand {
1467            base: None,
1468            model: None,
1469            auto_apply: true,
1470            save_only: None,
1471            ready: false,
1472            draft: false,
1473            context_dir: None,
1474            no_push: true,
1475        }
1476    }
1477
1478    fn sample_commit(hash: &str, message: &str) -> (CommitInfo, tempfile::NamedTempFile) {
1479        let tmp = tempfile::NamedTempFile::new().unwrap();
1480        let commit = CommitInfo {
1481            hash: hash.to_string(),
1482            author: "Test <test@test.com>".to_string(),
1483            date: chrono::Utc::now().fixed_offset(),
1484            original_message: message.to_string(),
1485            in_main_branches: vec![],
1486            analysis: CommitAnalysis {
1487                detected_type: "feat".to_string(),
1488                detected_scope: String::new(),
1489                proposed_message: message.to_string(),
1490                file_changes: FileChanges {
1491                    total_files: 0,
1492                    files_added: 0,
1493                    files_deleted: 0,
1494                    file_list: vec![],
1495                },
1496                diff_summary: String::new(),
1497                diff_file: tmp.path().to_string_lossy().to_string(),
1498                file_diffs: Vec::new(),
1499            },
1500        };
1501        (commit, tmp)
1502    }
1503
1504    fn sample_repo_view(commits: Vec<CommitInfo>, pr_template: Option<String>) -> RepositoryView {
1505        RepositoryView {
1506            versions: Some(VersionInfo {
1507                omni_dev: "0.0.0".to_string(),
1508            }),
1509            explanation: FieldExplanation::default(),
1510            working_directory: WorkingDirectoryInfo {
1511                clean: true,
1512                untracked_changes: vec![],
1513            },
1514            remotes: vec![],
1515            ai: AiInfo {
1516                scratch: String::new(),
1517            },
1518            branch_info: Some(BranchInfo {
1519                branch: "feature/test".to_string(),
1520            }),
1521            pr_template,
1522            pr_template_location: None,
1523            branch_prs: None,
1524            commits,
1525        }
1526    }
1527
1528    #[tokio::test]
1529    async fn run_create_pr_with_client_ai_success_returns_content() {
1530        let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1531        let repo_view = sample_repo_view(vec![c1], None);
1532        let context = CommitContext::new();
1533        let cmd = fresh_cmd();
1534
1535        let yaml = "title: My PR\ndescription: |\n  Body text\n".to_string();
1536        let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1537        let client = ClaudeClient::new(Box::new(mock));
1538
1539        let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1540            .await
1541            .unwrap();
1542        assert_eq!(outcome.title, "My PR");
1543        assert!(outcome.description.contains("Body text"));
1544        assert!(outcome.pr_yaml.contains("title:"));
1545    }
1546
1547    #[tokio::test]
1548    async fn run_create_pr_with_client_ai_failure_falls_back_to_commit_summary() {
1549        let (c1, _tmp) = sample_commit("abcdef00", "feat: single commit subject");
1550        let repo_view = sample_repo_view(vec![c1], None);
1551        let context = CommitContext::new();
1552        let cmd = fresh_cmd();
1553
1554        // Empty mock → AI call exhausts retries → fallback path triggered.
1555        let mock = ConfigurableMockAiClient::new(vec![]);
1556        let client = ClaudeClient::new(Box::new(mock));
1557
1558        let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1559            .await
1560            .unwrap();
1561        assert!(
1562            outcome.title.contains("feat: single commit subject")
1563                || outcome.title.contains("Pull Request")
1564                || outcome.title.contains("feature/test"),
1565            "fallback title unexpected: {}",
1566            outcome.title
1567        );
1568    }
1569
1570    #[tokio::test]
1571    async fn run_create_pr_with_client_uses_repo_template_when_present() {
1572        let (c1, _tmp) = sample_commit("abcdef00", "feat: x");
1573        let repo_view = sample_repo_view(vec![c1], Some("# Custom template\n".to_string()));
1574        let context = CommitContext::new();
1575        let cmd = fresh_cmd();
1576
1577        // AI fails → fallback uses the repo template as the description base.
1578        let mock = ConfigurableMockAiClient::new(vec![]);
1579        let client = ClaudeClient::new(Box::new(mock));
1580
1581        let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1582            .await
1583            .unwrap();
1584        assert!(
1585            outcome.description.contains("# Custom template"),
1586            "fallback description should include repo template: {}",
1587            outcome.description
1588        );
1589    }
1590
1591    #[test]
1592    fn create_pr_outcome_clone_and_debug() {
1593        let outcome = CreatePrOutcome {
1594            title: "t".to_string(),
1595            description: "d".to_string(),
1596            pr_yaml: "y".to_string(),
1597        };
1598        let cloned = outcome.clone();
1599        assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
1600    }
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605    use super::*;
1606
1607    // --- parse_bool_string ---
1608
1609    #[test]
1610    fn parse_bool_true_variants() {
1611        assert_eq!(parse_bool_string("true"), Some(true));
1612        assert_eq!(parse_bool_string("1"), Some(true));
1613        assert_eq!(parse_bool_string("yes"), Some(true));
1614    }
1615
1616    #[test]
1617    fn parse_bool_false_variants() {
1618        assert_eq!(parse_bool_string("false"), Some(false));
1619        assert_eq!(parse_bool_string("0"), Some(false));
1620        assert_eq!(parse_bool_string("no"), Some(false));
1621    }
1622
1623    #[test]
1624    fn parse_bool_invalid() {
1625        assert_eq!(parse_bool_string("maybe"), None);
1626        assert_eq!(parse_bool_string(""), None);
1627    }
1628
1629    #[test]
1630    fn parse_bool_case_insensitive() {
1631        assert_eq!(parse_bool_string("TRUE"), Some(true));
1632        assert_eq!(parse_bool_string("Yes"), Some(true));
1633        assert_eq!(parse_bool_string("FALSE"), Some(false));
1634        assert_eq!(parse_bool_string("No"), Some(false));
1635    }
1636
1637    // --- is_breaking_change ---
1638
1639    #[test]
1640    fn breaking_change_type_contains() {
1641        assert!(is_breaking_change("BREAKING", "normal message"));
1642    }
1643
1644    #[test]
1645    fn breaking_change_message_contains() {
1646        assert!(is_breaking_change("feat", "BREAKING CHANGE: removed API"));
1647    }
1648
1649    #[test]
1650    fn breaking_change_none() {
1651        assert!(!is_breaking_change("feat", "add new feature"));
1652    }
1653
1654    // --- check_checkbox ---
1655
1656    #[test]
1657    fn check_checkbox_found() {
1658        let mut desc = "- [ ] New feature\n- [ ] Bug fix".to_string();
1659        check_checkbox(&mut desc, "- [ ] New feature");
1660        assert!(desc.contains("- [x] New feature"));
1661        assert!(desc.contains("- [ ] Bug fix"));
1662    }
1663
1664    #[test]
1665    fn check_checkbox_not_found() {
1666        let mut desc = "- [ ] Bug fix".to_string();
1667        let original = desc.clone();
1668        check_checkbox(&mut desc, "- [ ] New feature");
1669        assert_eq!(desc, original);
1670    }
1671
1672    // --- format_scopes_section ---
1673
1674    #[test]
1675    fn scopes_section_single() {
1676        let scopes = vec!["cli".to_string()];
1677        assert_eq!(
1678            format_scopes_section(&scopes),
1679            "**Affected areas:** cli\n\n"
1680        );
1681    }
1682
1683    #[test]
1684    fn scopes_section_multiple() {
1685        let scopes = vec!["cli".to_string(), "git".to_string()];
1686        let result = format_scopes_section(&scopes);
1687        assert!(result.contains("cli"));
1688        assert!(result.contains("git"));
1689        assert!(result.starts_with("**Affected areas:**"));
1690    }
1691
1692    #[test]
1693    fn scopes_section_empty() {
1694        assert_eq!(format_scopes_section(&[]), "");
1695    }
1696
1697    // --- format_commit_list ---
1698
1699    #[test]
1700    fn commit_list_formatting() {
1701        let entries = vec![
1702            ("abc12345", "feat: add feature"),
1703            ("def67890", "fix: resolve bug"),
1704        ];
1705        let result = format_commit_list(&entries);
1706        assert!(result.contains("### Commits in this PR:"));
1707        assert!(result.contains("- `abc12345` feat: add feature"));
1708        assert!(result.contains("- `def67890` fix: resolve bug"));
1709    }
1710
1711    // --- clean_branch_name ---
1712
1713    #[test]
1714    fn clean_branch_simple() {
1715        assert_eq!(clean_branch_name("feat/add-login"), "feat add login");
1716    }
1717
1718    #[test]
1719    fn clean_branch_underscores() {
1720        assert_eq!(clean_branch_name("user_name/fix_bug"), "user name fix bug");
1721    }
1722
1723    // --- extract_first_line ---
1724
1725    #[test]
1726    fn first_line_multiline() {
1727        assert_eq!(extract_first_line("first\nsecond\nthird"), "first");
1728    }
1729
1730    #[test]
1731    fn first_line_single() {
1732        assert_eq!(extract_first_line("only line"), "only line");
1733    }
1734
1735    #[test]
1736    fn first_line_empty() {
1737        assert_eq!(extract_first_line(""), "");
1738    }
1739
1740    // --- format_draft_status ---
1741
1742    #[test]
1743    fn draft_status_true() {
1744        let (icon, text) = format_draft_status(true);
1745        assert_eq!(text, "draft");
1746        assert!(!icon.is_empty());
1747    }
1748
1749    #[test]
1750    fn draft_status_false() {
1751        let (icon, text) = format_draft_status(false);
1752        assert_eq!(text, "ready for review");
1753        assert!(!icon.is_empty());
1754    }
1755
1756    // --- determine_push_action ---
1757
1758    #[test]
1759    fn push_action_skip_when_no_push() {
1760        assert_eq!(determine_push_action(true, false), PushAction::Skip);
1761        assert_eq!(determine_push_action(true, true), PushAction::Skip);
1762    }
1763
1764    #[test]
1765    fn push_action_sync_existing_branch() {
1766        assert_eq!(determine_push_action(false, true), PushAction::SyncExisting);
1767    }
1768
1769    #[test]
1770    fn push_action_push_new_branch() {
1771        assert_eq!(determine_push_action(false, false), PushAction::PushNew);
1772    }
1773}