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