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