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