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