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 auto_apply: bool,
19
20 #[arg(long, value_name = "FILE")]
22 pub save_only: Option<String>,
23
24 #[arg(long, conflicts_with = "draft")]
26 pub ready: bool,
27
28 #[arg(long, conflicts_with = "ready")]
30 pub draft: bool,
31
32 #[arg(long)]
34 pub context_dir: Option<std::path::PathBuf>,
35
36 #[arg(long)]
38 pub no_push: bool,
39
40 #[arg(long)]
42 pub from_commits: 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, repo: Option<&std::path::Path>) -> Result<()> {
91 let repo_root = match repo {
95 Some(p) => p.to_path_buf(),
96 None => std::env::current_dir().context("Failed to determine current directory")?,
97 };
98 let repo_root = repo_root.as_path();
99
100 let ai_info = crate::utils::check_pr_command_prerequisites(None, repo_root)?;
105 println!(
106 "✓ {} credentials verified (model: {})",
107 ai_info.provider, ai_info.model
108 );
109 println!("✓ GitHub CLI verified");
110
111 println!("🔄 Starting pull request creation process...");
112
113 let repo_view = self.generate_repository_view(repo_root)?;
115
116 self.validate_branch_state(&repo_view)?;
118
119 use crate::claude::context::ProjectDiscovery;
121 let context_dir =
122 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
123 let discovery = ProjectDiscovery::new(repo_root.to_path_buf(), context_dir);
124 let project_context = discovery.discover().unwrap_or_default();
125 self.show_guidance_files_status(repo_root, &project_context)?;
126
127 let claude_client = crate::claude::create_default_claude_client(None, None).await?;
129 self.show_model_info_from_client(&claude_client)?;
130
131 self.show_commit_range_info(&repo_view)?;
133
134 let context = {
136 use crate::claude::context::{BranchAnalyzer, FileAnalyzer, WorkPatternAnalyzer};
137 use crate::data::context::CommitContext;
138 let mut context = CommitContext::new();
139 context.project = project_context;
140
141 if let Some(branch_info) = &repo_view.branch_info {
143 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
144 }
145
146 if !repo_view.commits.is_empty() {
147 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
148 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
149 }
150 context
151 };
152 self.show_context_summary(&context)?;
153
154 debug!("About to generate PR content from AI");
156 let (pr_content, _claude_client) = self
157 .generate_pr_content_with_client_internal(repo_root, &repo_view, claude_client)
158 .await?;
159
160 self.show_context_information(&repo_view).await?;
162 debug!(
163 generated_title = %pr_content.title,
164 generated_description_length = pr_content.description.len(),
165 generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
166 "Generated PR content from AI"
167 );
168
169 if let Some(save_path) = self.save_only {
171 let pr_yaml = crate::data::to_yaml(&pr_content)
172 .context("Failed to serialize PR content to YAML")?;
173 std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
174 println!("💾 PR details saved to: {save_path}");
175 return Ok(());
176 }
177
178 debug!("About to serialize PR content to YAML");
180 let temp_dir = tempfile::tempdir()?;
181 let pr_file = temp_dir.path().join("pr-details.yaml");
182
183 debug!(
184 pre_serialize_title = %pr_content.title,
185 pre_serialize_description_length = pr_content.description.len(),
186 pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
187 "About to serialize PR content with to_yaml"
188 );
189
190 let pr_yaml =
191 crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
192
193 debug!(
194 file_path = %pr_file.display(),
195 yaml_content_length = pr_yaml.len(),
196 yaml_content = %pr_yaml,
197 original_title = %pr_content.title,
198 original_description_length = pr_content.description.len(),
199 "Writing PR details to temporary YAML file"
200 );
201
202 std::fs::write(&pr_file, &pr_yaml)?;
203
204 let pr_action = if self.auto_apply {
206 if repo_view
208 .branch_prs
209 .as_ref()
210 .is_some_and(|prs| !prs.is_empty())
211 {
212 PrAction::UpdateExisting
213 } else {
214 PrAction::CreateNew
215 }
216 } else {
217 self.handle_pr_file(&pr_file, &repo_view)?
218 };
219
220 if pr_action == PrAction::Cancel {
221 println!("❌ PR operation cancelled by user");
222 return Ok(());
223 }
224
225 let final_pr_yaml =
227 std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
228
229 debug!(
230 yaml_length = final_pr_yaml.len(),
231 yaml_content = %final_pr_yaml,
232 "Read PR details YAML from file"
233 );
234
235 let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
236 .context("Failed to parse PR details YAML. Please check the file format.")?;
237
238 debug!(
239 title = %final_pr_content.title,
240 description_length = final_pr_content.description.len(),
241 description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
242 "Parsed PR content from YAML"
243 );
244
245 let is_draft = self.should_create_as_draft();
247
248 match pr_action {
249 PrAction::CreateNew => {
250 self.create_github_pr(
251 repo_root,
252 &repo_view,
253 &final_pr_content.title,
254 &final_pr_content.description,
255 is_draft,
256 self.base.as_deref(),
257 )?;
258 println!("✅ Pull request created successfully!");
259 }
260 PrAction::UpdateExisting => {
261 self.update_github_pr(
262 repo_root,
263 &repo_view,
264 &final_pr_content.title,
265 &final_pr_content.description,
266 self.base.as_deref(),
267 )?;
268 println!("✅ Pull request updated successfully!");
269 }
270 PrAction::Cancel => unreachable!(), }
272
273 Ok(())
274 }
275
276 fn generate_repository_view(
278 &self,
279 repo_root: &std::path::Path,
280 ) -> Result<crate::data::RepositoryView> {
281 use crate::data::{
282 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
283 WorkingDirectoryInfo,
284 };
285 use crate::git::{GitRepository, RemoteInfo};
286 use crate::utils::ai_scratch;
287
288 let repo = GitRepository::open_at(repo_root)
290 .context("Failed to open git repository at the given path")?;
291
292 let current_branch = repo.get_current_branch().context(
294 "Failed to get current branch. Make sure you're not in detached HEAD state.",
295 )?;
296
297 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
299
300 let primary_remote = remotes
302 .iter()
303 .find(|r| r.name == "origin")
304 .or_else(|| remotes.first())
305 .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
306
307 let base_branch = if let Some(branch) = self.base.as_ref() {
309 let remote_ref = format!("refs/remotes/{branch}");
312 if repo.repository().find_reference(&remote_ref).is_ok() {
313 branch.clone()
314 } else {
315 let with_remote = format!("{}/{}", primary_remote.name, branch);
317 let remote_ref = format!("refs/remotes/{with_remote}");
318 if repo.repository().find_reference(&remote_ref).is_ok() {
319 with_remote
320 } else {
321 anyhow::bail!(
322 "Remote branch '{branch}' does not exist (also tried '{with_remote}')"
323 );
324 }
325 }
326 } else {
327 let main_branch = &primary_remote.main_branch;
329 if main_branch == "unknown" {
330 let remote_name = &primary_remote.name;
331 anyhow::bail!("Could not determine main branch for remote '{remote_name}'");
332 }
333
334 let remote_main = format!("{}/{}", primary_remote.name, main_branch);
335
336 let remote_ref = format!("refs/remotes/{remote_main}");
338 if repo.repository().find_reference(&remote_ref).is_err() {
339 anyhow::bail!(
340 "Remote main branch '{remote_main}' does not exist. Try running 'git fetch' first."
341 );
342 }
343
344 remote_main
345 };
346
347 let commit_range = format!("{base_branch}..HEAD");
349
350 let wd_status = repo.get_working_directory_status()?;
352 let working_directory = WorkingDirectoryInfo {
353 clean: wd_status.clean,
354 untracked_changes: wd_status
355 .untracked_changes
356 .into_iter()
357 .map(|fs| FileStatusInfo {
358 status: fs.status,
359 file: fs.file,
360 })
361 .collect(),
362 };
363
364 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
366
367 let commits = repo.get_commits_in_range(&commit_range)?;
369
370 let pr_template_result = InfoCommand::read_pr_template(repo_root).ok();
372 let (pr_template, pr_template_location) = match pr_template_result {
373 Some((content, location)) => (Some(content), Some(location)),
374 None => (None, None),
375 };
376
377 let branch_prs = InfoCommand::get_branch_prs(¤t_branch, repo_root)
379 .ok()
380 .filter(|prs| !prs.is_empty());
381
382 let versions = Some(VersionInfo {
384 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
385 });
386
387 let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
389 .context("Failed to determine AI scratch directory")?;
390 let ai_info = AiInfo {
391 scratch: ai_scratch_path.to_string_lossy().to_string(),
392 };
393
394 let mut repo_view = RepositoryView {
396 versions,
397 explanation: FieldExplanation::default(),
398 working_directory,
399 remotes,
400 ai: ai_info,
401 branch_info: Some(BranchInfo {
402 branch: current_branch,
403 }),
404 pr_template,
405 pr_template_location,
406 branch_prs,
407 commits,
408 };
409
410 repo_view.update_field_presence();
412
413 Ok(repo_view)
414 }
415
416 fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
418 if !repo_view.working_directory.clean {
420 anyhow::bail!(
421 "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
422 );
423 }
424
425 if !repo_view.working_directory.untracked_changes.is_empty() {
427 let file_list: Vec<&str> = repo_view
428 .working_directory
429 .untracked_changes
430 .iter()
431 .map(|f| f.file.as_str())
432 .collect();
433 anyhow::bail!(
434 "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
435 file_list.join(", ")
436 );
437 }
438
439 if repo_view.commits.is_empty() {
441 anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
442 }
443
444 if let Some(existing_prs) = &repo_view.branch_prs {
446 if !existing_prs.is_empty() {
447 let pr_info: Vec<String> = existing_prs
448 .iter()
449 .map(|pr| format!("#{} ({})", pr.number, pr.state))
450 .collect();
451
452 println!(
453 "📋 Existing PR(s) found for this branch: {}",
454 pr_info.join(", ")
455 );
456 }
458 }
459
460 Ok(())
461 }
462
463 async fn show_context_information(
465 &self,
466 _repo_view: &crate::data::RepositoryView,
467 ) -> Result<()> {
468 Ok(())
473 }
474
475 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
477 let base_branch = match self.base.as_ref() {
479 Some(branch) => {
480 let primary_remote_name = repo_view
483 .remotes
484 .iter()
485 .find(|r| r.name == "origin")
486 .or_else(|| repo_view.remotes.first())
487 .map_or("origin", |r| r.name.as_str());
488 if branch.starts_with(&format!("{primary_remote_name}/")) {
490 branch.clone()
491 } else {
492 format!("{primary_remote_name}/{branch}")
493 }
494 }
495 None => {
496 repo_view
498 .remotes
499 .iter()
500 .find(|r| r.name == "origin")
501 .or_else(|| repo_view.remotes.first())
502 .map_or_else(
503 || "unknown".to_string(),
504 |r| format!("{}/{}", r.name, r.main_branch),
505 )
506 }
507 };
508
509 let commit_range = format!("{base_branch}..HEAD");
510 let commit_count = repo_view.commits.len();
511
512 let current_branch = repo_view
514 .branch_info
515 .as_ref()
516 .map_or("unknown", |bi| bi.branch.as_str());
517
518 println!("📊 Branch Analysis:");
519 println!(" 🌿 Current branch: {current_branch}");
520 println!(" 📏 Commit range: {commit_range}");
521 println!(" 📝 Commits found: {commit_count} commits");
522 println!();
523
524 Ok(())
525 }
526
527 async fn collect_context(
529 &self,
530 repo_root: &std::path::Path,
531 repo_view: &crate::data::RepositoryView,
532 ) -> Result<crate::data::context::CommitContext> {
533 use crate::claude::context::{
534 BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
535 };
536 use crate::data::context::{CommitContext, ProjectContext};
537 use crate::git::GitRepository;
538
539 let mut context = CommitContext::new();
540
541 let context_dir =
543 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
544
545 let discovery = ProjectDiscovery::new(repo_root.to_path_buf(), context_dir);
547 match discovery.discover() {
548 Ok(project_context) => {
549 context.project = project_context;
550 }
551 Err(_e) => {
552 context.project = ProjectContext::default();
553 }
554 }
555
556 let repo = GitRepository::open_at(repo_root)?;
558 let current_branch = repo
559 .get_current_branch()
560 .unwrap_or_else(|_| "HEAD".to_string());
561 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
562
563 if !repo_view.commits.is_empty() {
565 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
566 }
567
568 if !repo_view.commits.is_empty() {
570 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
571 }
572
573 Ok(context)
574 }
575
576 fn show_guidance_files_status(
578 &self,
579 repo_root: &std::path::Path,
580 project_context: &crate::data::context::ProjectContext,
581 ) -> Result<()> {
582 use crate::claude::context::{
583 config_source_label, resolve_context_dir_with_source_at, ConfigSourceLabel,
584 };
585
586 let (context_dir, dir_source) =
587 resolve_context_dir_with_source_at(self.context_dir.as_deref(), repo_root);
588
589 println!("📋 Project guidance files status:");
590 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
591
592 let pr_guidelines_source = if project_context.pr_guidelines.is_some() {
594 match config_source_label(&context_dir, "pr-guidelines.md") {
595 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
596 label => format!("✅ {label}"),
597 }
598 } else {
599 "❌ None found".to_string()
600 };
601 println!(" 🔀 PR guidelines: {pr_guidelines_source}");
602
603 let scopes_count = project_context.valid_scopes.len();
605 let scopes_source = if scopes_count > 0 {
606 match config_source_label(&context_dir, "scopes.yaml") {
607 ConfigSourceLabel::NotFound => {
608 format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
609 }
610 label => format!("✅ {label} ({scopes_count} scopes)"),
611 }
612 } else {
613 "❌ None found".to_string()
614 };
615 println!(" 🎯 Valid scopes: {scopes_source}");
616
617 let pr_template_path = repo_root.join(".github/pull_request_template.md");
619 let pr_template_status = if pr_template_path.exists() {
620 format!("✅ Project: {}", pr_template_path.display())
621 } else {
622 "❌ None found".to_string()
623 };
624 println!(" 📋 PR template: {pr_template_status}");
625
626 println!();
627 Ok(())
628 }
629
630 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
632 use crate::data::context::{VerbosityLevel, WorkPattern};
633
634 println!("🔍 Context Analysis:");
635
636 if !context.project.valid_scopes.is_empty() {
638 let scope_names: Vec<&str> = context
639 .project
640 .valid_scopes
641 .iter()
642 .map(|s| s.name.as_str())
643 .collect();
644 println!(" 📁 Valid scopes: {}", scope_names.join(", "));
645 }
646
647 if context.branch.is_feature_branch {
649 println!(
650 " 🌿 Branch: {} ({})",
651 context.branch.description, context.branch.work_type
652 );
653 if let Some(ref ticket) = context.branch.ticket_id {
654 println!(" 🎫 Ticket: {ticket}");
655 }
656 }
657
658 match context.range.work_pattern {
660 WorkPattern::Sequential => println!(" 🔄 Pattern: Sequential development"),
661 WorkPattern::Refactoring => println!(" 🧹 Pattern: Refactoring work"),
662 WorkPattern::BugHunt => println!(" 🐛 Pattern: Bug investigation"),
663 WorkPattern::Documentation => println!(" 📖 Pattern: Documentation updates"),
664 WorkPattern::Configuration => println!(" ⚙️ Pattern: Configuration changes"),
665 WorkPattern::Unknown => {}
666 }
667
668 if let Some(label) = super::formatting::format_file_analysis(&context.files) {
670 println!(" {label}");
671 }
672
673 match context.suggested_verbosity() {
675 VerbosityLevel::Comprehensive => {
676 println!(" 📝 Detail level: Comprehensive (significant changes detected)");
677 }
678 VerbosityLevel::Detailed => println!(" 📝 Detail level: Detailed"),
679 VerbosityLevel::Concise => println!(" 📝 Detail level: Concise"),
680 }
681
682 println!();
683 Ok(())
684 }
685
686 async fn generate_pr_content_with_client_internal(
688 &self,
689 repo_root: &std::path::Path,
690 repo_view: &crate::data::RepositoryView,
691 claude_client: crate::claude::client::ClaudeClient,
692 ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
693 use tracing::debug;
694
695 let pr_template = match &repo_view.pr_template {
697 Some(template) => template.clone(),
698 None => self.get_default_pr_template(),
699 };
700
701 debug!(
702 pr_template_length = pr_template.len(),
703 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
704 "Using PR template for generation"
705 );
706
707 println!("🤖 Generating AI-powered PR description...");
708
709 debug!("Collecting context for PR generation");
711 let context = self.collect_context(repo_root, repo_view).await?;
712 debug!("Context collection completed");
713
714 debug!(
716 from_commits = self.from_commits,
717 "About to call Claude AI for PR content generation"
718 );
719 let ai_result = if self.from_commits {
720 claude_client
721 .generate_pr_content_with_context_from_commits(repo_view, &pr_template, &context)
722 .await
723 } else {
724 claude_client
725 .generate_pr_content_with_context(repo_view, &pr_template, &context)
726 .await
727 };
728 match ai_result {
729 Ok(pr_content) => {
730 debug!(
731 ai_generated_title = %pr_content.title,
732 ai_generated_description_length = pr_content.description.len(),
733 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
734 "AI successfully generated PR content"
735 );
736 Ok((pr_content, claude_client))
737 }
738 Err(e) => {
739 debug!(error = %e, "AI PR generation failed, falling back to basic description");
740 let mut description = pr_template;
742 self.enhance_description_with_commits(&mut description, repo_view)?;
743
744 let title = self.generate_title_from_commits(repo_view);
746
747 debug!(
748 fallback_title = %title,
749 fallback_description_length = description.len(),
750 "Created fallback PR content"
751 );
752
753 Ok((PrContent { title, description }, claude_client))
754 }
755 }
756 }
757
758 fn get_default_pr_template(&self) -> String {
760 r#"# Pull Request
761
762## Description
763<!-- Provide a brief description of what this PR does -->
764
765## Type of Change
766<!-- Mark the relevant option with an "x" -->
767- [ ] Bug fix (non-breaking change which fixes an issue)
768- [ ] New feature (non-breaking change which adds functionality)
769- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
770- [ ] Documentation update
771- [ ] Refactoring (no functional changes)
772- [ ] Performance improvement
773- [ ] Test coverage improvement
774
775## Changes Made
776<!-- List the specific changes made in this PR -->
777-
778-
779-
780
781## Testing
782- [ ] All existing tests pass
783- [ ] New tests added for new functionality
784- [ ] Manual testing performed
785
786## Additional Notes
787<!-- Add any additional notes for reviewers -->
788"#.to_string()
789 }
790
791 fn enhance_description_with_commits(
793 &self,
794 description: &mut String,
795 repo_view: &crate::data::RepositoryView,
796 ) -> Result<()> {
797 if repo_view.commits.is_empty() {
798 return Ok(());
799 }
800
801 description.push_str("\n---\n");
803 description.push_str("## 📝 Commit Summary\n");
804 description
805 .push_str("*This section was automatically generated based on commit analysis*\n\n");
806
807 let mut types_found = std::collections::HashSet::new();
809 let mut scopes_found = std::collections::HashSet::new();
810 let mut has_breaking_changes = false;
811
812 for commit in &repo_view.commits {
813 let detected_type = &commit.analysis.detected_type;
814 types_found.insert(detected_type.clone());
815 if is_breaking_change(detected_type, &commit.original_message) {
816 has_breaking_changes = true;
817 }
818
819 let detected_scope = &commit.analysis.detected_scope;
820 if !detected_scope.is_empty() {
821 scopes_found.insert(detected_scope.clone());
822 }
823 }
824
825 if types_found.contains("feat") {
827 check_checkbox(description, "- [ ] New feature");
828 }
829 if types_found.contains("fix") {
830 check_checkbox(description, "- [ ] Bug fix");
831 }
832 if types_found.contains("docs") {
833 check_checkbox(description, "- [ ] Documentation update");
834 }
835 if types_found.contains("refactor") {
836 check_checkbox(description, "- [ ] Refactoring");
837 }
838 if has_breaking_changes {
839 check_checkbox(description, "- [ ] Breaking change");
840 }
841
842 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
844 let scopes_section = format_scopes_section(&scopes_list);
845 if !scopes_section.is_empty() {
846 description.push_str(&scopes_section);
847 }
848
849 let commit_entries: Vec<(&str, &str)> = repo_view
851 .commits
852 .iter()
853 .map(|c| {
854 let short = &c.hash[..crate::git::SHORT_HASH_LEN];
855 let first = extract_first_line(&c.original_message);
856 (short, first)
857 })
858 .collect();
859 description.push_str(&format_commit_list(&commit_entries));
860
861 let total_files: usize = repo_view
863 .commits
864 .iter()
865 .map(|c| c.analysis.file_changes.total_files)
866 .sum();
867
868 if total_files > 0 {
869 description.push_str(&format!("\n**Files changed:** {total_files} files\n"));
870 }
871
872 Ok(())
873 }
874
875 fn handle_pr_file(
877 &self,
878 pr_file: &std::path::Path,
879 repo_view: &crate::data::RepositoryView,
880 ) -> Result<PrAction> {
881 use std::io::{self, Write};
882
883 println!("\n📝 PR details generated.");
884 println!("💾 Details saved to: {}", pr_file.display());
885
886 let is_draft = self.should_create_as_draft();
888 let (status_icon, status_text) = format_draft_status(is_draft);
889 println!("{status_icon} PR will be created as: {status_text}");
890 println!();
891
892 let has_existing_prs = repo_view
894 .branch_prs
895 .as_ref()
896 .is_some_and(|prs| !prs.is_empty());
897
898 loop {
899 if has_existing_prs {
900 print!("❓ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
901 } else {
902 print!(
903 "❓ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
904 );
905 }
906 io::stdout().flush()?;
907
908 let mut input = String::new();
909 io::stdin().read_line(&mut input)?;
910
911 match input.trim().to_lowercase().as_str() {
912 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
913 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
914 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
915 "s" | "show" => {
916 self.show_pr_file(pr_file)?;
917 println!();
918 }
919 "e" | "edit" => {
920 self.edit_pr_file(pr_file)?;
921 println!();
922 }
923 "q" | "quit" => return Ok(PrAction::Cancel),
924 _ => {
925 if has_existing_prs {
926 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
927 } else {
928 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
929 }
930 }
931 }
932 }
933 }
934
935 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
937 use std::fs;
938
939 println!("\n📄 PR details file contents:");
940 println!("─────────────────────────────");
941
942 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
943 println!("{contents}");
944 println!("─────────────────────────────");
945
946 Ok(())
947 }
948
949 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
951 use std::env;
952 use std::io::{self, Write};
953 use std::process::Command;
954
955 let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
957 e
958 } else {
959 println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
961 print!("Please enter the command to use as your editor: ");
962 io::stdout().flush().context("Failed to flush stdout")?;
963
964 let mut input = String::new();
965 io::stdin()
966 .read_line(&mut input)
967 .context("Failed to read user input")?;
968 input.trim().to_string()
969 };
970
971 if editor.is_empty() {
972 println!("❌ No editor specified. Returning to menu.");
973 return Ok(());
974 }
975
976 println!("📝 Opening PR details file in editor: {editor}");
977
978 let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
979
980 let mut command = Command::new(editor_cmd);
981 command.args(args);
982 command.arg(pr_file.to_string_lossy().as_ref());
983
984 match command.status() {
985 Ok(status) => {
986 if status.success() {
987 println!("✅ Editor session completed.");
988 } else {
989 println!(
990 "⚠️ Editor exited with non-zero status: {:?}",
991 status.code()
992 );
993 }
994 }
995 Err(e) => {
996 println!("❌ Failed to execute editor '{editor}': {e}");
997 println!(" Please check that the editor command is correct and available in your PATH.");
998 }
999 }
1000
1001 Ok(())
1002 }
1003
1004 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
1006 if repo_view.commits.is_empty() {
1007 return "Pull Request".to_string();
1008 }
1009
1010 if repo_view.commits.len() == 1 {
1012 let first = extract_first_line(&repo_view.commits[0].original_message);
1013 let trimmed = first.trim();
1014 return if trimmed.is_empty() {
1015 "Pull Request".to_string()
1016 } else {
1017 trimmed.to_string()
1018 };
1019 }
1020
1021 let branch_name = repo_view
1023 .branch_info
1024 .as_ref()
1025 .map_or("feature", |bi| bi.branch.as_str());
1026
1027 format!("feat: {}", clean_branch_name(branch_name))
1028 }
1029
1030 fn create_github_pr(
1032 &self,
1033 repo_root: &std::path::Path,
1034 repo_view: &crate::data::RepositoryView,
1035 title: &str,
1036 description: &str,
1037 is_draft: bool,
1038 new_base: Option<&str>,
1039 ) -> Result<()> {
1040 use std::process::Command;
1041
1042 let branch_name = repo_view
1044 .branch_info
1045 .as_ref()
1046 .map(|bi| &bi.branch)
1047 .context("Branch info not available")?;
1048
1049 let pr_status = if is_draft {
1050 "draft"
1051 } else {
1052 "ready for review"
1053 };
1054 println!("🚀 Creating pull request ({pr_status})...");
1055 println!(" 📋 Title: {title}");
1056 println!(" 🌿 Branch: {branch_name}");
1057 if let Some(base) = new_base {
1058 println!(" 🎯 Base: {base}");
1059 }
1060
1061 let push_action = if self.no_push {
1063 determine_push_action(true, false)
1064 } else {
1065 debug!("Opening git repository to check branch status");
1066 let git_repo = crate::git::GitRepository::open_at(repo_root)
1067 .context("Failed to open git repository at the given path")?;
1068
1069 debug!(
1070 "Checking if branch '{}' exists on remote 'origin'",
1071 branch_name
1072 );
1073 let branch_on_remote = git_repo.branch_exists_on_remote(branch_name, "origin")?;
1074 let action = determine_push_action(false, branch_on_remote);
1075
1076 debug!("Push action for branch '{}': {:?}", branch_name, action);
1077 println!("📤 Pushing branch to remote...");
1078 git_repo
1079 .push_branch(branch_name, "origin")
1080 .context("Failed to push branch to remote")?;
1081
1082 action
1083 };
1084
1085 if push_action == PushAction::Skip {
1086 debug!("Skipping push (--no-push flag set)");
1087 }
1088
1089 debug!("Creating PR with gh CLI - title: '{}'", title);
1091 debug!("PR description length: {} characters", description.len());
1092 debug!("PR draft status: {}", is_draft);
1093 if let Some(base) = new_base {
1094 debug!("PR base branch: {}", base);
1095 }
1096
1097 let mut args = vec![
1098 "pr",
1099 "create",
1100 "--head",
1101 branch_name,
1102 "--title",
1103 title,
1104 "--body",
1105 description,
1106 ];
1107
1108 if let Some(base) = new_base {
1109 args.push("--base");
1110 args.push(base);
1111 }
1112
1113 if is_draft {
1114 args.push("--draft");
1115 }
1116
1117 let pr_result = Command::new("gh")
1118 .current_dir(repo_root)
1119 .args(&args)
1120 .output()
1121 .context("Failed to create pull request")?;
1122
1123 if pr_result.status.success() {
1124 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
1125 let pr_url = pr_url.trim();
1126 debug!("PR created successfully with URL: {}", pr_url);
1127 println!("🎉 Pull request created: {pr_url}");
1128 } else {
1129 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1130 error!("gh CLI failed to create PR: {}", error_msg);
1131 anyhow::bail!("Failed to create pull request: {error_msg}");
1132 }
1133
1134 Ok(())
1135 }
1136
1137 fn update_github_pr(
1139 &self,
1140 repo_root: &std::path::Path,
1141 repo_view: &crate::data::RepositoryView,
1142 title: &str,
1143 description: &str,
1144 new_base: Option<&str>,
1145 ) -> Result<()> {
1146 use std::io::{self, Write};
1147 use std::process::Command;
1148
1149 let existing_pr = repo_view
1151 .branch_prs
1152 .as_ref()
1153 .and_then(|prs| prs.first())
1154 .context("No existing PR found to update")?;
1155
1156 let pr_number = existing_pr.number;
1157 let current_base = &existing_pr.base;
1158
1159 println!("🚀 Updating pull request #{pr_number}...");
1160 println!(" 📋 Title: {title}");
1161
1162 let change_base = if let Some(base) = new_base {
1164 if !current_base.is_empty() && current_base != base {
1165 print!(" 🎯 Current base: {current_base} → New base: {base}. Change? [y/N]: ");
1166 io::stdout().flush()?;
1167
1168 let mut input = String::new();
1169 io::stdin().read_line(&mut input)?;
1170 let response = input.trim().to_lowercase();
1171 response == "y" || response == "yes"
1172 } else {
1173 false
1174 }
1175 } else {
1176 false
1177 };
1178
1179 debug!(
1180 pr_number = pr_number,
1181 title = %title,
1182 description_length = description.len(),
1183 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1184 change_base = change_base,
1185 "Updating GitHub PR with title and description"
1186 );
1187
1188 let pr_number_str = pr_number.to_string();
1190 let mut gh_args = vec![
1191 "pr",
1192 "edit",
1193 &pr_number_str,
1194 "--title",
1195 title,
1196 "--body",
1197 description,
1198 ];
1199
1200 if change_base {
1201 if let Some(base) = new_base {
1202 gh_args.push("--base");
1203 gh_args.push(base);
1204 }
1205 }
1206
1207 debug!(
1208 args = ?gh_args,
1209 "Executing gh command to update PR"
1210 );
1211
1212 let pr_result = Command::new("gh")
1213 .current_dir(repo_root)
1214 .args(&gh_args)
1215 .output()
1216 .context("Failed to update pull request")?;
1217
1218 if pr_result.status.success() {
1219 println!("🎉 Pull request updated: {}", existing_pr.url);
1221 if change_base {
1222 if let Some(base) = new_base {
1223 println!(" 🎯 Base branch changed to: {base}");
1224 }
1225 }
1226 } else {
1227 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1228 anyhow::bail!("Failed to update pull request: {error_msg}");
1229 }
1230
1231 Ok(())
1232 }
1233
1234 fn show_model_info_from_client(
1236 &self,
1237 client: &crate::claude::client::ClaudeClient,
1238 ) -> Result<()> {
1239 use crate::claude::model_config::get_model_registry;
1240
1241 println!("🤖 AI Model Configuration:");
1242
1243 let metadata = client.get_ai_client_metadata();
1245 let registry = get_model_registry();
1251
1252 if let Some(spec) = registry.get_model_spec(&metadata.model) {
1253 if metadata.model != spec.api_identifier {
1255 println!(
1256 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
1257 metadata.model, spec.api_identifier
1258 );
1259 } else {
1260 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1261 }
1262
1263 println!(" 🏷️ Provider: {}", spec.provider);
1264 println!(" 📊 Generation: {}", spec.generation);
1265 println!(" ⭐ Tier: {} ({})", spec.tier, {
1266 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1267 &tier_info.description
1268 } else {
1269 "No description available"
1270 }
1271 });
1272 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1273 println!(" 📥 Input context: {}", metadata.max_context_length);
1274
1275 if let Some((ref key, ref value)) = metadata.active_beta {
1276 println!(" 🔬 Beta header: {key}: {value}");
1277 }
1278
1279 if spec.legacy {
1280 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
1281 }
1282 } else {
1283 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1285 println!(" 🏷️ Provider: {}", metadata.provider);
1286 println!(" ⚠️ Model not found in registry, using client metadata:");
1287 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1288 println!(" 📥 Input context: {}", metadata.max_context_length);
1289 }
1290
1291 println!();
1292 Ok(())
1293 }
1294}
1295
1296#[derive(Debug, PartialEq)]
1300enum PushAction {
1301 Skip,
1303 SyncExisting,
1305 PushNew,
1307}
1308
1309fn determine_push_action(no_push: bool, branch_on_remote: bool) -> PushAction {
1311 if no_push {
1312 PushAction::Skip
1313 } else if branch_on_remote {
1314 PushAction::SyncExisting
1315 } else {
1316 PushAction::PushNew
1317 }
1318}
1319
1320fn parse_bool_string(val: &str) -> Option<bool> {
1325 match val.to_lowercase().as_str() {
1326 "true" | "1" | "yes" => Some(true),
1327 "false" | "0" | "no" => Some(false),
1328 _ => None,
1329 }
1330}
1331
1332fn is_breaking_change(detected_type: &str, original_message: &str) -> bool {
1334 detected_type.contains("BREAKING") || original_message.contains("BREAKING CHANGE")
1335}
1336
1337fn check_checkbox(description: &mut String, search_text: &str) {
1339 if let Some(pos) = description.find(search_text) {
1340 description.replace_range(pos..pos + 5, "- [x]");
1341 }
1342}
1343
1344fn format_scopes_section(scopes: &[String]) -> String {
1348 if scopes.is_empty() {
1349 return String::new();
1350 }
1351 format!("**Affected areas:** {}\n\n", scopes.join(", "))
1352}
1353
1354fn format_commit_list(entries: &[(&str, &str)]) -> String {
1356 let mut output = String::from("### Commits in this PR:\n");
1357 for (hash, message) in entries {
1358 output.push_str(&format!("- `{hash}` {message}\n"));
1359 }
1360 output
1361}
1362
1363fn clean_branch_name(branch: &str) -> String {
1365 branch.replace(['/', '-', '_'], " ")
1366}
1367
1368fn extract_first_line(text: &str) -> &str {
1370 text.lines().next().unwrap_or("").trim()
1371}
1372
1373fn format_draft_status(is_draft: bool) -> (&'static str, &'static str) {
1375 if is_draft {
1376 ("\u{1f4cb}", "draft")
1377 } else {
1378 ("\u{2705}", "ready for review")
1379 }
1380}
1381
1382#[derive(Debug, Clone)]
1384pub struct CreatePrOutcome {
1385 pub title: String,
1387 pub description: String,
1389 pub pr_yaml: String,
1391}
1392
1393pub async fn run_create_pr(
1402 model: Option<String>,
1403 base_branch: Option<&str>,
1404 repo_path: Option<&std::path::Path>,
1405) -> Result<CreatePrOutcome> {
1406 let repo_root = match repo_path {
1410 Some(p) => p.to_path_buf(),
1411 None => std::env::current_dir().context("Failed to determine current directory")?,
1412 };
1413
1414 crate::utils::check_pr_command_prerequisites(model.as_deref(), &repo_root)?;
1415
1416 let cmd = CreatePrCommand {
1417 base: base_branch.map(str::to_string),
1418 auto_apply: true,
1419 save_only: None,
1420 ready: false,
1421 draft: false,
1422 context_dir: None,
1423 no_push: true,
1424 from_commits: false,
1425 };
1426
1427 let repo_view = cmd.generate_repository_view(&repo_root)?;
1428 let context = cmd.collect_context(&repo_root, &repo_view).await?;
1429 let claude_client = crate::claude::create_default_claude_client(model, None).await?;
1430 run_create_pr_with_client(&cmd, &repo_view, &context, &claude_client).await
1431}
1432
1433pub(crate) async fn run_create_pr_with_client(
1441 cmd: &CreatePrCommand,
1442 repo_view: &crate::data::RepositoryView,
1443 context: &crate::data::context::CommitContext,
1444 claude_client: &crate::claude::client::ClaudeClient,
1445) -> Result<CreatePrOutcome> {
1446 let pr_template = match &repo_view.pr_template {
1447 Some(template) => template.clone(),
1448 None => cmd.get_default_pr_template(),
1449 };
1450
1451 let ai_result = if cmd.from_commits {
1452 claude_client
1453 .generate_pr_content_with_context_from_commits(repo_view, &pr_template, context)
1454 .await
1455 } else {
1456 claude_client
1457 .generate_pr_content_with_context(repo_view, &pr_template, context)
1458 .await
1459 };
1460 let pr_content = match ai_result {
1461 Ok(content) => content,
1462 Err(_e) => {
1463 let mut description = pr_template;
1464 cmd.enhance_description_with_commits(&mut description, repo_view)?;
1465 let title = cmd.generate_title_from_commits(repo_view);
1466 PrContent { title, description }
1467 }
1468 };
1469
1470 let pr_yaml = crate::data::to_yaml(&pr_content).context("Failed to serialise PrContent")?;
1471
1472 Ok(CreatePrOutcome {
1473 title: pr_content.title,
1474 description: pr_content.description,
1475 pr_yaml,
1476 })
1477}
1478
1479#[cfg(test)]
1480#[allow(clippy::unwrap_used, clippy::expect_used)]
1481mod run_create_pr_tests {
1482 use super::*;
1483 use crate::claude::client::ClaudeClient;
1484 use crate::claude::test_utils::ConfigurableMockAiClient;
1485 use crate::data::context::CommitContext;
1486 use crate::data::{
1487 AiInfo, BranchInfo, FieldExplanation, RepositoryView, VersionInfo, WorkingDirectoryInfo,
1488 };
1489 use crate::git::commit::FileChanges;
1490 use crate::git::{CommitAnalysis, CommitInfo};
1491
1492 #[tokio::test]
1493 async fn run_create_pr_invalid_repo_path_errors_before_ai() {
1494 let err = run_create_pr(
1495 None,
1496 None,
1497 Some(std::path::Path::new("/no/such/path/exists")),
1498 )
1499 .await
1500 .unwrap_err();
1501 let msg = format!("{err:#}").to_lowercase();
1502 assert!(
1507 msg.contains("git")
1508 || msg.contains("repository")
1509 || msg.contains("credential")
1510 || msg.contains("api")
1511 || msg.contains("directory"),
1512 "expected git/repository or preflight error, got: {msg}"
1513 );
1514 }
1515
1516 fn fresh_cmd() -> CreatePrCommand {
1517 CreatePrCommand {
1518 base: None,
1519 auto_apply: true,
1520 save_only: None,
1521 ready: false,
1522 draft: false,
1523 context_dir: None,
1524 no_push: true,
1525 from_commits: false,
1526 }
1527 }
1528
1529 fn sample_commit(hash: &str, message: &str) -> (CommitInfo, tempfile::NamedTempFile) {
1530 let tmp = tempfile::NamedTempFile::new().unwrap();
1531 let commit = CommitInfo {
1532 hash: hash.to_string(),
1533 author: "Test <test@test.com>".to_string(),
1534 date: chrono::Utc::now().fixed_offset(),
1535 original_message: message.to_string(),
1536 in_main_branches: vec![],
1537 analysis: CommitAnalysis {
1538 detected_type: "feat".to_string(),
1539 detected_scope: String::new(),
1540 proposed_message: message.to_string(),
1541 file_changes: FileChanges {
1542 total_files: 0,
1543 files_added: 0,
1544 files_deleted: 0,
1545 file_list: vec![],
1546 },
1547 diff_summary: String::new(),
1548 diff_file: tmp.path().to_string_lossy().to_string(),
1549 file_diffs: Vec::new(),
1550 },
1551 };
1552 (commit, tmp)
1553 }
1554
1555 fn sample_repo_view(commits: Vec<CommitInfo>, pr_template: Option<String>) -> RepositoryView {
1556 RepositoryView {
1557 versions: Some(VersionInfo {
1558 omni_dev: "0.0.0".to_string(),
1559 }),
1560 explanation: FieldExplanation::default(),
1561 working_directory: WorkingDirectoryInfo {
1562 clean: true,
1563 untracked_changes: vec![],
1564 },
1565 remotes: vec![],
1566 ai: AiInfo {
1567 scratch: String::new(),
1568 },
1569 branch_info: Some(BranchInfo {
1570 branch: "feature/test".to_string(),
1571 }),
1572 pr_template,
1573 pr_template_location: None,
1574 branch_prs: None,
1575 commits,
1576 }
1577 }
1578
1579 #[tokio::test]
1580 async fn run_create_pr_with_client_ai_success_returns_content() {
1581 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1582 let repo_view = sample_repo_view(vec![c1], None);
1583 let context = CommitContext::new();
1584 let cmd = fresh_cmd();
1585
1586 let yaml = "title: My PR\ndescription: |\n Body text\n".to_string();
1587 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1588 let client = ClaudeClient::new(Box::new(mock));
1589
1590 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1591 .await
1592 .unwrap();
1593 assert_eq!(outcome.title, "My PR");
1594 assert!(outcome.description.contains("Body text"));
1595 assert!(outcome.pr_yaml.contains("title:"));
1596 }
1597
1598 #[tokio::test]
1599 async fn run_create_pr_with_client_ai_failure_falls_back_to_commit_summary() {
1600 let (c1, _tmp) = sample_commit("abcdef00", "feat: single commit subject");
1601 let repo_view = sample_repo_view(vec![c1], None);
1602 let context = CommitContext::new();
1603 let cmd = fresh_cmd();
1604
1605 let mock = ConfigurableMockAiClient::new(vec![]);
1607 let client = ClaudeClient::new(Box::new(mock));
1608
1609 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1610 .await
1611 .unwrap();
1612 assert!(
1613 outcome.title.contains("feat: single commit subject")
1614 || outcome.title.contains("Pull Request")
1615 || outcome.title.contains("feature/test"),
1616 "fallback title unexpected: {}",
1617 outcome.title
1618 );
1619 }
1620
1621 #[tokio::test]
1622 async fn run_create_pr_with_client_uses_repo_template_when_present() {
1623 let (c1, _tmp) = sample_commit("abcdef00", "feat: x");
1624 let repo_view = sample_repo_view(vec![c1], Some("# Custom template\n".to_string()));
1625 let context = CommitContext::new();
1626 let cmd = fresh_cmd();
1627
1628 let mock = ConfigurableMockAiClient::new(vec![]);
1630 let client = ClaudeClient::new(Box::new(mock));
1631
1632 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1633 .await
1634 .unwrap();
1635 assert!(
1636 outcome.description.contains("# Custom template"),
1637 "fallback description should include repo template: {}",
1638 outcome.description
1639 );
1640 }
1641
1642 #[tokio::test]
1643 async fn run_create_pr_with_client_from_commits_omits_diff() {
1644 let dir = tempfile::tempdir().unwrap();
1647 let diff_path = dir.path().join("recognisable.diff");
1648 std::fs::write(
1649 &diff_path,
1650 "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+UNIQUE_DIFF_MARKER\n",
1651 )
1652 .unwrap();
1653
1654 let commit = CommitInfo {
1655 hash: format!("{:0>40}", 0),
1656 author: "Test <test@test.com>".to_string(),
1657 date: chrono::Utc::now().fixed_offset(),
1658 original_message: "feat: UNIQUE_COMMIT_SUBJECT_MARKER".to_string(),
1659 in_main_branches: vec![],
1660 analysis: CommitAnalysis {
1661 detected_type: "feat".to_string(),
1662 detected_scope: String::new(),
1663 proposed_message: "feat: t".to_string(),
1664 file_changes: FileChanges {
1665 total_files: 1,
1666 files_added: 0,
1667 files_deleted: 0,
1668 file_list: vec![],
1669 },
1670 diff_summary: String::new(),
1671 diff_file: diff_path.to_string_lossy().to_string(),
1672 file_diffs: Vec::new(),
1673 },
1674 };
1675 let repo_view = sample_repo_view(vec![commit], None);
1676 let context = CommitContext::new();
1677 let mut cmd = fresh_cmd();
1678 cmd.from_commits = true;
1679
1680 let yaml = "title: feat: x\ndescription: y\n".to_string();
1681 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1682 let prompt_handle = mock.prompt_handle();
1683 let client = ClaudeClient::new(Box::new(mock));
1684
1685 run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1686 .await
1687 .unwrap();
1688
1689 let prompts = prompt_handle.prompts();
1690 assert_eq!(prompts.len(), 1, "expected one AI call");
1691 let (_, user_prompt) = &prompts[0];
1692 assert!(
1693 user_prompt.contains("UNIQUE_COMMIT_SUBJECT_MARKER"),
1694 "commit subject should be in the prompt"
1695 );
1696 assert!(
1697 !user_prompt.contains("UNIQUE_DIFF_MARKER"),
1698 "diff content must NOT appear in the prompt when from_commits is set"
1699 );
1700 assert!(
1701 !user_prompt.contains("diff --git"),
1702 "diff hunks must NOT appear in the prompt when from_commits is set"
1703 );
1704 }
1705
1706 #[test]
1707 fn create_pr_outcome_clone_and_debug() {
1708 let outcome = CreatePrOutcome {
1709 title: "t".to_string(),
1710 description: "d".to_string(),
1711 pr_yaml: "y".to_string(),
1712 };
1713 let cloned = outcome.clone();
1714 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
1715 }
1716
1717 fn init_repo_with_remote_and_template(template_marker: &str) -> tempfile::TempDir {
1721 use git2::{Repository, Signature};
1722 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1723 std::fs::create_dir_all(&tmp_root).unwrap();
1724 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1725 let repo_path = temp_dir.path();
1726 let repo = Repository::init(repo_path).unwrap();
1727 {
1728 let mut config = repo.config().unwrap();
1729 config.set_str("user.name", "Test").unwrap();
1730 config.set_str("user.email", "test@example.com").unwrap();
1731 config.set_str("init.defaultBranch", "main").unwrap();
1732 }
1733 repo.set_head("refs/heads/main").unwrap();
1734
1735 let signature = Signature::now("Test", "test@example.com").unwrap();
1736
1737 std::fs::write(repo_path.join("f.txt"), "base").unwrap();
1739 let mut idx = repo.index().unwrap();
1740 idx.add_path(std::path::Path::new("f.txt")).unwrap();
1741 idx.write().unwrap();
1742 let tree_id = idx.write_tree().unwrap();
1743 let tree = repo.find_tree(tree_id).unwrap();
1744 let base_oid = repo
1745 .commit(
1746 Some("HEAD"),
1747 &signature,
1748 &signature,
1749 "base: init",
1750 &tree,
1751 &[],
1752 )
1753 .unwrap();
1754
1755 repo.remote("origin", "https://example.com/test/repo.git")
1757 .unwrap();
1758 repo.reference(
1760 "refs/remotes/origin/main",
1761 base_oid,
1762 true,
1763 "set origin/main",
1764 )
1765 .unwrap();
1766
1767 let base_commit = repo.find_commit(base_oid).unwrap();
1769 repo.branch("feature/test", &base_commit, true).unwrap();
1770 repo.set_head("refs/heads/feature/test").unwrap();
1771 std::fs::write(repo_path.join("f.txt"), "feature").unwrap();
1772 let mut idx = repo.index().unwrap();
1773 idx.add_path(std::path::Path::new("f.txt")).unwrap();
1774 idx.write().unwrap();
1775 let tree_id = idx.write_tree().unwrap();
1776 let tree = repo.find_tree(tree_id).unwrap();
1777 repo.commit(
1778 Some("HEAD"),
1779 &signature,
1780 &signature,
1781 "feat: feature work",
1782 &tree,
1783 &[&base_commit],
1784 )
1785 .unwrap();
1786
1787 let github_dir = repo_path.join(".github");
1789 std::fs::create_dir_all(&github_dir).unwrap();
1790 std::fs::write(github_dir.join("pull_request_template.md"), template_marker).unwrap();
1791
1792 temp_dir
1793 }
1794
1795 #[test]
1802 fn generate_repository_view_anchors_to_injected_repo() {
1803 let marker = "## INJECTED_PR_TEMPLATE_MARKER_42";
1804 let temp_dir = init_repo_with_remote_and_template(marker);
1805 let cmd = fresh_cmd();
1806
1807 let repo_view = cmd.generate_repository_view(temp_dir.path()).unwrap();
1808
1809 assert_eq!(
1811 repo_view.pr_template.as_deref(),
1812 Some(marker),
1813 "PR template must be read from the injected repo root"
1814 );
1815 assert_eq!(
1817 repo_view.branch_info.as_ref().map(|b| b.branch.as_str()),
1818 Some("feature/test")
1819 );
1820 assert_eq!(
1821 repo_view.commits.len(),
1822 1,
1823 "exactly the one commit ahead of origin/main"
1824 );
1825 assert!(repo_view.commits[0]
1826 .original_message
1827 .contains("feature work"));
1828 }
1829}
1830
1831#[cfg(test)]
1832mod tests {
1833 use super::*;
1834
1835 #[test]
1838 fn parse_bool_true_variants() {
1839 assert_eq!(parse_bool_string("true"), Some(true));
1840 assert_eq!(parse_bool_string("1"), Some(true));
1841 assert_eq!(parse_bool_string("yes"), Some(true));
1842 }
1843
1844 #[test]
1845 fn parse_bool_false_variants() {
1846 assert_eq!(parse_bool_string("false"), Some(false));
1847 assert_eq!(parse_bool_string("0"), Some(false));
1848 assert_eq!(parse_bool_string("no"), Some(false));
1849 }
1850
1851 #[test]
1852 fn parse_bool_invalid() {
1853 assert_eq!(parse_bool_string("maybe"), None);
1854 assert_eq!(parse_bool_string(""), None);
1855 }
1856
1857 #[test]
1858 fn parse_bool_case_insensitive() {
1859 assert_eq!(parse_bool_string("TRUE"), Some(true));
1860 assert_eq!(parse_bool_string("Yes"), Some(true));
1861 assert_eq!(parse_bool_string("FALSE"), Some(false));
1862 assert_eq!(parse_bool_string("No"), Some(false));
1863 }
1864
1865 #[test]
1868 fn breaking_change_type_contains() {
1869 assert!(is_breaking_change("BREAKING", "normal message"));
1870 }
1871
1872 #[test]
1873 fn breaking_change_message_contains() {
1874 assert!(is_breaking_change("feat", "BREAKING CHANGE: removed API"));
1875 }
1876
1877 #[test]
1878 fn breaking_change_none() {
1879 assert!(!is_breaking_change("feat", "add new feature"));
1880 }
1881
1882 #[test]
1885 fn check_checkbox_found() {
1886 let mut desc = "- [ ] New feature\n- [ ] Bug fix".to_string();
1887 check_checkbox(&mut desc, "- [ ] New feature");
1888 assert!(desc.contains("- [x] New feature"));
1889 assert!(desc.contains("- [ ] Bug fix"));
1890 }
1891
1892 #[test]
1893 fn check_checkbox_not_found() {
1894 let mut desc = "- [ ] Bug fix".to_string();
1895 let original = desc.clone();
1896 check_checkbox(&mut desc, "- [ ] New feature");
1897 assert_eq!(desc, original);
1898 }
1899
1900 #[test]
1903 fn scopes_section_single() {
1904 let scopes = vec!["cli".to_string()];
1905 assert_eq!(
1906 format_scopes_section(&scopes),
1907 "**Affected areas:** cli\n\n"
1908 );
1909 }
1910
1911 #[test]
1912 fn scopes_section_multiple() {
1913 let scopes = vec!["cli".to_string(), "git".to_string()];
1914 let result = format_scopes_section(&scopes);
1915 assert!(result.contains("cli"));
1916 assert!(result.contains("git"));
1917 assert!(result.starts_with("**Affected areas:**"));
1918 }
1919
1920 #[test]
1921 fn scopes_section_empty() {
1922 assert_eq!(format_scopes_section(&[]), "");
1923 }
1924
1925 #[test]
1928 fn commit_list_formatting() {
1929 let entries = vec![
1930 ("abc12345", "feat: add feature"),
1931 ("def67890", "fix: resolve bug"),
1932 ];
1933 let result = format_commit_list(&entries);
1934 assert!(result.contains("### Commits in this PR:"));
1935 assert!(result.contains("- `abc12345` feat: add feature"));
1936 assert!(result.contains("- `def67890` fix: resolve bug"));
1937 }
1938
1939 #[test]
1942 fn clean_branch_simple() {
1943 assert_eq!(clean_branch_name("feat/add-login"), "feat add login");
1944 }
1945
1946 #[test]
1947 fn clean_branch_underscores() {
1948 assert_eq!(clean_branch_name("user_name/fix_bug"), "user name fix bug");
1949 }
1950
1951 #[test]
1954 fn first_line_multiline() {
1955 assert_eq!(extract_first_line("first\nsecond\nthird"), "first");
1956 }
1957
1958 #[test]
1959 fn first_line_single() {
1960 assert_eq!(extract_first_line("only line"), "only line");
1961 }
1962
1963 #[test]
1964 fn first_line_empty() {
1965 assert_eq!(extract_first_line(""), "");
1966 }
1967
1968 #[test]
1971 fn draft_status_true() {
1972 let (icon, text) = format_draft_status(true);
1973 assert_eq!(text, "draft");
1974 assert!(!icon.is_empty());
1975 }
1976
1977 #[test]
1978 fn draft_status_false() {
1979 let (icon, text) = format_draft_status(false);
1980 assert_eq!(text, "ready for review");
1981 assert!(!icon.is_empty());
1982 }
1983
1984 #[test]
1987 fn push_action_skip_when_no_push() {
1988 assert_eq!(determine_push_action(true, false), PushAction::Skip);
1989 assert_eq!(determine_push_action(true, true), PushAction::Skip);
1990 }
1991
1992 #[test]
1993 fn push_action_sync_existing_branch() {
1994 assert_eq!(determine_push_action(false, true), PushAction::SyncExisting);
1995 }
1996
1997 #[test]
1998 fn push_action_push_new_branch() {
1999 assert_eq!(determine_push_action(false, false), PushAction::PushNew);
2000 }
2001}