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