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