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
37/// PR action choices.
38#[derive(Debug, PartialEq)]
39enum PrAction {
40    CreateNew,
41    UpdateExisting,
42    Cancel,
43}
44
45/// AI-generated PR content with structured fields.
46#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
47pub struct PrContent {
48    /// Concise PR title (ideally 50-80 characters).
49    pub title: String,
50    /// Full PR description in markdown format.
51    pub description: String,
52}
53
54impl CreatePrCommand {
55    /// Determines if the PR should be created as draft.
56    ///
57    /// Priority order:
58    /// 1. --ready flag (not draft)
59    /// 2. --draft flag (draft)
60    /// 3. OMNI_DEV_DEFAULT_DRAFT_PR env/config setting
61    /// 4. Hard-coded default (draft)
62    fn should_create_as_draft(&self) -> bool {
63        use crate::utils::settings::get_env_var;
64
65        // Explicit flags take precedence
66        if self.ready {
67            return false;
68        }
69        if self.draft {
70            return true;
71        }
72
73        // Check configuration setting
74        get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
75            .ok()
76            .and_then(|val| match val.to_lowercase().as_str() {
77                "true" | "1" | "yes" => Some(true),
78                "false" | "0" | "no" => Some(false),
79                _ => None,
80            })
81            .unwrap_or(true) // Default to draft if not configured
82    }
83
84    /// Executes the create PR command.
85    pub async fn execute(self) -> Result<()> {
86        // Preflight check: validate all prerequisites before any processing
87        // This catches missing credentials/tools early before wasting time
88        let ai_info = crate::utils::check_pr_command_prerequisites(self.model.as_deref())?;
89        println!(
90            "āœ“ {} credentials verified (model: {})",
91            ai_info.provider, ai_info.model
92        );
93        println!("āœ“ GitHub CLI verified");
94
95        println!("šŸ”„ Starting pull request creation process...");
96
97        // 1. Generate repository view (reuse InfoCommand logic)
98        let repo_view = self.generate_repository_view()?;
99
100        // 2. Validate branch state (always needed)
101        self.validate_branch_state(&repo_view)?;
102
103        // 3. Show guidance files status early (before AI processing)
104        use crate::claude::context::ProjectDiscovery;
105        let repo_root = std::path::PathBuf::from(".");
106        let context_dir = std::path::PathBuf::from(".omni-dev");
107        let discovery = ProjectDiscovery::new(repo_root, context_dir);
108        let project_context = discovery.discover().unwrap_or_default();
109        self.show_guidance_files_status(&project_context)?;
110
111        // 4. Show AI model configuration before generation
112        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), None)?;
113        self.show_model_info_from_client(&claude_client)?;
114
115        // 5. Show branch analysis and commit information
116        self.show_commit_range_info(&repo_view)?;
117
118        // 6. Show context analysis (quick collection for display only)
119        let context = {
120            use crate::claude::context::{BranchAnalyzer, WorkPatternAnalyzer};
121            use crate::data::context::CommitContext;
122            let mut context = CommitContext::new();
123            context.project = project_context;
124
125            // Quick analysis for display
126            if let Some(branch_info) = &repo_view.branch_info {
127                context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
128            }
129
130            if !repo_view.commits.is_empty() {
131                context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
132            }
133            context
134        };
135        self.show_context_summary(&context)?;
136
137        // 7. Generate AI-powered PR content (title + description)
138        debug!("About to generate PR content from AI");
139        let (pr_content, _claude_client) = self
140            .generate_pr_content_with_client_internal(&repo_view, claude_client)
141            .await?;
142
143        // 8. Show detailed context information (like twiddle command)
144        self.show_context_information(&repo_view).await?;
145        debug!(
146            generated_title = %pr_content.title,
147            generated_description_length = pr_content.description.len(),
148            generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
149            "Generated PR content from AI"
150        );
151
152        // 5. Handle different output modes
153        if let Some(save_path) = self.save_only {
154            let pr_yaml = crate::data::to_yaml(&pr_content)
155                .context("Failed to serialize PR content to YAML")?;
156            std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
157            println!("šŸ’¾ PR details saved to: {}", save_path);
158            return Ok(());
159        }
160
161        // 6. Create temporary file for PR details
162        debug!("About to serialize PR content to YAML");
163        let temp_dir = tempfile::tempdir()?;
164        let pr_file = temp_dir.path().join("pr-details.yaml");
165
166        debug!(
167            pre_serialize_title = %pr_content.title,
168            pre_serialize_description_length = pr_content.description.len(),
169            pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
170            "About to serialize PR content with to_yaml"
171        );
172
173        let pr_yaml =
174            crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
175
176        debug!(
177            file_path = %pr_file.display(),
178            yaml_content_length = pr_yaml.len(),
179            yaml_content = %pr_yaml,
180            original_title = %pr_content.title,
181            original_description_length = pr_content.description.len(),
182            "Writing PR details to temporary YAML file"
183        );
184
185        std::fs::write(&pr_file, &pr_yaml)?;
186
187        // 7. Handle PR details file - show path and get user choice
188        let pr_action = if self.auto_apply {
189            // For auto-apply, default to update if PR exists, otherwise create new
190            if repo_view
191                .branch_prs
192                .as_ref()
193                .is_some_and(|prs| !prs.is_empty())
194            {
195                PrAction::UpdateExisting
196            } else {
197                PrAction::CreateNew
198            }
199        } else {
200            self.handle_pr_file(&pr_file, &repo_view)?
201        };
202
203        if pr_action == PrAction::Cancel {
204            println!("āŒ PR operation cancelled by user");
205            return Ok(());
206        }
207
208        // 8. Create or update PR (re-read from file to capture any user edits)
209        let final_pr_yaml =
210            std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
211
212        debug!(
213            yaml_length = final_pr_yaml.len(),
214            yaml_content = %final_pr_yaml,
215            "Read PR details YAML from file"
216        );
217
218        let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
219            .context("Failed to parse PR details YAML. Please check the file format.")?;
220
221        debug!(
222            title = %final_pr_content.title,
223            description_length = final_pr_content.description.len(),
224            description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
225            "Parsed PR content from YAML"
226        );
227
228        // Determine draft status
229        let is_draft = self.should_create_as_draft();
230
231        match pr_action {
232            PrAction::CreateNew => {
233                self.create_github_pr(
234                    &repo_view,
235                    &final_pr_content.title,
236                    &final_pr_content.description,
237                    is_draft,
238                    self.base.as_deref(),
239                )?;
240                println!("āœ… Pull request created successfully!");
241            }
242            PrAction::UpdateExisting => {
243                self.update_github_pr(
244                    &repo_view,
245                    &final_pr_content.title,
246                    &final_pr_content.description,
247                    self.base.as_deref(),
248                )?;
249                println!("āœ… Pull request updated successfully!");
250            }
251            PrAction::Cancel => unreachable!(), // Already handled above
252        }
253
254        Ok(())
255    }
256
257    /// Generates the repository view (reuses InfoCommand logic).
258    fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
259        use crate::data::{
260            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
261            WorkingDirectoryInfo,
262        };
263        use crate::git::{GitRepository, RemoteInfo};
264        use crate::utils::ai_scratch;
265
266        // Open git repository
267        let repo = GitRepository::open()
268            .context("Failed to open git repository. Make sure you're in a git repository.")?;
269
270        // Get current branch name
271        let current_branch = repo.get_current_branch().context(
272            "Failed to get current branch. Make sure you're not in detached HEAD state.",
273        )?;
274
275        // Get remote information to determine proper remote and main branch
276        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
277
278        // Find the primary remote (prefer origin, fallback to first available)
279        let primary_remote = remotes
280            .iter()
281            .find(|r| r.name == "origin")
282            .or_else(|| remotes.first())
283            .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
284
285        // Determine base branch (with remote prefix)
286        let base_branch = match self.base.as_ref() {
287            Some(branch) => {
288                // User specified base branch - try to resolve it
289                // First, check if it's already a valid remote ref (e.g., "origin/main")
290                let remote_ref = format!("refs/remotes/{}", branch);
291                if repo.repository().find_reference(&remote_ref).is_ok() {
292                    branch.clone()
293                } else {
294                    // Try prepending the primary remote name (e.g., "main" -> "origin/main")
295                    let with_remote = format!("{}/{}", primary_remote.name, branch);
296                    let remote_ref = format!("refs/remotes/{}", with_remote);
297                    if repo.repository().find_reference(&remote_ref).is_ok() {
298                        with_remote
299                    } else {
300                        anyhow::bail!(
301                            "Remote branch '{}' does not exist (also tried '{}')",
302                            branch,
303                            with_remote
304                        );
305                    }
306                }
307            }
308            None => {
309                // Auto-detect using the primary remote's main branch
310                let main_branch = &primary_remote.main_branch;
311                if main_branch == "unknown" {
312                    anyhow::bail!(
313                        "Could not determine main branch for remote '{}'",
314                        primary_remote.name
315                    );
316                }
317
318                let remote_main = format!("{}/{}", primary_remote.name, main_branch);
319
320                // Validate that the remote main branch exists
321                let remote_ref = format!("refs/remotes/{}", remote_main);
322                if repo.repository().find_reference(&remote_ref).is_err() {
323                    anyhow::bail!(
324                        "Remote main branch '{}' does not exist. Try running 'git fetch' first.",
325                        remote_main
326                    );
327                }
328
329                remote_main
330            }
331        };
332
333        // Calculate commit range: [remote_base]..HEAD
334        let commit_range = format!("{}..HEAD", base_branch);
335
336        // Get working directory status
337        let wd_status = repo.get_working_directory_status()?;
338        let working_directory = WorkingDirectoryInfo {
339            clean: wd_status.clean,
340            untracked_changes: wd_status
341                .untracked_changes
342                .into_iter()
343                .map(|fs| FileStatusInfo {
344                    status: fs.status,
345                    file: fs.file,
346                })
347                .collect(),
348        };
349
350        // Get remote information
351        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
352
353        // Parse commit range and get commits
354        let commits = repo.get_commits_in_range(&commit_range)?;
355
356        // Check for PR template
357        let pr_template_result = InfoCommand::read_pr_template().ok();
358        let (pr_template, pr_template_location) = match pr_template_result {
359            Some((content, location)) => (Some(content), Some(location)),
360            None => (None, None),
361        };
362
363        // Get PRs for current branch
364        let branch_prs = InfoCommand::get_branch_prs(&current_branch)
365            .ok()
366            .filter(|prs| !prs.is_empty());
367
368        // Create version information
369        let versions = Some(VersionInfo {
370            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
371        });
372
373        // Get AI scratch directory
374        let ai_scratch_path =
375            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
376        let ai_info = AiInfo {
377            scratch: ai_scratch_path.to_string_lossy().to_string(),
378        };
379
380        // Build repository view with branch info
381        let mut repo_view = RepositoryView {
382            versions,
383            explanation: FieldExplanation::default(),
384            working_directory,
385            remotes,
386            ai: ai_info,
387            branch_info: Some(BranchInfo {
388                branch: current_branch,
389            }),
390            pr_template,
391            pr_template_location,
392            branch_prs,
393            commits,
394        };
395
396        // Update field presence based on actual data
397        repo_view.update_field_presence();
398
399        Ok(repo_view)
400    }
401
402    /// Validates the branch state for PR creation.
403    fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
404        // Check if working directory is clean
405        if !repo_view.working_directory.clean {
406            anyhow::bail!(
407                "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
408            );
409        }
410
411        // Check if there are any untracked changes
412        if !repo_view.working_directory.untracked_changes.is_empty() {
413            let file_list: Vec<&str> = repo_view
414                .working_directory
415                .untracked_changes
416                .iter()
417                .map(|f| f.file.as_str())
418                .collect();
419            anyhow::bail!(
420                "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
421                file_list.join(", ")
422            );
423        }
424
425        // Check if commits exist
426        if repo_view.commits.is_empty() {
427            anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
428        }
429
430        // Check if PR already exists for this branch
431        if let Some(existing_prs) = &repo_view.branch_prs {
432            if !existing_prs.is_empty() {
433                let pr_info: Vec<String> = existing_prs
434                    .iter()
435                    .map(|pr| format!("#{} ({})", pr.number, pr.state))
436                    .collect();
437
438                println!(
439                    "šŸ“‹ Existing PR(s) found for this branch: {}",
440                    pr_info.join(", ")
441                );
442                // Don't bail - we'll handle this in the main flow
443            }
444        }
445
446        Ok(())
447    }
448
449    /// Shows detailed context information (similar to twiddle command).
450    async fn show_context_information(
451        &self,
452        _repo_view: &crate::data::RepositoryView,
453    ) -> Result<()> {
454        // Note: commit range info and context summary are now shown earlier
455        // This method is kept for potential future detailed information
456        // that should be shown after AI generation
457
458        Ok(())
459    }
460
461    /// Shows commit range and count information.
462    fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
463        // Recreate the base branch determination logic from generate_repository_view
464        let base_branch = match self.base.as_ref() {
465            Some(branch) => {
466                // User specified base branch
467                // Get the primary remote name from repo_view
468                let primary_remote_name = repo_view
469                    .remotes
470                    .iter()
471                    .find(|r| r.name == "origin")
472                    .or_else(|| repo_view.remotes.first())
473                    .map(|r| r.name.as_str())
474                    .unwrap_or("origin");
475                // Check if already has remote prefix
476                if branch.starts_with(&format!("{}/", primary_remote_name)) {
477                    branch.clone()
478                } else {
479                    format!("{}/{}", primary_remote_name, branch)
480                }
481            }
482            None => {
483                // Auto-detected base branch from remotes
484                repo_view
485                    .remotes
486                    .iter()
487                    .find(|r| r.name == "origin")
488                    .or_else(|| repo_view.remotes.first())
489                    .map(|r| format!("{}/{}", r.name, r.main_branch))
490                    .unwrap_or_else(|| "unknown".to_string())
491            }
492        };
493
494        let commit_range = format!("{}..HEAD", base_branch);
495        let commit_count = repo_view.commits.len();
496
497        // Get current branch name
498        let current_branch = repo_view
499            .branch_info
500            .as_ref()
501            .map(|bi| bi.branch.as_str())
502            .unwrap_or("unknown");
503
504        println!("šŸ“Š Branch Analysis:");
505        println!("   🌿 Current branch: {}", current_branch);
506        println!("   šŸ“ Commit range: {}", commit_range);
507        println!("   šŸ“ Commits found: {} commits", commit_count);
508        println!();
509
510        Ok(())
511    }
512
513    /// Collects contextual information for enhanced PR generation (adapted from twiddle).
514    async fn collect_context(
515        &self,
516        repo_view: &crate::data::RepositoryView,
517    ) -> Result<crate::data::context::CommitContext> {
518        use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
519        use crate::data::context::CommitContext;
520        use crate::git::GitRepository;
521
522        let mut context = CommitContext::new();
523
524        // 1. Discover project context
525        let context_dir = std::path::PathBuf::from(".omni-dev");
526
527        // ProjectDiscovery takes repo root and context directory
528        let repo_root = std::path::PathBuf::from(".");
529        let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
530        match discovery.discover() {
531            Ok(project_context) => {
532                context.project = project_context;
533            }
534            Err(_e) => {
535                context.project = Default::default();
536            }
537        }
538
539        // 2. Analyze current branch
540        let repo = GitRepository::open()?;
541        let current_branch = repo
542            .get_current_branch()
543            .unwrap_or_else(|_| "HEAD".to_string());
544        context.branch = BranchAnalyzer::analyze(&current_branch).unwrap_or_default();
545
546        // 3. Analyze commit range patterns
547        if !repo_view.commits.is_empty() {
548            context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
549        }
550
551        Ok(context)
552    }
553
554    /// Shows guidance files status (adapted from twiddle).
555    fn show_guidance_files_status(
556        &self,
557        project_context: &crate::data::context::ProjectContext,
558    ) -> Result<()> {
559        let context_dir = std::path::PathBuf::from(".omni-dev");
560
561        println!("šŸ“‹ Project guidance files status:");
562
563        // Check PR guidelines (for PR commands)
564        let pr_guidelines_found = project_context.pr_guidelines.is_some();
565        let pr_guidelines_source = if pr_guidelines_found {
566            let local_path = context_dir.join("local").join("pr-guidelines.md");
567            let project_path = context_dir.join("pr-guidelines.md");
568            let home_path = dirs::home_dir()
569                .map(|h| h.join(".omni-dev").join("pr-guidelines.md"))
570                .unwrap_or_default();
571
572            if local_path.exists() {
573                format!("āœ… Local override: {}", local_path.display())
574            } else if project_path.exists() {
575                format!("āœ… Project: {}", project_path.display())
576            } else if home_path.exists() {
577                format!("āœ… Global: {}", home_path.display())
578            } else {
579                "āœ… (source unknown)".to_string()
580            }
581        } else {
582            "āŒ None found".to_string()
583        };
584        println!("   šŸ”€ PR guidelines: {}", pr_guidelines_source);
585
586        // Check scopes
587        let scopes_count = project_context.valid_scopes.len();
588        let scopes_source = if scopes_count > 0 {
589            let local_path = context_dir.join("local").join("scopes.yaml");
590            let project_path = context_dir.join("scopes.yaml");
591            let home_path = dirs::home_dir()
592                .map(|h| h.join(".omni-dev").join("scopes.yaml"))
593                .unwrap_or_default();
594
595            let source = if local_path.exists() {
596                format!("Local override: {}", local_path.display())
597            } else if project_path.exists() {
598                format!("Project: {}", project_path.display())
599            } else if home_path.exists() {
600                format!("Global: {}", home_path.display())
601            } else {
602                "(source unknown + ecosystem defaults)".to_string()
603            };
604            format!("āœ… {} ({} scopes)", source, scopes_count)
605        } else {
606            "āŒ None found".to_string()
607        };
608        println!("   šŸŽÆ Valid scopes: {}", scopes_source);
609
610        // Check PR template
611        let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
612        let pr_template_status = if pr_template_path.exists() {
613            format!("āœ… Project: {}", pr_template_path.display())
614        } else {
615            "āŒ None found".to_string()
616        };
617        println!("   šŸ“‹ PR template: {}", pr_template_status);
618
619        println!();
620        Ok(())
621    }
622
623    /// Shows the context summary (adapted from twiddle).
624    fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
625        use crate::data::context::{VerbosityLevel, WorkPattern};
626
627        println!("šŸ” Context Analysis:");
628
629        // Project context
630        if !context.project.valid_scopes.is_empty() {
631            let scope_names: Vec<&str> = context
632                .project
633                .valid_scopes
634                .iter()
635                .map(|s| s.name.as_str())
636                .collect();
637            println!("   šŸ“ Valid scopes: {}", scope_names.join(", "));
638        }
639
640        // Branch context
641        if context.branch.is_feature_branch {
642            println!(
643                "   🌿 Branch: {} ({})",
644                context.branch.description, context.branch.work_type
645            );
646            if let Some(ref ticket) = context.branch.ticket_id {
647                println!("   šŸŽ« Ticket: {}", ticket);
648            }
649        }
650
651        // Work pattern
652        match context.range.work_pattern {
653            WorkPattern::Sequential => println!("   šŸ”„ Pattern: Sequential development"),
654            WorkPattern::Refactoring => println!("   🧹 Pattern: Refactoring work"),
655            WorkPattern::BugHunt => println!("   šŸ› Pattern: Bug investigation"),
656            WorkPattern::Documentation => println!("   šŸ“– Pattern: Documentation updates"),
657            WorkPattern::Configuration => println!("   āš™ļø  Pattern: Configuration changes"),
658            WorkPattern::Unknown => {}
659        }
660
661        // Verbosity level
662        match context.suggested_verbosity() {
663            VerbosityLevel::Comprehensive => {
664                println!("   šŸ“ Detail level: Comprehensive (significant changes detected)")
665            }
666            VerbosityLevel::Detailed => println!("   šŸ“ Detail level: Detailed"),
667            VerbosityLevel::Concise => println!("   šŸ“ Detail level: Concise"),
668        }
669
670        println!();
671        Ok(())
672    }
673
674    /// Generates PR content with a pre-created client (internal method that does not show model info).
675    async fn generate_pr_content_with_client_internal(
676        &self,
677        repo_view: &crate::data::RepositoryView,
678        claude_client: crate::claude::client::ClaudeClient,
679    ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
680        use tracing::debug;
681
682        // Get PR template (either from repo or default)
683        let pr_template = match &repo_view.pr_template {
684            Some(template) => template.clone(),
685            None => self.get_default_pr_template(),
686        };
687
688        debug!(
689            pr_template_length = pr_template.len(),
690            pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
691            "Using PR template for generation"
692        );
693
694        println!("šŸ¤– Generating AI-powered PR description...");
695
696        // Collect project context for PR guidelines
697        debug!("Collecting context for PR generation");
698        let context = self.collect_context(repo_view).await?;
699        debug!("Context collection completed");
700
701        // Generate AI-powered PR content with context
702        debug!("About to call Claude AI for PR content generation");
703        match claude_client
704            .generate_pr_content_with_context(repo_view, &pr_template, &context)
705            .await
706        {
707            Ok(pr_content) => {
708                debug!(
709                    ai_generated_title = %pr_content.title,
710                    ai_generated_description_length = pr_content.description.len(),
711                    ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
712                    "AI successfully generated PR content"
713                );
714                Ok((pr_content, claude_client))
715            }
716            Err(e) => {
717                debug!(error = %e, "AI PR generation failed, falling back to basic description");
718                // Fallback to basic description with commit analysis (silently)
719                let mut description = pr_template;
720                self.enhance_description_with_commits(&mut description, repo_view)?;
721
722                // Generate fallback title from commits
723                let title = self.generate_title_from_commits(repo_view);
724
725                debug!(
726                    fallback_title = %title,
727                    fallback_description_length = description.len(),
728                    "Created fallback PR content"
729                );
730
731                Ok((PrContent { title, description }, claude_client))
732            }
733        }
734    }
735
736    /// Returns the default PR template when none exists in the repository.
737    fn get_default_pr_template(&self) -> String {
738        r#"# Pull Request
739
740## Description
741<!-- Provide a brief description of what this PR does -->
742
743## Type of Change
744<!-- Mark the relevant option with an "x" -->
745- [ ] Bug fix (non-breaking change which fixes an issue)
746- [ ] New feature (non-breaking change which adds functionality)
747- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
748- [ ] Documentation update
749- [ ] Refactoring (no functional changes)
750- [ ] Performance improvement
751- [ ] Test coverage improvement
752
753## Changes Made
754<!-- List the specific changes made in this PR -->
755- 
756- 
757- 
758
759## Testing
760- [ ] All existing tests pass
761- [ ] New tests added for new functionality
762- [ ] Manual testing performed
763
764## Additional Notes
765<!-- Add any additional notes for reviewers -->
766"#.to_string()
767    }
768
769    /// Enhances the PR description with commit analysis.
770    fn enhance_description_with_commits(
771        &self,
772        description: &mut String,
773        repo_view: &crate::data::RepositoryView,
774    ) -> Result<()> {
775        if repo_view.commits.is_empty() {
776            return Ok(());
777        }
778
779        // Add commit summary section
780        description.push_str("\n---\n");
781        description.push_str("## šŸ“ Commit Summary\n");
782        description
783            .push_str("*This section was automatically generated based on commit analysis*\n\n");
784
785        // Analyze commit types and scopes
786        let mut types_found = std::collections::HashSet::new();
787        let mut scopes_found = std::collections::HashSet::new();
788        let mut has_breaking_changes = false;
789
790        for commit in &repo_view.commits {
791            let detected_type = &commit.analysis.detected_type;
792            types_found.insert(detected_type.clone());
793            if detected_type.contains("BREAKING")
794                || commit.original_message.contains("BREAKING CHANGE")
795            {
796                has_breaking_changes = true;
797            }
798
799            let detected_scope = &commit.analysis.detected_scope;
800            if !detected_scope.is_empty() {
801                scopes_found.insert(detected_scope.clone());
802            }
803        }
804
805        // Update type checkboxes based on detected types
806        if let Some(feat_pos) = description.find("- [ ] New feature") {
807            if types_found.contains("feat") {
808                description.replace_range(feat_pos..feat_pos + 5, "- [x]");
809            }
810        }
811        if let Some(fix_pos) = description.find("- [ ] Bug fix") {
812            if types_found.contains("fix") {
813                description.replace_range(fix_pos..fix_pos + 5, "- [x]");
814            }
815        }
816        if let Some(docs_pos) = description.find("- [ ] Documentation update") {
817            if types_found.contains("docs") {
818                description.replace_range(docs_pos..docs_pos + 5, "- [x]");
819            }
820        }
821        if let Some(refactor_pos) = description.find("- [ ] Refactoring") {
822            if types_found.contains("refactor") {
823                description.replace_range(refactor_pos..refactor_pos + 5, "- [x]");
824            }
825        }
826        if let Some(breaking_pos) = description.find("- [ ] Breaking change") {
827            if has_breaking_changes {
828                description.replace_range(breaking_pos..breaking_pos + 5, "- [x]");
829            }
830        }
831
832        // Add detected scopes
833        if !scopes_found.is_empty() {
834            let scopes_list: Vec<_> = scopes_found.into_iter().collect();
835            description.push_str(&format!(
836                "**Affected areas:** {}\n\n",
837                scopes_list.join(", ")
838            ));
839        }
840
841        // Add commit list
842        description.push_str("### Commits in this PR:\n");
843        for commit in &repo_view.commits {
844            let short_hash = &commit.hash[..crate::git::SHORT_HASH_LEN];
845            let first_line = commit.original_message.lines().next().unwrap_or("").trim();
846            description.push_str(&format!("- `{}` {}\n", short_hash, first_line));
847        }
848
849        // Add file change summary
850        let total_files: usize = repo_view
851            .commits
852            .iter()
853            .map(|c| c.analysis.file_changes.total_files)
854            .sum();
855
856        if total_files > 0 {
857            description.push_str(&format!("\n**Files changed:** {} files\n", total_files));
858        }
859
860        Ok(())
861    }
862
863    /// Handles the PR description file by showing the path and getting the user choice.
864    fn handle_pr_file(
865        &self,
866        pr_file: &std::path::Path,
867        repo_view: &crate::data::RepositoryView,
868    ) -> Result<PrAction> {
869        use std::io::{self, Write};
870
871        println!("\nšŸ“ PR details generated.");
872        println!("šŸ’¾ Details saved to: {}", pr_file.display());
873
874        // Show draft status
875        let is_draft = self.should_create_as_draft();
876        let status_icon = if is_draft { "šŸ“‹" } else { "āœ…" };
877        let status_text = if is_draft {
878            "draft"
879        } else {
880            "ready for review"
881        };
882        println!("{} PR will be created as: {}", status_icon, status_text);
883        println!();
884
885        // Check if there are existing PRs and show different options
886        let has_existing_prs = repo_view
887            .branch_prs
888            .as_ref()
889            .is_some_and(|prs| !prs.is_empty());
890
891        loop {
892            if has_existing_prs {
893                print!("ā“ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
894            } else {
895                print!(
896                    "ā“ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
897                );
898            }
899            io::stdout().flush()?;
900
901            let mut input = String::new();
902            io::stdin().read_line(&mut input)?;
903
904            match input.trim().to_lowercase().as_str() {
905                "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
906                "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
907                "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
908                "s" | "show" => {
909                    self.show_pr_file(pr_file)?;
910                    println!();
911                }
912                "e" | "edit" => {
913                    self.edit_pr_file(pr_file)?;
914                    println!();
915                }
916                "q" | "quit" => return Ok(PrAction::Cancel),
917                _ => {
918                    if has_existing_prs {
919                        println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
920                    } else {
921                        println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
922                    }
923                }
924            }
925        }
926    }
927
928    /// Shows the contents of the PR details file.
929    fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
930        use std::fs;
931
932        println!("\nšŸ“„ PR details file contents:");
933        println!("─────────────────────────────");
934
935        let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
936        println!("{}", contents);
937        println!("─────────────────────────────");
938
939        Ok(())
940    }
941
942    /// Opens the PR details file in an external editor.
943    fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
944        use std::env;
945        use std::io::{self, Write};
946        use std::process::Command;
947
948        // Try to get editor from environment variables
949        let editor = env::var("OMNI_DEV_EDITOR")
950            .or_else(|_| env::var("EDITOR"))
951            .unwrap_or_else(|_| {
952                // Prompt user for editor if neither environment variable is set
953                println!(
954                    "šŸ”§ Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
955                );
956                print!("Please enter the command to use as your editor: ");
957                io::stdout().flush().expect("Failed to flush stdout");
958
959                let mut input = String::new();
960                io::stdin()
961                    .read_line(&mut input)
962                    .expect("Failed to read user input");
963                input.trim().to_string()
964            });
965
966        if editor.is_empty() {
967            println!("āŒ No editor specified. Returning to menu.");
968            return Ok(());
969        }
970
971        println!("šŸ“ Opening PR details file in editor: {}", editor);
972
973        // Split editor command to handle arguments
974        let mut cmd_parts = editor.split_whitespace();
975        let editor_cmd = cmd_parts.next().unwrap_or(&editor);
976        let args: Vec<&str> = cmd_parts.collect();
977
978        let mut command = Command::new(editor_cmd);
979        command.args(args);
980        command.arg(pr_file.to_string_lossy().as_ref());
981
982        match command.status() {
983            Ok(status) => {
984                if status.success() {
985                    println!("āœ… Editor session completed.");
986                } else {
987                    println!(
988                        "āš ļø  Editor exited with non-zero status: {:?}",
989                        status.code()
990                    );
991                }
992            }
993            Err(e) => {
994                println!("āŒ Failed to execute editor '{}': {}", editor, e);
995                println!("   Please check that the editor command is correct and available in your PATH.");
996            }
997        }
998
999        Ok(())
1000    }
1001
1002    /// Generates a concise title from commit analysis (fallback).
1003    fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
1004        if repo_view.commits.is_empty() {
1005            return "Pull Request".to_string();
1006        }
1007
1008        // For single commit, use its first line
1009        if repo_view.commits.len() == 1 {
1010            return repo_view.commits[0]
1011                .original_message
1012                .lines()
1013                .next()
1014                .unwrap_or("Pull Request")
1015                .trim()
1016                .to_string();
1017        }
1018
1019        // For multiple commits, generate from branch name
1020        let branch_name = repo_view
1021            .branch_info
1022            .as_ref()
1023            .map(|bi| bi.branch.as_str())
1024            .unwrap_or("feature");
1025
1026        let cleaned_branch = branch_name.replace(['/', '-', '_'], " ");
1027
1028        format!("feat: {}", cleaned_branch)
1029    }
1030
1031    /// Creates a new GitHub PR using gh CLI.
1032    fn create_github_pr(
1033        &self,
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        // Check if branch is pushed to remote and push if needed
1062        debug!("Opening git repository to check branch status");
1063        let git_repo =
1064            crate::git::GitRepository::open().context("Failed to open git repository")?;
1065
1066        debug!(
1067            "Checking if branch '{}' exists on remote 'origin'",
1068            branch_name
1069        );
1070        if !git_repo.branch_exists_on_remote(branch_name, "origin")? {
1071            println!("šŸ“¤ Pushing branch to remote...");
1072            debug!(
1073                "Branch '{}' not found on remote, attempting to push",
1074                branch_name
1075            );
1076            git_repo
1077                .push_branch(branch_name, "origin")
1078                .context("Failed to push branch to remote")?;
1079        } else {
1080            debug!("Branch '{}' already exists on remote 'origin'", branch_name);
1081        }
1082
1083        // Create PR using gh CLI with explicit head branch
1084        debug!("Creating PR with gh CLI - title: '{}'", title);
1085        debug!("PR description length: {} characters", description.len());
1086        debug!("PR draft status: {}", is_draft);
1087        if let Some(base) = new_base {
1088            debug!("PR base branch: {}", base);
1089        }
1090
1091        let mut args = vec![
1092            "pr",
1093            "create",
1094            "--head",
1095            branch_name,
1096            "--title",
1097            title,
1098            "--body",
1099            description,
1100        ];
1101
1102        if let Some(base) = new_base {
1103            args.push("--base");
1104            args.push(base);
1105        }
1106
1107        if is_draft {
1108            args.push("--draft");
1109        }
1110
1111        let pr_result = Command::new("gh")
1112            .args(&args)
1113            .output()
1114            .context("Failed to create pull request")?;
1115
1116        if pr_result.status.success() {
1117            let pr_url = String::from_utf8_lossy(&pr_result.stdout);
1118            let pr_url = pr_url.trim();
1119            debug!("PR created successfully with URL: {}", pr_url);
1120            println!("šŸŽ‰ Pull request created: {}", pr_url);
1121        } else {
1122            let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1123            error!("gh CLI failed to create PR: {}", error_msg);
1124            anyhow::bail!("Failed to create pull request: {}", error_msg);
1125        }
1126
1127        Ok(())
1128    }
1129
1130    /// Updates an existing GitHub PR using gh CLI.
1131    fn update_github_pr(
1132        &self,
1133        repo_view: &crate::data::RepositoryView,
1134        title: &str,
1135        description: &str,
1136        new_base: Option<&str>,
1137    ) -> Result<()> {
1138        use std::io::{self, Write};
1139        use std::process::Command;
1140
1141        // Get the first existing PR (assuming we're updating the most recent one)
1142        let existing_pr = repo_view
1143            .branch_prs
1144            .as_ref()
1145            .and_then(|prs| prs.first())
1146            .context("No existing PR found to update")?;
1147
1148        let pr_number = existing_pr.number;
1149        let current_base = &existing_pr.base;
1150
1151        println!("šŸš€ Updating pull request #{}...", pr_number);
1152        println!("   šŸ“‹ Title: {}", title);
1153
1154        // Check if base branch should be changed
1155        let change_base = if let Some(base) = new_base {
1156            if !current_base.is_empty() && current_base != base {
1157                print!(
1158                    "   šŸŽÆ Current base: {} → New base: {}. Change? [y/N]: ",
1159                    current_base, base
1160                );
1161                io::stdout().flush()?;
1162
1163                let mut input = String::new();
1164                io::stdin().read_line(&mut input)?;
1165                let response = input.trim().to_lowercase();
1166                response == "y" || response == "yes"
1167            } else {
1168                false
1169            }
1170        } else {
1171            false
1172        };
1173
1174        debug!(
1175            pr_number = pr_number,
1176            title = %title,
1177            description_length = description.len(),
1178            description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1179            change_base = change_base,
1180            "Updating GitHub PR with title and description"
1181        );
1182
1183        // Update PR using gh CLI
1184        let pr_number_str = pr_number.to_string();
1185        let mut gh_args = vec![
1186            "pr",
1187            "edit",
1188            &pr_number_str,
1189            "--title",
1190            title,
1191            "--body",
1192            description,
1193        ];
1194
1195        if change_base {
1196            if let Some(base) = new_base {
1197                gh_args.push("--base");
1198                gh_args.push(base);
1199            }
1200        }
1201
1202        debug!(
1203            args = ?gh_args,
1204            "Executing gh command to update PR"
1205        );
1206
1207        let pr_result = Command::new("gh")
1208            .args(&gh_args)
1209            .output()
1210            .context("Failed to update pull request")?;
1211
1212        if pr_result.status.success() {
1213            // Get the PR URL using the existing PR data
1214            println!("šŸŽ‰ Pull request updated: {}", existing_pr.url);
1215            if change_base {
1216                if let Some(base) = new_base {
1217                    println!("   šŸŽÆ Base branch changed to: {}", base);
1218                }
1219            }
1220        } else {
1221            let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1222            anyhow::bail!("Failed to update pull request: {}", error_msg);
1223        }
1224
1225        Ok(())
1226    }
1227
1228    /// Shows model information from the actual AI client.
1229    fn show_model_info_from_client(
1230        &self,
1231        client: &crate::claude::client::ClaudeClient,
1232    ) -> Result<()> {
1233        use crate::claude::model_config::get_model_registry;
1234
1235        println!("šŸ¤– AI Model Configuration:");
1236
1237        // Get actual metadata from the client
1238        let metadata = client.get_ai_client_metadata();
1239        let registry = get_model_registry();
1240
1241        if let Some(spec) = registry.get_model_spec(&metadata.model) {
1242            // Highlight the API identifier portion in yellow
1243            if metadata.model != spec.api_identifier {
1244                println!(
1245                    "   šŸ“” Model: {} → \x1b[33m{}\x1b[0m",
1246                    metadata.model, spec.api_identifier
1247                );
1248            } else {
1249                println!("   šŸ“” Model: \x1b[33m{}\x1b[0m", metadata.model);
1250            }
1251
1252            println!("   šŸ·ļø  Provider: {}", spec.provider);
1253            println!("   šŸ“Š Generation: {}", spec.generation);
1254            println!("   ⭐ Tier: {} ({})", spec.tier, {
1255                if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1256                    &tier_info.description
1257                } else {
1258                    "No description available"
1259                }
1260            });
1261            println!("   šŸ“¤ Max output tokens: {}", metadata.max_response_length);
1262            println!("   šŸ“„ Input context: {}", metadata.max_context_length);
1263
1264            if let Some((ref key, ref value)) = metadata.active_beta {
1265                println!("   šŸ”¬ Beta header: {}: {}", key, value);
1266            }
1267
1268            if spec.legacy {
1269                println!("   āš ļø  Legacy model (consider upgrading to newer version)");
1270            }
1271        } else {
1272            // Fallback to client metadata if not in registry
1273            println!("   šŸ“” Model: \x1b[33m{}\x1b[0m", metadata.model);
1274            println!("   šŸ·ļø  Provider: {}", metadata.provider);
1275            println!("   āš ļø  Model not found in registry, using client metadata:");
1276            println!("   šŸ“¤ Max output tokens: {}", metadata.max_response_length);
1277            println!("   šŸ“„ Input context: {}", metadata.max_context_length);
1278        }
1279
1280        println!();
1281        Ok(())
1282    }
1283}