1use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::{debug, error};
6
7use super::info::InfoCommand;
8
9#[derive(Parser)]
11pub struct CreatePrCommand {
12 #[arg(long, value_name = "BRANCH")]
14 pub base: Option<String>,
15
16 #[arg(long)]
18 pub model: Option<String>,
19
20 #[arg(long)]
22 pub auto_apply: bool,
23
24 #[arg(long, value_name = "FILE")]
26 pub save_only: Option<String>,
27
28 #[arg(long, conflicts_with = "draft")]
30 pub ready: bool,
31
32 #[arg(long, conflicts_with = "ready")]
34 pub draft: bool,
35
36 #[arg(long)]
38 pub context_dir: Option<std::path::PathBuf>,
39
40 #[arg(long)]
42 pub no_push: bool,
43}
44
45#[derive(Debug, PartialEq)]
47enum PrAction {
48 CreateNew,
49 UpdateExisting,
50 Cancel,
51}
52
53#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
55#[schemars(deny_unknown_fields)]
56pub struct PrContent {
57 pub title: String,
59 pub description: String,
61}
62
63impl CreatePrCommand {
64 fn should_create_as_draft(&self) -> bool {
72 use crate::utils::settings::get_env_var;
73
74 if self.ready {
76 return false;
77 }
78 if self.draft {
79 return true;
80 }
81
82 get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
84 .ok()
85 .and_then(|val| parse_bool_string(&val))
86 .unwrap_or(true) }
88
89 pub async fn execute(self) -> Result<()> {
91 let ai_info = crate::utils::check_pr_command_prerequisites(self.model.as_deref())?;
94 println!(
95 "✓ {} credentials verified (model: {})",
96 ai_info.provider, ai_info.model
97 );
98 println!("✓ GitHub CLI verified");
99
100 println!("🔄 Starting pull request creation process...");
101
102 let repo_view = self.generate_repository_view()?;
104
105 self.validate_branch_state(&repo_view)?;
107
108 use crate::claude::context::ProjectDiscovery;
110 let repo_root = std::path::PathBuf::from(".");
111 let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
112 let discovery = ProjectDiscovery::new(repo_root, context_dir);
113 let project_context = discovery.discover().unwrap_or_default();
114 self.show_guidance_files_status(&project_context)?;
115
116 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), None)?;
118 self.show_model_info_from_client(&claude_client)?;
119
120 self.show_commit_range_info(&repo_view)?;
122
123 let context = {
125 use crate::claude::context::{BranchAnalyzer, FileAnalyzer, WorkPatternAnalyzer};
126 use crate::data::context::CommitContext;
127 let mut context = CommitContext::new();
128 context.project = project_context;
129
130 if let Some(branch_info) = &repo_view.branch_info {
132 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
133 }
134
135 if !repo_view.commits.is_empty() {
136 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
137 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
138 }
139 context
140 };
141 self.show_context_summary(&context)?;
142
143 debug!("About to generate PR content from AI");
145 let (pr_content, _claude_client) = self
146 .generate_pr_content_with_client_internal(&repo_view, claude_client)
147 .await?;
148
149 self.show_context_information(&repo_view).await?;
151 debug!(
152 generated_title = %pr_content.title,
153 generated_description_length = pr_content.description.len(),
154 generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
155 "Generated PR content from AI"
156 );
157
158 if let Some(save_path) = self.save_only {
160 let pr_yaml = crate::data::to_yaml(&pr_content)
161 .context("Failed to serialize PR content to YAML")?;
162 std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
163 println!("💾 PR details saved to: {save_path}");
164 return Ok(());
165 }
166
167 debug!("About to serialize PR content to YAML");
169 let temp_dir = tempfile::tempdir()?;
170 let pr_file = temp_dir.path().join("pr-details.yaml");
171
172 debug!(
173 pre_serialize_title = %pr_content.title,
174 pre_serialize_description_length = pr_content.description.len(),
175 pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
176 "About to serialize PR content with to_yaml"
177 );
178
179 let pr_yaml =
180 crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
181
182 debug!(
183 file_path = %pr_file.display(),
184 yaml_content_length = pr_yaml.len(),
185 yaml_content = %pr_yaml,
186 original_title = %pr_content.title,
187 original_description_length = pr_content.description.len(),
188 "Writing PR details to temporary YAML file"
189 );
190
191 std::fs::write(&pr_file, &pr_yaml)?;
192
193 let pr_action = if self.auto_apply {
195 if repo_view
197 .branch_prs
198 .as_ref()
199 .is_some_and(|prs| !prs.is_empty())
200 {
201 PrAction::UpdateExisting
202 } else {
203 PrAction::CreateNew
204 }
205 } else {
206 self.handle_pr_file(&pr_file, &repo_view)?
207 };
208
209 if pr_action == PrAction::Cancel {
210 println!("❌ PR operation cancelled by user");
211 return Ok(());
212 }
213
214 let final_pr_yaml =
216 std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
217
218 debug!(
219 yaml_length = final_pr_yaml.len(),
220 yaml_content = %final_pr_yaml,
221 "Read PR details YAML from file"
222 );
223
224 let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
225 .context("Failed to parse PR details YAML. Please check the file format.")?;
226
227 debug!(
228 title = %final_pr_content.title,
229 description_length = final_pr_content.description.len(),
230 description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
231 "Parsed PR content from YAML"
232 );
233
234 let is_draft = self.should_create_as_draft();
236
237 match pr_action {
238 PrAction::CreateNew => {
239 self.create_github_pr(
240 &repo_view,
241 &final_pr_content.title,
242 &final_pr_content.description,
243 is_draft,
244 self.base.as_deref(),
245 )?;
246 println!("✅ Pull request created successfully!");
247 }
248 PrAction::UpdateExisting => {
249 self.update_github_pr(
250 &repo_view,
251 &final_pr_content.title,
252 &final_pr_content.description,
253 self.base.as_deref(),
254 )?;
255 println!("✅ Pull request updated successfully!");
256 }
257 PrAction::Cancel => unreachable!(), }
259
260 Ok(())
261 }
262
263 fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
265 use crate::data::{
266 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
267 WorkingDirectoryInfo,
268 };
269 use crate::git::{GitRepository, RemoteInfo};
270 use crate::utils::ai_scratch;
271
272 let repo = GitRepository::open()
274 .context("Failed to open git repository. Make sure you're in a git repository.")?;
275
276 let current_branch = repo.get_current_branch().context(
278 "Failed to get current branch. Make sure you're not in detached HEAD state.",
279 )?;
280
281 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
283
284 let primary_remote = remotes
286 .iter()
287 .find(|r| r.name == "origin")
288 .or_else(|| remotes.first())
289 .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
290
291 let base_branch = if let Some(branch) = self.base.as_ref() {
293 let remote_ref = format!("refs/remotes/{branch}");
296 if repo.repository().find_reference(&remote_ref).is_ok() {
297 branch.clone()
298 } else {
299 let with_remote = format!("{}/{}", primary_remote.name, branch);
301 let remote_ref = format!("refs/remotes/{with_remote}");
302 if repo.repository().find_reference(&remote_ref).is_ok() {
303 with_remote
304 } else {
305 anyhow::bail!(
306 "Remote branch '{branch}' does not exist (also tried '{with_remote}')"
307 );
308 }
309 }
310 } else {
311 let main_branch = &primary_remote.main_branch;
313 if main_branch == "unknown" {
314 let remote_name = &primary_remote.name;
315 anyhow::bail!("Could not determine main branch for remote '{remote_name}'");
316 }
317
318 let remote_main = format!("{}/{}", primary_remote.name, main_branch);
319
320 let remote_ref = format!("refs/remotes/{remote_main}");
322 if repo.repository().find_reference(&remote_ref).is_err() {
323 anyhow::bail!(
324 "Remote main branch '{remote_main}' does not exist. Try running 'git fetch' first."
325 );
326 }
327
328 remote_main
329 };
330
331 let commit_range = format!("{base_branch}..HEAD");
333
334 let wd_status = repo.get_working_directory_status()?;
336 let working_directory = WorkingDirectoryInfo {
337 clean: wd_status.clean,
338 untracked_changes: wd_status
339 .untracked_changes
340 .into_iter()
341 .map(|fs| FileStatusInfo {
342 status: fs.status,
343 file: fs.file,
344 })
345 .collect(),
346 };
347
348 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
350
351 let commits = repo.get_commits_in_range(&commit_range)?;
353
354 let pr_template_result = InfoCommand::read_pr_template().ok();
356 let (pr_template, pr_template_location) = match pr_template_result {
357 Some((content, location)) => (Some(content), Some(location)),
358 None => (None, None),
359 };
360
361 let branch_prs = InfoCommand::get_branch_prs(¤t_branch)
363 .ok()
364 .filter(|prs| !prs.is_empty());
365
366 let versions = Some(VersionInfo {
368 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
369 });
370
371 let ai_scratch_path =
373 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
374 let ai_info = AiInfo {
375 scratch: ai_scratch_path.to_string_lossy().to_string(),
376 };
377
378 let mut repo_view = RepositoryView {
380 versions,
381 explanation: FieldExplanation::default(),
382 working_directory,
383 remotes,
384 ai: ai_info,
385 branch_info: Some(BranchInfo {
386 branch: current_branch,
387 }),
388 pr_template,
389 pr_template_location,
390 branch_prs,
391 commits,
392 };
393
394 repo_view.update_field_presence();
396
397 Ok(repo_view)
398 }
399
400 fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
402 if !repo_view.working_directory.clean {
404 anyhow::bail!(
405 "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
406 );
407 }
408
409 if !repo_view.working_directory.untracked_changes.is_empty() {
411 let file_list: Vec<&str> = repo_view
412 .working_directory
413 .untracked_changes
414 .iter()
415 .map(|f| f.file.as_str())
416 .collect();
417 anyhow::bail!(
418 "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
419 file_list.join(", ")
420 );
421 }
422
423 if repo_view.commits.is_empty() {
425 anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
426 }
427
428 if let Some(existing_prs) = &repo_view.branch_prs {
430 if !existing_prs.is_empty() {
431 let pr_info: Vec<String> = existing_prs
432 .iter()
433 .map(|pr| format!("#{} ({})", pr.number, pr.state))
434 .collect();
435
436 println!(
437 "📋 Existing PR(s) found for this branch: {}",
438 pr_info.join(", ")
439 );
440 }
442 }
443
444 Ok(())
445 }
446
447 async fn show_context_information(
449 &self,
450 _repo_view: &crate::data::RepositoryView,
451 ) -> Result<()> {
452 Ok(())
457 }
458
459 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
461 let base_branch = match self.base.as_ref() {
463 Some(branch) => {
464 let primary_remote_name = repo_view
467 .remotes
468 .iter()
469 .find(|r| r.name == "origin")
470 .or_else(|| repo_view.remotes.first())
471 .map_or("origin", |r| r.name.as_str());
472 if branch.starts_with(&format!("{primary_remote_name}/")) {
474 branch.clone()
475 } else {
476 format!("{primary_remote_name}/{branch}")
477 }
478 }
479 None => {
480 repo_view
482 .remotes
483 .iter()
484 .find(|r| r.name == "origin")
485 .or_else(|| repo_view.remotes.first())
486 .map_or_else(
487 || "unknown".to_string(),
488 |r| format!("{}/{}", r.name, r.main_branch),
489 )
490 }
491 };
492
493 let commit_range = format!("{base_branch}..HEAD");
494 let commit_count = repo_view.commits.len();
495
496 let current_branch = repo_view
498 .branch_info
499 .as_ref()
500 .map_or("unknown", |bi| bi.branch.as_str());
501
502 println!("📊 Branch Analysis:");
503 println!(" 🌿 Current branch: {current_branch}");
504 println!(" 📏 Commit range: {commit_range}");
505 println!(" 📝 Commits found: {commit_count} commits");
506 println!();
507
508 Ok(())
509 }
510
511 async fn collect_context(
513 &self,
514 repo_view: &crate::data::RepositoryView,
515 ) -> Result<crate::data::context::CommitContext> {
516 use crate::claude::context::{
517 BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
518 };
519 use crate::data::context::{CommitContext, ProjectContext};
520 use crate::git::GitRepository;
521
522 let mut context = CommitContext::new();
523
524 let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
526
527 let repo_root = std::path::PathBuf::from(".");
529 let discovery = ProjectDiscovery::new(repo_root, context_dir);
530 match discovery.discover() {
531 Ok(project_context) => {
532 context.project = project_context;
533 }
534 Err(_e) => {
535 context.project = ProjectContext::default();
536 }
537 }
538
539 let repo = GitRepository::open()?;
541 let current_branch = repo
542 .get_current_branch()
543 .unwrap_or_else(|_| "HEAD".to_string());
544 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
545
546 if !repo_view.commits.is_empty() {
548 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
549 }
550
551 if !repo_view.commits.is_empty() {
553 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
554 }
555
556 Ok(context)
557 }
558
559 fn show_guidance_files_status(
561 &self,
562 project_context: &crate::data::context::ProjectContext,
563 ) -> Result<()> {
564 use crate::claude::context::{
565 config_source_label, resolve_context_dir_with_source, ConfigSourceLabel,
566 };
567
568 let (context_dir, dir_source) =
569 resolve_context_dir_with_source(self.context_dir.as_deref());
570
571 println!("📋 Project guidance files status:");
572 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
573
574 let pr_guidelines_source = if project_context.pr_guidelines.is_some() {
576 match config_source_label(&context_dir, "pr-guidelines.md") {
577 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
578 label => format!("✅ {label}"),
579 }
580 } else {
581 "❌ None found".to_string()
582 };
583 println!(" 🔀 PR guidelines: {pr_guidelines_source}");
584
585 let scopes_count = project_context.valid_scopes.len();
587 let scopes_source = if scopes_count > 0 {
588 match config_source_label(&context_dir, "scopes.yaml") {
589 ConfigSourceLabel::NotFound => {
590 format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
591 }
592 label => format!("✅ {label} ({scopes_count} scopes)"),
593 }
594 } else {
595 "❌ None found".to_string()
596 };
597 println!(" 🎯 Valid scopes: {scopes_source}");
598
599 let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
601 let pr_template_status = if pr_template_path.exists() {
602 format!("✅ Project: {}", pr_template_path.display())
603 } else {
604 "❌ None found".to_string()
605 };
606 println!(" 📋 PR template: {pr_template_status}");
607
608 println!();
609 Ok(())
610 }
611
612 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
614 use crate::data::context::{VerbosityLevel, WorkPattern};
615
616 println!("🔍 Context Analysis:");
617
618 if !context.project.valid_scopes.is_empty() {
620 let scope_names: Vec<&str> = context
621 .project
622 .valid_scopes
623 .iter()
624 .map(|s| s.name.as_str())
625 .collect();
626 println!(" 📁 Valid scopes: {}", scope_names.join(", "));
627 }
628
629 if context.branch.is_feature_branch {
631 println!(
632 " 🌿 Branch: {} ({})",
633 context.branch.description, context.branch.work_type
634 );
635 if let Some(ref ticket) = context.branch.ticket_id {
636 println!(" 🎫 Ticket: {ticket}");
637 }
638 }
639
640 match context.range.work_pattern {
642 WorkPattern::Sequential => println!(" 🔄 Pattern: Sequential development"),
643 WorkPattern::Refactoring => println!(" 🧹 Pattern: Refactoring work"),
644 WorkPattern::BugHunt => println!(" 🐛 Pattern: Bug investigation"),
645 WorkPattern::Documentation => println!(" 📖 Pattern: Documentation updates"),
646 WorkPattern::Configuration => println!(" ⚙️ Pattern: Configuration changes"),
647 WorkPattern::Unknown => {}
648 }
649
650 if let Some(label) = super::formatting::format_file_analysis(&context.files) {
652 println!(" {label}");
653 }
654
655 match context.suggested_verbosity() {
657 VerbosityLevel::Comprehensive => {
658 println!(" 📝 Detail level: Comprehensive (significant changes detected)");
659 }
660 VerbosityLevel::Detailed => println!(" 📝 Detail level: Detailed"),
661 VerbosityLevel::Concise => println!(" 📝 Detail level: Concise"),
662 }
663
664 println!();
665 Ok(())
666 }
667
668 async fn generate_pr_content_with_client_internal(
670 &self,
671 repo_view: &crate::data::RepositoryView,
672 claude_client: crate::claude::client::ClaudeClient,
673 ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
674 use tracing::debug;
675
676 let pr_template = match &repo_view.pr_template {
678 Some(template) => template.clone(),
679 None => self.get_default_pr_template(),
680 };
681
682 debug!(
683 pr_template_length = pr_template.len(),
684 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
685 "Using PR template for generation"
686 );
687
688 println!("🤖 Generating AI-powered PR description...");
689
690 debug!("Collecting context for PR generation");
692 let context = self.collect_context(repo_view).await?;
693 debug!("Context collection completed");
694
695 debug!("About to call Claude AI for PR content generation");
697 match claude_client
698 .generate_pr_content_with_context(repo_view, &pr_template, &context)
699 .await
700 {
701 Ok(pr_content) => {
702 debug!(
703 ai_generated_title = %pr_content.title,
704 ai_generated_description_length = pr_content.description.len(),
705 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
706 "AI successfully generated PR content"
707 );
708 Ok((pr_content, claude_client))
709 }
710 Err(e) => {
711 debug!(error = %e, "AI PR generation failed, falling back to basic description");
712 let mut description = pr_template;
714 self.enhance_description_with_commits(&mut description, repo_view)?;
715
716 let title = self.generate_title_from_commits(repo_view);
718
719 debug!(
720 fallback_title = %title,
721 fallback_description_length = description.len(),
722 "Created fallback PR content"
723 );
724
725 Ok((PrContent { title, description }, claude_client))
726 }
727 }
728 }
729
730 fn get_default_pr_template(&self) -> String {
732 r#"# Pull Request
733
734## Description
735<!-- Provide a brief description of what this PR does -->
736
737## Type of Change
738<!-- Mark the relevant option with an "x" -->
739- [ ] Bug fix (non-breaking change which fixes an issue)
740- [ ] New feature (non-breaking change which adds functionality)
741- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
742- [ ] Documentation update
743- [ ] Refactoring (no functional changes)
744- [ ] Performance improvement
745- [ ] Test coverage improvement
746
747## Changes Made
748<!-- List the specific changes made in this PR -->
749-
750-
751-
752
753## Testing
754- [ ] All existing tests pass
755- [ ] New tests added for new functionality
756- [ ] Manual testing performed
757
758## Additional Notes
759<!-- Add any additional notes for reviewers -->
760"#.to_string()
761 }
762
763 fn enhance_description_with_commits(
765 &self,
766 description: &mut String,
767 repo_view: &crate::data::RepositoryView,
768 ) -> Result<()> {
769 if repo_view.commits.is_empty() {
770 return Ok(());
771 }
772
773 description.push_str("\n---\n");
775 description.push_str("## 📝 Commit Summary\n");
776 description
777 .push_str("*This section was automatically generated based on commit analysis*\n\n");
778
779 let mut types_found = std::collections::HashSet::new();
781 let mut scopes_found = std::collections::HashSet::new();
782 let mut has_breaking_changes = false;
783
784 for commit in &repo_view.commits {
785 let detected_type = &commit.analysis.detected_type;
786 types_found.insert(detected_type.clone());
787 if is_breaking_change(detected_type, &commit.original_message) {
788 has_breaking_changes = true;
789 }
790
791 let detected_scope = &commit.analysis.detected_scope;
792 if !detected_scope.is_empty() {
793 scopes_found.insert(detected_scope.clone());
794 }
795 }
796
797 if types_found.contains("feat") {
799 check_checkbox(description, "- [ ] New feature");
800 }
801 if types_found.contains("fix") {
802 check_checkbox(description, "- [ ] Bug fix");
803 }
804 if types_found.contains("docs") {
805 check_checkbox(description, "- [ ] Documentation update");
806 }
807 if types_found.contains("refactor") {
808 check_checkbox(description, "- [ ] Refactoring");
809 }
810 if has_breaking_changes {
811 check_checkbox(description, "- [ ] Breaking change");
812 }
813
814 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
816 let scopes_section = format_scopes_section(&scopes_list);
817 if !scopes_section.is_empty() {
818 description.push_str(&scopes_section);
819 }
820
821 let commit_entries: Vec<(&str, &str)> = repo_view
823 .commits
824 .iter()
825 .map(|c| {
826 let short = &c.hash[..crate::git::SHORT_HASH_LEN];
827 let first = extract_first_line(&c.original_message);
828 (short, first)
829 })
830 .collect();
831 description.push_str(&format_commit_list(&commit_entries));
832
833 let total_files: usize = repo_view
835 .commits
836 .iter()
837 .map(|c| c.analysis.file_changes.total_files)
838 .sum();
839
840 if total_files > 0 {
841 description.push_str(&format!("\n**Files changed:** {total_files} files\n"));
842 }
843
844 Ok(())
845 }
846
847 fn handle_pr_file(
849 &self,
850 pr_file: &std::path::Path,
851 repo_view: &crate::data::RepositoryView,
852 ) -> Result<PrAction> {
853 use std::io::{self, Write};
854
855 println!("\n📝 PR details generated.");
856 println!("💾 Details saved to: {}", pr_file.display());
857
858 let is_draft = self.should_create_as_draft();
860 let (status_icon, status_text) = format_draft_status(is_draft);
861 println!("{status_icon} PR will be created as: {status_text}");
862 println!();
863
864 let has_existing_prs = repo_view
866 .branch_prs
867 .as_ref()
868 .is_some_and(|prs| !prs.is_empty());
869
870 loop {
871 if has_existing_prs {
872 print!("❓ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
873 } else {
874 print!(
875 "❓ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
876 );
877 }
878 io::stdout().flush()?;
879
880 let mut input = String::new();
881 io::stdin().read_line(&mut input)?;
882
883 match input.trim().to_lowercase().as_str() {
884 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
885 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
886 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
887 "s" | "show" => {
888 self.show_pr_file(pr_file)?;
889 println!();
890 }
891 "e" | "edit" => {
892 self.edit_pr_file(pr_file)?;
893 println!();
894 }
895 "q" | "quit" => return Ok(PrAction::Cancel),
896 _ => {
897 if has_existing_prs {
898 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
899 } else {
900 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
901 }
902 }
903 }
904 }
905 }
906
907 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
909 use std::fs;
910
911 println!("\n📄 PR details file contents:");
912 println!("─────────────────────────────");
913
914 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
915 println!("{contents}");
916 println!("─────────────────────────────");
917
918 Ok(())
919 }
920
921 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
923 use std::env;
924 use std::io::{self, Write};
925 use std::process::Command;
926
927 let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
929 e
930 } else {
931 println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
933 print!("Please enter the command to use as your editor: ");
934 io::stdout().flush().context("Failed to flush stdout")?;
935
936 let mut input = String::new();
937 io::stdin()
938 .read_line(&mut input)
939 .context("Failed to read user input")?;
940 input.trim().to_string()
941 };
942
943 if editor.is_empty() {
944 println!("❌ No editor specified. Returning to menu.");
945 return Ok(());
946 }
947
948 println!("📝 Opening PR details file in editor: {editor}");
949
950 let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
951
952 let mut command = Command::new(editor_cmd);
953 command.args(args);
954 command.arg(pr_file.to_string_lossy().as_ref());
955
956 match command.status() {
957 Ok(status) => {
958 if status.success() {
959 println!("✅ Editor session completed.");
960 } else {
961 println!(
962 "⚠️ Editor exited with non-zero status: {:?}",
963 status.code()
964 );
965 }
966 }
967 Err(e) => {
968 println!("❌ Failed to execute editor '{editor}': {e}");
969 println!(" Please check that the editor command is correct and available in your PATH.");
970 }
971 }
972
973 Ok(())
974 }
975
976 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
978 if repo_view.commits.is_empty() {
979 return "Pull Request".to_string();
980 }
981
982 if repo_view.commits.len() == 1 {
984 let first = extract_first_line(&repo_view.commits[0].original_message);
985 let trimmed = first.trim();
986 return if trimmed.is_empty() {
987 "Pull Request".to_string()
988 } else {
989 trimmed.to_string()
990 };
991 }
992
993 let branch_name = repo_view
995 .branch_info
996 .as_ref()
997 .map_or("feature", |bi| bi.branch.as_str());
998
999 format!("feat: {}", clean_branch_name(branch_name))
1000 }
1001
1002 fn create_github_pr(
1004 &self,
1005 repo_view: &crate::data::RepositoryView,
1006 title: &str,
1007 description: &str,
1008 is_draft: bool,
1009 new_base: Option<&str>,
1010 ) -> Result<()> {
1011 use std::process::Command;
1012
1013 let branch_name = repo_view
1015 .branch_info
1016 .as_ref()
1017 .map(|bi| &bi.branch)
1018 .context("Branch info not available")?;
1019
1020 let pr_status = if is_draft {
1021 "draft"
1022 } else {
1023 "ready for review"
1024 };
1025 println!("🚀 Creating pull request ({pr_status})...");
1026 println!(" 📋 Title: {title}");
1027 println!(" 🌿 Branch: {branch_name}");
1028 if let Some(base) = new_base {
1029 println!(" 🎯 Base: {base}");
1030 }
1031
1032 let push_action = if self.no_push {
1034 determine_push_action(true, false)
1035 } else {
1036 debug!("Opening git repository to check branch status");
1037 let git_repo =
1038 crate::git::GitRepository::open().context("Failed to open git repository")?;
1039
1040 debug!(
1041 "Checking if branch '{}' exists on remote 'origin'",
1042 branch_name
1043 );
1044 let branch_on_remote = git_repo.branch_exists_on_remote(branch_name, "origin")?;
1045 let action = determine_push_action(false, branch_on_remote);
1046
1047 debug!("Push action for branch '{}': {:?}", branch_name, action);
1048 println!("📤 Pushing branch to remote...");
1049 git_repo
1050 .push_branch(branch_name, "origin")
1051 .context("Failed to push branch to remote")?;
1052
1053 action
1054 };
1055
1056 if push_action == PushAction::Skip {
1057 debug!("Skipping push (--no-push flag set)");
1058 }
1059
1060 debug!("Creating PR with gh CLI - title: '{}'", title);
1062 debug!("PR description length: {} characters", description.len());
1063 debug!("PR draft status: {}", is_draft);
1064 if let Some(base) = new_base {
1065 debug!("PR base branch: {}", base);
1066 }
1067
1068 let mut args = vec![
1069 "pr",
1070 "create",
1071 "--head",
1072 branch_name,
1073 "--title",
1074 title,
1075 "--body",
1076 description,
1077 ];
1078
1079 if let Some(base) = new_base {
1080 args.push("--base");
1081 args.push(base);
1082 }
1083
1084 if is_draft {
1085 args.push("--draft");
1086 }
1087
1088 let pr_result = Command::new("gh")
1089 .args(&args)
1090 .output()
1091 .context("Failed to create pull request")?;
1092
1093 if pr_result.status.success() {
1094 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
1095 let pr_url = pr_url.trim();
1096 debug!("PR created successfully with URL: {}", pr_url);
1097 println!("🎉 Pull request created: {pr_url}");
1098 } else {
1099 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1100 error!("gh CLI failed to create PR: {}", error_msg);
1101 anyhow::bail!("Failed to create pull request: {error_msg}");
1102 }
1103
1104 Ok(())
1105 }
1106
1107 fn update_github_pr(
1109 &self,
1110 repo_view: &crate::data::RepositoryView,
1111 title: &str,
1112 description: &str,
1113 new_base: Option<&str>,
1114 ) -> Result<()> {
1115 use std::io::{self, Write};
1116 use std::process::Command;
1117
1118 let existing_pr = repo_view
1120 .branch_prs
1121 .as_ref()
1122 .and_then(|prs| prs.first())
1123 .context("No existing PR found to update")?;
1124
1125 let pr_number = existing_pr.number;
1126 let current_base = &existing_pr.base;
1127
1128 println!("🚀 Updating pull request #{pr_number}...");
1129 println!(" 📋 Title: {title}");
1130
1131 let change_base = if let Some(base) = new_base {
1133 if !current_base.is_empty() && current_base != base {
1134 print!(" 🎯 Current base: {current_base} → New base: {base}. Change? [y/N]: ");
1135 io::stdout().flush()?;
1136
1137 let mut input = String::new();
1138 io::stdin().read_line(&mut input)?;
1139 let response = input.trim().to_lowercase();
1140 response == "y" || response == "yes"
1141 } else {
1142 false
1143 }
1144 } else {
1145 false
1146 };
1147
1148 debug!(
1149 pr_number = pr_number,
1150 title = %title,
1151 description_length = description.len(),
1152 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1153 change_base = change_base,
1154 "Updating GitHub PR with title and description"
1155 );
1156
1157 let pr_number_str = pr_number.to_string();
1159 let mut gh_args = vec![
1160 "pr",
1161 "edit",
1162 &pr_number_str,
1163 "--title",
1164 title,
1165 "--body",
1166 description,
1167 ];
1168
1169 if change_base {
1170 if let Some(base) = new_base {
1171 gh_args.push("--base");
1172 gh_args.push(base);
1173 }
1174 }
1175
1176 debug!(
1177 args = ?gh_args,
1178 "Executing gh command to update PR"
1179 );
1180
1181 let pr_result = Command::new("gh")
1182 .args(&gh_args)
1183 .output()
1184 .context("Failed to update pull request")?;
1185
1186 if pr_result.status.success() {
1187 println!("🎉 Pull request updated: {}", existing_pr.url);
1189 if change_base {
1190 if let Some(base) = new_base {
1191 println!(" 🎯 Base branch changed to: {base}");
1192 }
1193 }
1194 } else {
1195 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1196 anyhow::bail!("Failed to update pull request: {error_msg}");
1197 }
1198
1199 Ok(())
1200 }
1201
1202 fn show_model_info_from_client(
1204 &self,
1205 client: &crate::claude::client::ClaudeClient,
1206 ) -> Result<()> {
1207 use crate::claude::model_config::get_model_registry;
1208
1209 println!("🤖 AI Model Configuration:");
1210
1211 let metadata = client.get_ai_client_metadata();
1213 let registry = get_model_registry();
1214
1215 if let Some(spec) = registry.get_model_spec(&metadata.model) {
1216 if metadata.model != spec.api_identifier {
1218 println!(
1219 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
1220 metadata.model, spec.api_identifier
1221 );
1222 } else {
1223 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1224 }
1225
1226 println!(" 🏷️ Provider: {}", spec.provider);
1227 println!(" 📊 Generation: {}", spec.generation);
1228 println!(" ⭐ Tier: {} ({})", spec.tier, {
1229 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1230 &tier_info.description
1231 } else {
1232 "No description available"
1233 }
1234 });
1235 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1236 println!(" 📥 Input context: {}", metadata.max_context_length);
1237
1238 if let Some((ref key, ref value)) = metadata.active_beta {
1239 println!(" 🔬 Beta header: {key}: {value}");
1240 }
1241
1242 if spec.legacy {
1243 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
1244 }
1245 } else {
1246 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1248 println!(" 🏷️ Provider: {}", metadata.provider);
1249 println!(" ⚠️ Model not found in registry, using client metadata:");
1250 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1251 println!(" 📥 Input context: {}", metadata.max_context_length);
1252 }
1253
1254 println!();
1255 Ok(())
1256 }
1257}
1258
1259#[derive(Debug, PartialEq)]
1263enum PushAction {
1264 Skip,
1266 SyncExisting,
1268 PushNew,
1270}
1271
1272fn determine_push_action(no_push: bool, branch_on_remote: bool) -> PushAction {
1274 if no_push {
1275 PushAction::Skip
1276 } else if branch_on_remote {
1277 PushAction::SyncExisting
1278 } else {
1279 PushAction::PushNew
1280 }
1281}
1282
1283fn parse_bool_string(val: &str) -> Option<bool> {
1288 match val.to_lowercase().as_str() {
1289 "true" | "1" | "yes" => Some(true),
1290 "false" | "0" | "no" => Some(false),
1291 _ => None,
1292 }
1293}
1294
1295fn is_breaking_change(detected_type: &str, original_message: &str) -> bool {
1297 detected_type.contains("BREAKING") || original_message.contains("BREAKING CHANGE")
1298}
1299
1300fn check_checkbox(description: &mut String, search_text: &str) {
1302 if let Some(pos) = description.find(search_text) {
1303 description.replace_range(pos..pos + 5, "- [x]");
1304 }
1305}
1306
1307fn format_scopes_section(scopes: &[String]) -> String {
1311 if scopes.is_empty() {
1312 return String::new();
1313 }
1314 format!("**Affected areas:** {}\n\n", scopes.join(", "))
1315}
1316
1317fn format_commit_list(entries: &[(&str, &str)]) -> String {
1319 let mut output = String::from("### Commits in this PR:\n");
1320 for (hash, message) in entries {
1321 output.push_str(&format!("- `{hash}` {message}\n"));
1322 }
1323 output
1324}
1325
1326fn clean_branch_name(branch: &str) -> String {
1328 branch.replace(['/', '-', '_'], " ")
1329}
1330
1331fn extract_first_line(text: &str) -> &str {
1333 text.lines().next().unwrap_or("").trim()
1334}
1335
1336fn format_draft_status(is_draft: bool) -> (&'static str, &'static str) {
1338 if is_draft {
1339 ("\u{1f4cb}", "draft")
1340 } else {
1341 ("\u{2705}", "ready for review")
1342 }
1343}
1344
1345#[derive(Debug, Clone)]
1347pub struct CreatePrOutcome {
1348 pub title: String,
1350 pub description: String,
1352 pub pr_yaml: String,
1354}
1355
1356pub async fn run_create_pr(
1365 model: Option<String>,
1366 base_branch: Option<&str>,
1367 repo_path: Option<&std::path::Path>,
1368) -> Result<CreatePrOutcome> {
1369 let _cwd_guard = match repo_path {
1370 Some(p) => Some(super::CwdGuard::enter(p).await?),
1371 None => None,
1372 };
1373
1374 crate::utils::check_pr_command_prerequisites(model.as_deref())?;
1375
1376 let cmd = CreatePrCommand {
1377 base: base_branch.map(str::to_string),
1378 model: model.clone(),
1379 auto_apply: true,
1380 save_only: None,
1381 ready: false,
1382 draft: false,
1383 context_dir: None,
1384 no_push: true,
1385 };
1386
1387 let repo_view = cmd.generate_repository_view()?;
1388 let context = cmd.collect_context(&repo_view).await?;
1389 let claude_client = crate::claude::create_default_claude_client(model, None)?;
1390 run_create_pr_with_client(&cmd, &repo_view, &context, &claude_client).await
1391}
1392
1393pub(crate) async fn run_create_pr_with_client(
1401 cmd: &CreatePrCommand,
1402 repo_view: &crate::data::RepositoryView,
1403 context: &crate::data::context::CommitContext,
1404 claude_client: &crate::claude::client::ClaudeClient,
1405) -> Result<CreatePrOutcome> {
1406 let pr_template = match &repo_view.pr_template {
1407 Some(template) => template.clone(),
1408 None => cmd.get_default_pr_template(),
1409 };
1410
1411 let pr_content = match claude_client
1412 .generate_pr_content_with_context(repo_view, &pr_template, context)
1413 .await
1414 {
1415 Ok(content) => content,
1416 Err(_e) => {
1417 let mut description = pr_template;
1418 cmd.enhance_description_with_commits(&mut description, repo_view)?;
1419 let title = cmd.generate_title_from_commits(repo_view);
1420 PrContent { title, description }
1421 }
1422 };
1423
1424 let pr_yaml = crate::data::to_yaml(&pr_content).context("Failed to serialise PrContent")?;
1425
1426 Ok(CreatePrOutcome {
1427 title: pr_content.title,
1428 description: pr_content.description,
1429 pr_yaml,
1430 })
1431}
1432
1433#[cfg(test)]
1434#[allow(clippy::unwrap_used, clippy::expect_used)]
1435mod run_create_pr_tests {
1436 use super::*;
1437 use crate::claude::client::ClaudeClient;
1438 use crate::claude::test_utils::ConfigurableMockAiClient;
1439 use crate::data::context::CommitContext;
1440 use crate::data::{
1441 AiInfo, BranchInfo, FieldExplanation, RepositoryView, VersionInfo, WorkingDirectoryInfo,
1442 };
1443 use crate::git::commit::FileChanges;
1444 use crate::git::{CommitAnalysis, CommitInfo};
1445
1446 #[tokio::test]
1447 async fn run_create_pr_invalid_repo_path_errors_before_ai() {
1448 let err = run_create_pr(
1449 None,
1450 None,
1451 Some(std::path::Path::new("/no/such/path/exists")),
1452 )
1453 .await
1454 .unwrap_err();
1455 let msg = format!("{err:#}");
1456 assert!(
1457 msg.to_lowercase().contains("set_current_dir")
1458 || msg.to_lowercase().contains("no such")
1459 || msg.to_lowercase().contains("directory"),
1460 "expected cwd-related error, got: {msg}"
1461 );
1462 }
1463
1464 fn fresh_cmd() -> CreatePrCommand {
1465 CreatePrCommand {
1466 base: None,
1467 model: None,
1468 auto_apply: true,
1469 save_only: None,
1470 ready: false,
1471 draft: false,
1472 context_dir: None,
1473 no_push: true,
1474 }
1475 }
1476
1477 fn sample_commit(hash: &str, message: &str) -> (CommitInfo, tempfile::NamedTempFile) {
1478 let tmp = tempfile::NamedTempFile::new().unwrap();
1479 let commit = CommitInfo {
1480 hash: hash.to_string(),
1481 author: "Test <test@test.com>".to_string(),
1482 date: chrono::Utc::now().fixed_offset(),
1483 original_message: message.to_string(),
1484 in_main_branches: vec![],
1485 analysis: CommitAnalysis {
1486 detected_type: "feat".to_string(),
1487 detected_scope: String::new(),
1488 proposed_message: message.to_string(),
1489 file_changes: FileChanges {
1490 total_files: 0,
1491 files_added: 0,
1492 files_deleted: 0,
1493 file_list: vec![],
1494 },
1495 diff_summary: String::new(),
1496 diff_file: tmp.path().to_string_lossy().to_string(),
1497 file_diffs: Vec::new(),
1498 },
1499 };
1500 (commit, tmp)
1501 }
1502
1503 fn sample_repo_view(commits: Vec<CommitInfo>, pr_template: Option<String>) -> RepositoryView {
1504 RepositoryView {
1505 versions: Some(VersionInfo {
1506 omni_dev: "0.0.0".to_string(),
1507 }),
1508 explanation: FieldExplanation::default(),
1509 working_directory: WorkingDirectoryInfo {
1510 clean: true,
1511 untracked_changes: vec![],
1512 },
1513 remotes: vec![],
1514 ai: AiInfo {
1515 scratch: String::new(),
1516 },
1517 branch_info: Some(BranchInfo {
1518 branch: "feature/test".to_string(),
1519 }),
1520 pr_template,
1521 pr_template_location: None,
1522 branch_prs: None,
1523 commits,
1524 }
1525 }
1526
1527 #[tokio::test]
1528 async fn run_create_pr_with_client_ai_success_returns_content() {
1529 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1530 let repo_view = sample_repo_view(vec![c1], None);
1531 let context = CommitContext::new();
1532 let cmd = fresh_cmd();
1533
1534 let yaml = "title: My PR\ndescription: |\n Body text\n".to_string();
1535 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1536 let client = ClaudeClient::new(Box::new(mock));
1537
1538 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1539 .await
1540 .unwrap();
1541 assert_eq!(outcome.title, "My PR");
1542 assert!(outcome.description.contains("Body text"));
1543 assert!(outcome.pr_yaml.contains("title:"));
1544 }
1545
1546 #[tokio::test]
1547 async fn run_create_pr_with_client_ai_failure_falls_back_to_commit_summary() {
1548 let (c1, _tmp) = sample_commit("abcdef00", "feat: single commit subject");
1549 let repo_view = sample_repo_view(vec![c1], None);
1550 let context = CommitContext::new();
1551 let cmd = fresh_cmd();
1552
1553 let mock = ConfigurableMockAiClient::new(vec![]);
1555 let client = ClaudeClient::new(Box::new(mock));
1556
1557 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1558 .await
1559 .unwrap();
1560 assert!(
1561 outcome.title.contains("feat: single commit subject")
1562 || outcome.title.contains("Pull Request")
1563 || outcome.title.contains("feature/test"),
1564 "fallback title unexpected: {}",
1565 outcome.title
1566 );
1567 }
1568
1569 #[tokio::test]
1570 async fn run_create_pr_with_client_uses_repo_template_when_present() {
1571 let (c1, _tmp) = sample_commit("abcdef00", "feat: x");
1572 let repo_view = sample_repo_view(vec![c1], Some("# Custom template\n".to_string()));
1573 let context = CommitContext::new();
1574 let cmd = fresh_cmd();
1575
1576 let mock = ConfigurableMockAiClient::new(vec![]);
1578 let client = ClaudeClient::new(Box::new(mock));
1579
1580 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1581 .await
1582 .unwrap();
1583 assert!(
1584 outcome.description.contains("# Custom template"),
1585 "fallback description should include repo template: {}",
1586 outcome.description
1587 );
1588 }
1589
1590 #[test]
1591 fn create_pr_outcome_clone_and_debug() {
1592 let outcome = CreatePrOutcome {
1593 title: "t".to_string(),
1594 description: "d".to_string(),
1595 pr_yaml: "y".to_string(),
1596 };
1597 let cloned = outcome.clone();
1598 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
1599 }
1600}
1601
1602#[cfg(test)]
1603mod tests {
1604 use super::*;
1605
1606 #[test]
1609 fn parse_bool_true_variants() {
1610 assert_eq!(parse_bool_string("true"), Some(true));
1611 assert_eq!(parse_bool_string("1"), Some(true));
1612 assert_eq!(parse_bool_string("yes"), Some(true));
1613 }
1614
1615 #[test]
1616 fn parse_bool_false_variants() {
1617 assert_eq!(parse_bool_string("false"), Some(false));
1618 assert_eq!(parse_bool_string("0"), Some(false));
1619 assert_eq!(parse_bool_string("no"), Some(false));
1620 }
1621
1622 #[test]
1623 fn parse_bool_invalid() {
1624 assert_eq!(parse_bool_string("maybe"), None);
1625 assert_eq!(parse_bool_string(""), None);
1626 }
1627
1628 #[test]
1629 fn parse_bool_case_insensitive() {
1630 assert_eq!(parse_bool_string("TRUE"), Some(true));
1631 assert_eq!(parse_bool_string("Yes"), Some(true));
1632 assert_eq!(parse_bool_string("FALSE"), Some(false));
1633 assert_eq!(parse_bool_string("No"), Some(false));
1634 }
1635
1636 #[test]
1639 fn breaking_change_type_contains() {
1640 assert!(is_breaking_change("BREAKING", "normal message"));
1641 }
1642
1643 #[test]
1644 fn breaking_change_message_contains() {
1645 assert!(is_breaking_change("feat", "BREAKING CHANGE: removed API"));
1646 }
1647
1648 #[test]
1649 fn breaking_change_none() {
1650 assert!(!is_breaking_change("feat", "add new feature"));
1651 }
1652
1653 #[test]
1656 fn check_checkbox_found() {
1657 let mut desc = "- [ ] New feature\n- [ ] Bug fix".to_string();
1658 check_checkbox(&mut desc, "- [ ] New feature");
1659 assert!(desc.contains("- [x] New feature"));
1660 assert!(desc.contains("- [ ] Bug fix"));
1661 }
1662
1663 #[test]
1664 fn check_checkbox_not_found() {
1665 let mut desc = "- [ ] Bug fix".to_string();
1666 let original = desc.clone();
1667 check_checkbox(&mut desc, "- [ ] New feature");
1668 assert_eq!(desc, original);
1669 }
1670
1671 #[test]
1674 fn scopes_section_single() {
1675 let scopes = vec!["cli".to_string()];
1676 assert_eq!(
1677 format_scopes_section(&scopes),
1678 "**Affected areas:** cli\n\n"
1679 );
1680 }
1681
1682 #[test]
1683 fn scopes_section_multiple() {
1684 let scopes = vec!["cli".to_string(), "git".to_string()];
1685 let result = format_scopes_section(&scopes);
1686 assert!(result.contains("cli"));
1687 assert!(result.contains("git"));
1688 assert!(result.starts_with("**Affected areas:**"));
1689 }
1690
1691 #[test]
1692 fn scopes_section_empty() {
1693 assert_eq!(format_scopes_section(&[]), "");
1694 }
1695
1696 #[test]
1699 fn commit_list_formatting() {
1700 let entries = vec![
1701 ("abc12345", "feat: add feature"),
1702 ("def67890", "fix: resolve bug"),
1703 ];
1704 let result = format_commit_list(&entries);
1705 assert!(result.contains("### Commits in this PR:"));
1706 assert!(result.contains("- `abc12345` feat: add feature"));
1707 assert!(result.contains("- `def67890` fix: resolve bug"));
1708 }
1709
1710 #[test]
1713 fn clean_branch_simple() {
1714 assert_eq!(clean_branch_name("feat/add-login"), "feat add login");
1715 }
1716
1717 #[test]
1718 fn clean_branch_underscores() {
1719 assert_eq!(clean_branch_name("user_name/fix_bug"), "user name fix bug");
1720 }
1721
1722 #[test]
1725 fn first_line_multiline() {
1726 assert_eq!(extract_first_line("first\nsecond\nthird"), "first");
1727 }
1728
1729 #[test]
1730 fn first_line_single() {
1731 assert_eq!(extract_first_line("only line"), "only line");
1732 }
1733
1734 #[test]
1735 fn first_line_empty() {
1736 assert_eq!(extract_first_line(""), "");
1737 }
1738
1739 #[test]
1742 fn draft_status_true() {
1743 let (icon, text) = format_draft_status(true);
1744 assert_eq!(text, "draft");
1745 assert!(!icon.is_empty());
1746 }
1747
1748 #[test]
1749 fn draft_status_false() {
1750 let (icon, text) = format_draft_status(false);
1751 assert_eq!(text, "ready for review");
1752 assert!(!icon.is_empty());
1753 }
1754
1755 #[test]
1758 fn push_action_skip_when_no_push() {
1759 assert_eq!(determine_push_action(true, false), PushAction::Skip);
1760 assert_eq!(determine_push_action(true, true), PushAction::Skip);
1761 }
1762
1763 #[test]
1764 fn push_action_sync_existing_branch() {
1765 assert_eq!(determine_push_action(false, true), PushAction::SyncExisting);
1766 }
1767
1768 #[test]
1769 fn push_action_push_new_branch() {
1770 assert_eq!(determine_push_action(false, false), PushAction::PushNew);
1771 }
1772}