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