Skip to main content

omni_dev/cli/git/
create_pr.rs

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