1use anyhow::{bail, Context, Result};
4use clap::Parser;
5use tracing::{debug, error, warn};
6
7use super::info::InfoCommand;
8use crate::claude::error::is_transient_ai_error as ai_error_is_transient;
9
10#[derive(Parser)]
12pub struct CreatePrCommand {
13 #[arg(long, value_name = "BRANCH")]
15 pub base: Option<String>,
16
17 #[arg(long)]
19 pub auto_apply: bool,
20
21 #[arg(long, value_name = "FILE")]
23 pub save_only: Option<String>,
24
25 #[arg(long, conflicts_with = "draft")]
27 pub ready: bool,
28
29 #[arg(long, conflicts_with = "ready")]
31 pub draft: bool,
32
33 #[arg(long)]
35 pub context_dir: Option<std::path::PathBuf>,
36
37 #[arg(long)]
39 pub no_push: bool,
40
41 #[arg(long)]
43 pub from_commits: bool,
44}
45
46#[derive(Debug, PartialEq)]
48enum PrAction {
49 CreateNew,
50 UpdateExisting,
51 Cancel,
52}
53
54#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
56#[schemars(deny_unknown_fields)]
57pub struct PrContent {
58 pub title: String,
60 pub description: String,
62}
63
64struct GeneratedPr {
69 content: PrContent,
71 used_fallback: bool,
73}
74
75impl GeneratedPr {
76 fn from_ai(content: PrContent) -> Self {
78 Self {
79 content,
80 used_fallback: false,
81 }
82 }
83
84 fn from_fallback(content: PrContent) -> Self {
86 Self {
87 content,
88 used_fallback: true,
89 }
90 }
91}
92
93impl CreatePrCommand {
94 fn should_create_as_draft(&self) -> bool {
102 use crate::utils::settings::get_env_var;
103
104 if self.ready {
106 return false;
107 }
108 if self.draft {
109 return true;
110 }
111
112 get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
114 .ok()
115 .and_then(|val| parse_bool_string(&val))
116 .unwrap_or(true) }
118
119 pub async fn execute(self, repo: Option<&std::path::Path>) -> Result<()> {
121 let repo_root = match repo {
125 Some(p) => p.to_path_buf(),
126 None => std::env::current_dir().context("Failed to determine current directory")?,
127 };
128 let repo_root = repo_root.as_path();
129
130 let ai_info = crate::utils::check_pr_command_prerequisites(None, repo_root)?;
135 println!(
136 "✓ {} credentials verified (model: {})",
137 ai_info.provider, ai_info.model
138 );
139 println!("✓ GitHub CLI verified");
140
141 println!("🔄 Starting pull request creation process...");
142
143 let repo_view = self.generate_repository_view(repo_root)?;
145
146 self.validate_branch_state(&repo_view)?;
148
149 use crate::claude::context::ProjectDiscovery;
151 let context_dir =
152 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
153 let discovery = ProjectDiscovery::new(repo_root.to_path_buf(), context_dir);
154 let project_context = discovery.discover().unwrap_or_default();
155 self.show_guidance_files_status(repo_root, &project_context)?;
156
157 let claude_client = crate::claude::create_default_claude_client(None, None).await?;
159 self.show_model_info_from_client(&claude_client)?;
160
161 self.show_commit_range_info(&repo_view)?;
163
164 let context = {
166 use crate::claude::context::{BranchAnalyzer, FileAnalyzer, WorkPatternAnalyzer};
167 use crate::data::context::CommitContext;
168 let mut context = CommitContext::new();
169 context.project = project_context;
170
171 if let Some(branch_info) = &repo_view.branch_info {
173 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
174 }
175
176 if !repo_view.commits.is_empty() {
177 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
178 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
179 }
180 context
181 };
182 self.show_context_summary(&context)?;
183
184 debug!("About to generate PR content from AI");
186 let (generated, _claude_client) = self
187 .generate_pr_content_with_client_internal(repo_root, &repo_view, claude_client)
188 .await?;
189 let GeneratedPr {
190 content: pr_content,
191 used_fallback,
192 } = generated;
193
194 self.show_context_information(&repo_view)?;
196 debug!(
197 generated_title = %pr_content.title,
198 generated_description_length = pr_content.description.len(),
199 generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
200 "Generated PR content from AI"
201 );
202
203 if let Some(save_path) = self.save_only {
205 let pr_yaml = crate::data::to_yaml(&pr_content)
206 .context("Failed to serialize PR content to YAML")?;
207 std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
208 println!("💾 PR details saved to: {save_path}");
209 return Ok(());
210 }
211
212 debug!("About to serialize PR content to YAML");
214 let temp_dir = tempfile::tempdir()?;
215 let pr_file = temp_dir.path().join("pr-details.yaml");
216
217 debug!(
218 pre_serialize_title = %pr_content.title,
219 pre_serialize_description_length = pr_content.description.len(),
220 pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
221 "About to serialize PR content with to_yaml"
222 );
223
224 let pr_yaml =
225 crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
226
227 debug!(
228 file_path = %pr_file.display(),
229 yaml_content_length = pr_yaml.len(),
230 yaml_content = %pr_yaml,
231 original_title = %pr_content.title,
232 original_description_length = pr_content.description.len(),
233 "Writing PR details to temporary YAML file"
234 );
235
236 std::fs::write(&pr_file, &pr_yaml)?;
237
238 let pr_action = if self.auto_apply {
240 if repo_view
242 .branch_prs
243 .as_ref()
244 .is_some_and(|prs| !prs.is_empty())
245 {
246 PrAction::UpdateExisting
247 } else {
248 PrAction::CreateNew
249 }
250 } else {
251 self.handle_pr_file(&pr_file, &repo_view)?
252 };
253
254 if pr_action == PrAction::Cancel {
255 println!("❌ PR operation cancelled by user");
256 return Ok(());
257 }
258
259 if used_fallback && self.auto_apply && pr_action == PrAction::UpdateExisting {
260 self.refuse_template_clobber(&repo_view)?;
261 }
262
263 let final_pr_yaml =
265 std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
266
267 debug!(
268 yaml_length = final_pr_yaml.len(),
269 yaml_content = %final_pr_yaml,
270 "Read PR details YAML from file"
271 );
272
273 let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
274 .context("Failed to parse PR details YAML. Please check the file format.")?;
275
276 debug!(
277 title = %final_pr_content.title,
278 description_length = final_pr_content.description.len(),
279 description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
280 "Parsed PR content from YAML"
281 );
282
283 let is_draft = self.should_create_as_draft();
285
286 match pr_action {
287 PrAction::CreateNew => {
288 self.create_github_pr(
289 repo_root,
290 &repo_view,
291 &final_pr_content.title,
292 &final_pr_content.description,
293 is_draft,
294 self.base.as_deref(),
295 )?;
296 println!("✅ Pull request created successfully!");
297 }
298 PrAction::UpdateExisting => {
299 self.update_github_pr(
300 repo_root,
301 &repo_view,
302 &final_pr_content.title,
303 &final_pr_content.description,
304 self.base.as_deref(),
305 )?;
306 println!("✅ Pull request updated successfully!");
307 }
308 PrAction::Cancel => unreachable!(), }
310
311 Ok(())
312 }
313
314 fn generate_repository_view(
316 &self,
317 repo_root: &std::path::Path,
318 ) -> Result<crate::data::RepositoryView> {
319 use crate::data::{
320 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
321 WorkingDirectoryInfo,
322 };
323 use crate::git::{GitRepository, RemoteInfo};
324 use crate::utils::ai_scratch;
325
326 let repo = GitRepository::open_at(repo_root)
328 .context("Failed to open git repository at the given path")?;
329
330 let current_branch = repo.get_current_branch().context(
332 "Failed to get current branch. Make sure you're not in detached HEAD state.",
333 )?;
334
335 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
337
338 let primary_remote = remotes
340 .iter()
341 .find(|r| r.name == "origin")
342 .or_else(|| remotes.first())
343 .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
344
345 let base_branch = if let Some(branch) = self.base.as_ref() {
347 let remote_ref = format!("refs/remotes/{branch}");
350 if repo.repository().find_reference(&remote_ref).is_ok() {
351 branch.clone()
352 } else {
353 let with_remote = format!("{}/{}", primary_remote.name, branch);
355 let remote_ref = format!("refs/remotes/{with_remote}");
356 if repo.repository().find_reference(&remote_ref).is_ok() {
357 with_remote
358 } else {
359 anyhow::bail!(
360 "Remote branch '{branch}' does not exist (also tried '{with_remote}')"
361 );
362 }
363 }
364 } else {
365 let main_branch = &primary_remote.main_branch;
367 if main_branch == "unknown" {
368 let remote_name = &primary_remote.name;
369 anyhow::bail!("Could not determine main branch for remote '{remote_name}'");
370 }
371
372 let remote_main = format!("{}/{}", primary_remote.name, main_branch);
373
374 let remote_ref = format!("refs/remotes/{remote_main}");
376 if repo.repository().find_reference(&remote_ref).is_err() {
377 anyhow::bail!(
378 "Remote main branch '{remote_main}' does not exist. Try running 'git fetch' first."
379 );
380 }
381
382 remote_main
383 };
384
385 let commit_range = format!("{base_branch}..HEAD");
387
388 let wd_status = repo.get_working_directory_status()?;
390 let working_directory = WorkingDirectoryInfo {
391 clean: wd_status.clean,
392 untracked_changes: wd_status
393 .untracked_changes
394 .into_iter()
395 .map(|fs| FileStatusInfo {
396 status: fs.status,
397 file: fs.file,
398 })
399 .collect(),
400 };
401
402 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
404
405 let commits = repo.get_commits_in_range(&commit_range)?;
407
408 let pr_template_result = InfoCommand::read_pr_template(repo_root).ok();
410 let (pr_template, pr_template_location) = match pr_template_result {
411 Some((content, location)) => (Some(content), Some(location)),
412 None => (None, None),
413 };
414
415 let branch_prs = InfoCommand::get_branch_prs(¤t_branch, repo_root)
417 .ok()
418 .filter(|prs| !prs.is_empty());
419
420 let versions = Some(VersionInfo {
422 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
423 });
424
425 let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
427 .context("Failed to determine AI scratch directory")?;
428 let ai_info = AiInfo {
429 scratch: ai_scratch_path.to_string_lossy().to_string(),
430 };
431
432 let mut repo_view = RepositoryView {
434 versions,
435 explanation: FieldExplanation::default(),
436 working_directory,
437 remotes,
438 ai: ai_info,
439 branch_info: Some(BranchInfo {
440 branch: current_branch,
441 }),
442 pr_template,
443 pr_template_location,
444 branch_prs,
445 commits,
446 };
447
448 repo_view.update_field_presence();
450
451 Ok(repo_view)
452 }
453
454 fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
456 if !repo_view.working_directory.clean {
458 anyhow::bail!(
459 "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
460 );
461 }
462
463 if !repo_view.working_directory.untracked_changes.is_empty() {
465 let file_list: Vec<&str> = repo_view
466 .working_directory
467 .untracked_changes
468 .iter()
469 .map(|f| f.file.as_str())
470 .collect();
471 anyhow::bail!(
472 "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
473 file_list.join(", ")
474 );
475 }
476
477 if repo_view.commits.is_empty() {
479 anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
480 }
481
482 if let Some(existing_prs) = &repo_view.branch_prs {
484 if !existing_prs.is_empty() {
485 let pr_info: Vec<String> = existing_prs
486 .iter()
487 .map(|pr| format!("#{} ({})", pr.number, pr.state))
488 .collect();
489
490 println!(
491 "📋 Existing PR(s) found for this branch: {}",
492 pr_info.join(", ")
493 );
494 }
496 }
497
498 Ok(())
499 }
500
501 fn show_context_information(&self, _repo_view: &crate::data::RepositoryView) -> Result<()> {
503 Ok(())
508 }
509
510 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
512 let base_branch = match self.base.as_ref() {
514 Some(branch) => {
515 let primary_remote_name = repo_view
518 .remotes
519 .iter()
520 .find(|r| r.name == "origin")
521 .or_else(|| repo_view.remotes.first())
522 .map_or("origin", |r| r.name.as_str());
523 if branch.starts_with(&format!("{primary_remote_name}/")) {
525 branch.clone()
526 } else {
527 format!("{primary_remote_name}/{branch}")
528 }
529 }
530 None => {
531 repo_view
533 .remotes
534 .iter()
535 .find(|r| r.name == "origin")
536 .or_else(|| repo_view.remotes.first())
537 .map_or_else(
538 || "unknown".to_string(),
539 |r| format!("{}/{}", r.name, r.main_branch),
540 )
541 }
542 };
543
544 let commit_range = format!("{base_branch}..HEAD");
545 let commit_count = repo_view.commits.len();
546
547 let current_branch = repo_view
549 .branch_info
550 .as_ref()
551 .map_or("unknown", |bi| bi.branch.as_str());
552
553 println!("📊 Branch Analysis:");
554 println!(" 🌿 Current branch: {current_branch}");
555 println!(" 📏 Commit range: {commit_range}");
556 println!(" 📝 Commits found: {commit_count} commits");
557 println!();
558
559 Ok(())
560 }
561
562 fn collect_context(
564 &self,
565 repo_root: &std::path::Path,
566 repo_view: &crate::data::RepositoryView,
567 ) -> Result<crate::data::context::CommitContext> {
568 use crate::claude::context::{
569 BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
570 };
571 use crate::data::context::{CommitContext, ProjectContext};
572 use crate::git::GitRepository;
573
574 let mut context = CommitContext::new();
575
576 let context_dir =
578 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
579
580 let discovery = ProjectDiscovery::new(repo_root.to_path_buf(), context_dir);
582 match discovery.discover() {
583 Ok(project_context) => {
584 context.project = project_context;
585 }
586 Err(_e) => {
587 context.project = ProjectContext::default();
588 }
589 }
590
591 let repo = GitRepository::open_at(repo_root)?;
593 let current_branch = repo
594 .get_current_branch()
595 .unwrap_or_else(|_| "HEAD".to_string());
596 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
597
598 if !repo_view.commits.is_empty() {
600 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
601 }
602
603 if !repo_view.commits.is_empty() {
605 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
606 }
607
608 Ok(context)
609 }
610
611 fn show_guidance_files_status(
613 &self,
614 repo_root: &std::path::Path,
615 project_context: &crate::data::context::ProjectContext,
616 ) -> Result<()> {
617 use crate::claude::context::{
618 config_source_label, resolve_context_dir_with_source_at, ConfigSourceLabel,
619 };
620
621 let (context_dir, dir_source) =
622 resolve_context_dir_with_source_at(self.context_dir.as_deref(), repo_root);
623
624 println!("📋 Project guidance files status:");
625 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
626
627 let pr_guidelines_source = if project_context.pr_guidelines.is_some() {
629 match config_source_label(&context_dir, "pr-guidelines.md") {
630 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
631 label => format!("✅ {label}"),
632 }
633 } else {
634 "❌ None found".to_string()
635 };
636 println!(" 🔀 PR guidelines: {pr_guidelines_source}");
637
638 let scopes_count = project_context.valid_scopes.len();
640 let scopes_source = if scopes_count > 0 {
641 match config_source_label(&context_dir, "scopes.yaml") {
642 ConfigSourceLabel::NotFound => {
643 format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
644 }
645 label => format!("✅ {label} ({scopes_count} scopes)"),
646 }
647 } else {
648 "❌ None found".to_string()
649 };
650 println!(" 🎯 Valid scopes: {scopes_source}");
651
652 let pr_template_path = repo_root.join(".github/pull_request_template.md");
654 let pr_template_status = if pr_template_path.exists() {
655 format!("✅ Project: {}", pr_template_path.display())
656 } else {
657 "❌ None found".to_string()
658 };
659 println!(" 📋 PR template: {pr_template_status}");
660
661 println!();
662 Ok(())
663 }
664
665 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
667 use crate::data::context::{VerbosityLevel, WorkPattern};
668
669 println!("🔍 Context Analysis:");
670
671 if !context.project.valid_scopes.is_empty() {
673 let scope_names: Vec<&str> = context
674 .project
675 .valid_scopes
676 .iter()
677 .map(|s| s.name.as_str())
678 .collect();
679 println!(" 📁 Valid scopes: {}", scope_names.join(", "));
680 }
681
682 if context.branch.is_feature_branch {
684 println!(
685 " 🌿 Branch: {} ({})",
686 context.branch.description, context.branch.work_type
687 );
688 if let Some(ref ticket) = context.branch.ticket_id {
689 println!(" 🎫 Ticket: {ticket}");
690 }
691 }
692
693 match context.range.work_pattern {
695 WorkPattern::Sequential => println!(" 🔄 Pattern: Sequential development"),
696 WorkPattern::Refactoring => println!(" 🧹 Pattern: Refactoring work"),
697 WorkPattern::BugHunt => println!(" 🐛 Pattern: Bug investigation"),
698 WorkPattern::Documentation => println!(" 📖 Pattern: Documentation updates"),
699 WorkPattern::Configuration => println!(" ⚙️ Pattern: Configuration changes"),
700 WorkPattern::Unknown => {}
701 }
702
703 if let Some(label) = super::formatting::format_file_analysis(&context.files) {
705 println!(" {label}");
706 }
707
708 match context.suggested_verbosity() {
710 VerbosityLevel::Comprehensive => {
711 println!(" 📝 Detail level: Comprehensive (significant changes detected)");
712 }
713 VerbosityLevel::Detailed => println!(" 📝 Detail level: Detailed"),
714 VerbosityLevel::Concise => println!(" 📝 Detail level: Concise"),
715 }
716
717 println!();
718 Ok(())
719 }
720
721 async fn generate_pr_content_with_client_internal(
727 &self,
728 repo_root: &std::path::Path,
729 repo_view: &crate::data::RepositoryView,
730 claude_client: crate::claude::client::ClaudeClient,
731 ) -> Result<(GeneratedPr, crate::claude::client::ClaudeClient)> {
732 use tracing::debug;
733
734 let pr_template = self.resolve_pr_template(repo_view);
735
736 debug!(
737 pr_template_length = pr_template.len(),
738 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
739 "Using PR template for generation"
740 );
741
742 println!("🤖 Generating AI-powered PR description...");
743
744 debug!("Collecting context for PR generation");
746 let context = self.collect_context(repo_root, repo_view)?;
747 debug!("Context collection completed");
748
749 debug!(
751 from_commits = self.from_commits,
752 "About to call Claude AI for PR content generation"
753 );
754 let ai_result = if self.from_commits {
755 claude_client
756 .generate_pr_content_with_context_from_commits(repo_view, &pr_template, &context)
757 .await
758 } else {
759 claude_client
760 .generate_pr_content_with_context(repo_view, &pr_template, &context)
761 .await
762 };
763 match ai_result {
764 Ok(pr_content) => {
765 debug!(
766 ai_generated_title = %pr_content.title,
767 ai_generated_description_length = pr_content.description.len(),
768 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
769 "AI successfully generated PR content"
770 );
771 Ok((GeneratedPr::from_ai(pr_content), claude_client))
772 }
773 Err(e) if !ai_error_is_transient(&e) => {
776 Err(e).context("AI PR generation failed with a non-retryable error")
777 }
778 Err(e) => {
779 let content = self.fallback_pr_content(&e, pr_template, repo_view)?;
780 Ok((GeneratedPr::from_fallback(content), claude_client))
781 }
782 }
783 }
784
785 fn fallback_pr_content(
791 &self,
792 error: &anyhow::Error,
793 pr_template: String,
794 repo_view: &crate::data::RepositoryView,
795 ) -> Result<PrContent> {
796 warn!(error = %format!("{error:#}"), "AI PR generation failed, falling back to the PR template");
797 eprintln!("warning: AI PR generation failed: {error:#}");
798 eprintln!(
799 "warning: falling back to the PR template — the description below is not AI-generated."
800 );
801
802 let mut description = pr_template;
803 self.enhance_description_with_commits(&mut description, repo_view)?;
804 let title = self.generate_title_from_commits(repo_view);
805
806 debug!(
807 fallback_title = %title,
808 fallback_description_length = description.len(),
809 "Created fallback PR content"
810 );
811
812 Ok(PrContent { title, description })
813 }
814
815 fn resolve_pr_template(&self, repo_view: &crate::data::RepositoryView) -> String {
818 match &repo_view.pr_template {
819 Some(template) => template.clone(),
820 None => self.get_default_pr_template(),
821 }
822 }
823
824 fn refuse_template_clobber(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
832 let Some(existing) = repo_view.branch_prs.as_ref().and_then(|prs| prs.first()) else {
833 return Ok(());
834 };
835
836 if Self::body_is_safe_to_replace(&existing.body, &self.resolve_pr_template(repo_view)) {
837 return Ok(());
838 }
839
840 bail!(
841 "Refusing to overwrite the description of PR #{} with template content \
842 after AI generation failed.\n\
843 The existing description would be lost and cannot be recovered by this tool.\n\
844 Re-run without --auto-apply to review the content first, or resolve the AI \
845 failure reported above.",
846 existing.number
847 );
848 }
849
850 fn body_is_safe_to_replace(body: &str, template: &str) -> bool {
856 let body = body.trim();
857 body.is_empty() || body == template.trim()
858 }
859
860 fn get_default_pr_template(&self) -> String {
862 r#"# Pull Request
863
864## Description
865<!-- Provide a brief description of what this PR does -->
866
867## Type of Change
868<!-- Mark the relevant option with an "x" -->
869- [ ] Bug fix (non-breaking change which fixes an issue)
870- [ ] New feature (non-breaking change which adds functionality)
871- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
872- [ ] Documentation update
873- [ ] Refactoring (no functional changes)
874- [ ] Performance improvement
875- [ ] Test coverage improvement
876
877## Changes Made
878<!-- List the specific changes made in this PR -->
879-
880-
881-
882
883## Testing
884- [ ] All existing tests pass
885- [ ] New tests added for new functionality
886- [ ] Manual testing performed
887
888## Additional Notes
889<!-- Add any additional notes for reviewers -->
890"#.to_string()
891 }
892
893 fn enhance_description_with_commits(
895 &self,
896 description: &mut String,
897 repo_view: &crate::data::RepositoryView,
898 ) -> Result<()> {
899 if repo_view.commits.is_empty() {
900 return Ok(());
901 }
902
903 description.push_str("\n---\n");
905 description.push_str("## 📝 Commit Summary\n");
906 description
907 .push_str("*This section was automatically generated based on commit analysis*\n\n");
908
909 let mut types_found = std::collections::HashSet::new();
911 let mut scopes_found = std::collections::HashSet::new();
912 let mut has_breaking_changes = false;
913
914 for commit in &repo_view.commits {
915 let detected_type = &commit.analysis.detected_type;
916 types_found.insert(detected_type.clone());
917 if is_breaking_change(detected_type, &commit.original_message) {
918 has_breaking_changes = true;
919 }
920
921 let detected_scope = &commit.analysis.detected_scope;
922 if !detected_scope.is_empty() {
923 scopes_found.insert(detected_scope.clone());
924 }
925 }
926
927 if types_found.contains("feat") {
929 check_checkbox(description, "- [ ] New feature");
930 }
931 if types_found.contains("fix") {
932 check_checkbox(description, "- [ ] Bug fix");
933 }
934 if types_found.contains("docs") {
935 check_checkbox(description, "- [ ] Documentation update");
936 }
937 if types_found.contains("refactor") {
938 check_checkbox(description, "- [ ] Refactoring");
939 }
940 if has_breaking_changes {
941 check_checkbox(description, "- [ ] Breaking change");
942 }
943
944 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
946 let scopes_section = format_scopes_section(&scopes_list);
947 if !scopes_section.is_empty() {
948 description.push_str(&scopes_section);
949 }
950
951 let commit_entries: Vec<(&str, &str)> = repo_view
953 .commits
954 .iter()
955 .map(|c| {
956 let short = &c.hash[..crate::git::SHORT_HASH_LEN];
957 let first = extract_first_line(&c.original_message);
958 (short, first)
959 })
960 .collect();
961 description.push_str(&format_commit_list(&commit_entries));
962
963 let total_files: usize = repo_view
965 .commits
966 .iter()
967 .map(|c| c.analysis.file_changes.total_files)
968 .sum();
969
970 if total_files > 0 {
971 description.push_str(&format!("\n**Files changed:** {total_files} files\n"));
972 }
973
974 Ok(())
975 }
976
977 fn handle_pr_file(
979 &self,
980 pr_file: &std::path::Path,
981 repo_view: &crate::data::RepositoryView,
982 ) -> Result<PrAction> {
983 use std::io::{self, Write};
984
985 println!("\n📝 PR details generated.");
986 println!("💾 Details saved to: {}", pr_file.display());
987
988 let is_draft = self.should_create_as_draft();
990 let (status_icon, status_text) = format_draft_status(is_draft);
991 println!("{status_icon} PR will be created as: {status_text}");
992 println!();
993
994 let has_existing_prs = repo_view
996 .branch_prs
997 .as_ref()
998 .is_some_and(|prs| !prs.is_empty());
999
1000 loop {
1001 if has_existing_prs {
1002 print!("❓ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
1003 } else {
1004 print!(
1005 "❓ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
1006 );
1007 }
1008 io::stdout().flush()?;
1009
1010 let mut input = String::new();
1011 io::stdin().read_line(&mut input)?;
1012
1013 match input.trim().to_lowercase().as_str() {
1014 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
1015 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
1016 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
1017 "s" | "show" => {
1018 self.show_pr_file(pr_file)?;
1019 println!();
1020 }
1021 "e" | "edit" => {
1022 self.edit_pr_file(pr_file)?;
1023 println!();
1024 }
1025 "q" | "quit" => return Ok(PrAction::Cancel),
1026 _ => {
1027 if has_existing_prs {
1028 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
1029 } else {
1030 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
1031 }
1032 }
1033 }
1034 }
1035 }
1036
1037 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
1039 use std::fs;
1040
1041 println!("\n📄 PR details file contents:");
1042 println!("─────────────────────────────");
1043
1044 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
1045 println!("{contents}");
1046 println!("─────────────────────────────");
1047
1048 Ok(())
1049 }
1050
1051 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
1053 use std::env;
1054 use std::io::{self, Write};
1055 use std::process::Command;
1056
1057 let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
1059 e
1060 } else {
1061 println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
1063 print!("Please enter the command to use as your editor: ");
1064 io::stdout().flush().context("Failed to flush stdout")?;
1065
1066 let mut input = String::new();
1067 io::stdin()
1068 .read_line(&mut input)
1069 .context("Failed to read user input")?;
1070 input.trim().to_string()
1071 };
1072
1073 if editor.is_empty() {
1074 println!("❌ No editor specified. Returning to menu.");
1075 return Ok(());
1076 }
1077
1078 println!("📝 Opening PR details file in editor: {editor}");
1079
1080 let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
1081
1082 let mut command = Command::new(editor_cmd);
1083 command.args(args);
1084 command.arg(pr_file.to_string_lossy().as_ref());
1085
1086 match command.status() {
1087 Ok(status) => {
1088 if status.success() {
1089 println!("✅ Editor session completed.");
1090 } else {
1091 println!(
1092 "⚠️ Editor exited with non-zero status: {:?}",
1093 status.code()
1094 );
1095 }
1096 }
1097 Err(e) => {
1098 println!("❌ Failed to execute editor '{editor}': {e}");
1099 println!(" Please check that the editor command is correct and available in your PATH.");
1100 }
1101 }
1102
1103 Ok(())
1104 }
1105
1106 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
1108 if repo_view.commits.is_empty() {
1109 return "Pull Request".to_string();
1110 }
1111
1112 if repo_view.commits.len() == 1 {
1114 let first = extract_first_line(&repo_view.commits[0].original_message);
1115 let trimmed = first.trim();
1116 return if trimmed.is_empty() {
1117 "Pull Request".to_string()
1118 } else {
1119 trimmed.to_string()
1120 };
1121 }
1122
1123 let branch_name = repo_view
1125 .branch_info
1126 .as_ref()
1127 .map_or("feature", |bi| bi.branch.as_str());
1128
1129 format!("feat: {}", clean_branch_name(branch_name))
1130 }
1131
1132 fn create_github_pr(
1134 &self,
1135 repo_root: &std::path::Path,
1136 repo_view: &crate::data::RepositoryView,
1137 title: &str,
1138 description: &str,
1139 is_draft: bool,
1140 new_base: Option<&str>,
1141 ) -> Result<()> {
1142 let branch_name = repo_view
1144 .branch_info
1145 .as_ref()
1146 .map(|bi| &bi.branch)
1147 .context("Branch info not available")?;
1148
1149 let pr_status = if is_draft {
1150 "draft"
1151 } else {
1152 "ready for review"
1153 };
1154 println!("🚀 Creating pull request ({pr_status})...");
1155 println!(" 📋 Title: {title}");
1156 println!(" 🌿 Branch: {branch_name}");
1157 if let Some(base) = new_base {
1158 println!(" 🎯 Base: {base}");
1159 }
1160
1161 let push_action = if self.no_push {
1163 determine_push_action(true, false)
1164 } else {
1165 debug!("Opening git repository to check branch status");
1166 let git_repo = crate::git::GitRepository::open_at(repo_root)
1167 .context("Failed to open git repository at the given path")?;
1168
1169 debug!(
1170 "Checking if branch '{}' exists on remote 'origin'",
1171 branch_name
1172 );
1173 let branch_on_remote = git_repo.branch_exists_on_remote(branch_name, "origin")?;
1174 let action = determine_push_action(false, branch_on_remote);
1175
1176 debug!("Push action for branch '{}': {:?}", branch_name, action);
1177 println!("📤 Pushing branch to remote...");
1178 git_repo
1179 .push_branch(branch_name, "origin")
1180 .context("Failed to push branch to remote")?;
1181
1182 action
1183 };
1184
1185 if push_action == PushAction::Skip {
1186 debug!("Skipping push (--no-push flag set)");
1187 }
1188
1189 debug!("Creating PR with gh CLI - title: '{}'", title);
1191 debug!("PR description length: {} characters", description.len());
1192 debug!("PR draft status: {}", is_draft);
1193 if let Some(base) = new_base {
1194 debug!("PR base branch: {}", base);
1195 }
1196
1197 let mut args = vec![
1198 "pr",
1199 "create",
1200 "--head",
1201 branch_name,
1202 "--title",
1203 title,
1204 "--body",
1205 description,
1206 ];
1207
1208 if let Some(base) = new_base {
1209 args.push("--base");
1210 args.push(base);
1211 }
1212
1213 if is_draft {
1214 args.push("--draft");
1215 }
1216
1217 let pr_result = crate::github_metrics::run_gh(
1218 &crate::pr_status::resolve_gh_binary(),
1219 args,
1220 "pr create",
1221 Some(repo_root),
1222 )
1223 .context("Failed to create pull request")?;
1224
1225 if pr_result.status.success() {
1226 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
1227 let pr_url = pr_url.trim();
1228 debug!("PR created successfully with URL: {}", pr_url);
1229 println!("🎉 Pull request created: {pr_url}");
1230 } else {
1231 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1232 error!("gh CLI failed to create PR: {}", error_msg);
1233 anyhow::bail!("Failed to create pull request: {error_msg}");
1234 }
1235
1236 Ok(())
1237 }
1238
1239 fn update_github_pr(
1241 &self,
1242 repo_root: &std::path::Path,
1243 repo_view: &crate::data::RepositoryView,
1244 title: &str,
1245 description: &str,
1246 new_base: Option<&str>,
1247 ) -> Result<()> {
1248 use std::io::{self, Write};
1249
1250 let existing_pr = repo_view
1252 .branch_prs
1253 .as_ref()
1254 .and_then(|prs| prs.first())
1255 .context("No existing PR found to update")?;
1256
1257 let pr_number = existing_pr.number;
1258 let current_base = &existing_pr.base;
1259
1260 println!("🚀 Updating pull request #{pr_number}...");
1261 println!(" 📋 Title: {title}");
1262
1263 let change_base = if let Some(base) = new_base {
1265 if !current_base.is_empty() && current_base != base {
1266 print!(" 🎯 Current base: {current_base} → New base: {base}. Change? [y/N]: ");
1267 io::stdout().flush()?;
1268
1269 let mut input = String::new();
1270 io::stdin().read_line(&mut input)?;
1271 let response = input.trim().to_lowercase();
1272 response == "y" || response == "yes"
1273 } else {
1274 false
1275 }
1276 } else {
1277 false
1278 };
1279
1280 debug!(
1281 pr_number = pr_number,
1282 title = %title,
1283 description_length = description.len(),
1284 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1285 change_base = change_base,
1286 "Updating GitHub PR with title and description"
1287 );
1288
1289 let pr_number_str = pr_number.to_string();
1291 let mut gh_args = vec![
1292 "pr",
1293 "edit",
1294 &pr_number_str,
1295 "--title",
1296 title,
1297 "--body",
1298 description,
1299 ];
1300
1301 if change_base {
1302 if let Some(base) = new_base {
1303 gh_args.push("--base");
1304 gh_args.push(base);
1305 }
1306 }
1307
1308 debug!(
1309 args = ?gh_args,
1310 "Executing gh command to update PR"
1311 );
1312
1313 let pr_result = crate::github_metrics::run_gh(
1314 &crate::pr_status::resolve_gh_binary(),
1315 gh_args,
1316 "pr edit",
1317 Some(repo_root),
1318 )
1319 .context("Failed to update pull request")?;
1320
1321 if pr_result.status.success() {
1322 println!("🎉 Pull request updated: {}", existing_pr.url);
1324 if change_base {
1325 if let Some(base) = new_base {
1326 println!(" 🎯 Base branch changed to: {base}");
1327 }
1328 }
1329 } else {
1330 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1331 anyhow::bail!("Failed to update pull request: {error_msg}");
1332 }
1333
1334 Ok(())
1335 }
1336
1337 fn show_model_info_from_client(
1339 &self,
1340 client: &crate::claude::client::ClaudeClient,
1341 ) -> Result<()> {
1342 use crate::claude::model_config::get_model_registry;
1343
1344 println!("🤖 AI Model Configuration:");
1345
1346 let metadata = client.get_ai_client_metadata();
1348 let registry = get_model_registry();
1354
1355 if let Some(spec) = registry.get_model_spec(&metadata.model) {
1356 if metadata.model != spec.api_identifier {
1358 println!(
1359 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
1360 metadata.model, spec.api_identifier
1361 );
1362 } else {
1363 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1364 }
1365
1366 println!(" 🏷️ Provider: {}", spec.provider);
1367 println!(" 📊 Generation: {}", spec.generation);
1368 println!(" ⭐ Tier: {} ({})", spec.tier, {
1369 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1370 &tier_info.description
1371 } else {
1372 "No description available"
1373 }
1374 });
1375 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1376 println!(" 📥 Input context: {}", metadata.max_context_length);
1377
1378 if let Some((ref key, ref value)) = metadata.active_beta {
1379 println!(" 🔬 Beta header: {key}: {value}");
1380 }
1381
1382 if spec.legacy {
1383 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
1384 }
1385 } else {
1386 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1388 println!(" 🏷️ Provider: {}", metadata.provider);
1389 println!(" ⚠️ Model not found in registry, using client metadata:");
1390 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1391 println!(" 📥 Input context: {}", metadata.max_context_length);
1392 }
1393
1394 println!();
1395 Ok(())
1396 }
1397}
1398
1399#[derive(Debug, PartialEq)]
1403enum PushAction {
1404 Skip,
1406 SyncExisting,
1408 PushNew,
1410}
1411
1412fn determine_push_action(no_push: bool, branch_on_remote: bool) -> PushAction {
1414 if no_push {
1415 PushAction::Skip
1416 } else if branch_on_remote {
1417 PushAction::SyncExisting
1418 } else {
1419 PushAction::PushNew
1420 }
1421}
1422
1423fn parse_bool_string(val: &str) -> Option<bool> {
1428 match val.to_lowercase().as_str() {
1429 "true" | "1" | "yes" => Some(true),
1430 "false" | "0" | "no" => Some(false),
1431 _ => None,
1432 }
1433}
1434
1435fn is_breaking_change(detected_type: &str, original_message: &str) -> bool {
1437 detected_type.contains("BREAKING") || original_message.contains("BREAKING CHANGE")
1438}
1439
1440fn check_checkbox(description: &mut String, search_text: &str) {
1442 if let Some(pos) = description.find(search_text) {
1443 description.replace_range(pos..pos + 5, "- [x]");
1444 }
1445}
1446
1447fn format_scopes_section(scopes: &[String]) -> String {
1451 if scopes.is_empty() {
1452 return String::new();
1453 }
1454 format!("**Affected areas:** {}\n\n", scopes.join(", "))
1455}
1456
1457fn format_commit_list(entries: &[(&str, &str)]) -> String {
1459 let mut output = String::from("### Commits in this PR:\n");
1460 for (hash, message) in entries {
1461 output.push_str(&format!("- `{hash}` {message}\n"));
1462 }
1463 output
1464}
1465
1466fn clean_branch_name(branch: &str) -> String {
1468 branch.replace(['/', '-', '_'], " ")
1469}
1470
1471fn extract_first_line(text: &str) -> &str {
1473 text.lines().next().unwrap_or("").trim()
1474}
1475
1476fn format_draft_status(is_draft: bool) -> (&'static str, &'static str) {
1478 if is_draft {
1479 ("\u{1f4cb}", "draft")
1480 } else {
1481 ("\u{2705}", "ready for review")
1482 }
1483}
1484
1485#[derive(Debug, Clone)]
1487pub struct CreatePrOutcome {
1488 pub title: String,
1490 pub description: String,
1492 pub pr_yaml: String,
1494}
1495
1496pub async fn run_create_pr(
1505 model: Option<String>,
1506 base_branch: Option<&str>,
1507 repo_path: Option<&std::path::Path>,
1508) -> Result<CreatePrOutcome> {
1509 let repo_root = match repo_path {
1513 Some(p) => p.to_path_buf(),
1514 None => std::env::current_dir().context("Failed to determine current directory")?,
1515 };
1516
1517 crate::utils::check_pr_command_prerequisites(model.as_deref(), &repo_root)?;
1518
1519 let cmd = CreatePrCommand {
1520 base: base_branch.map(str::to_string),
1521 auto_apply: true,
1522 save_only: None,
1523 ready: false,
1524 draft: false,
1525 context_dir: None,
1526 no_push: true,
1527 from_commits: false,
1528 };
1529
1530 let repo_view = cmd.generate_repository_view(&repo_root)?;
1531 let context = cmd.collect_context(&repo_root, &repo_view)?;
1532 let claude_client = crate::claude::create_default_claude_client(model, None).await?;
1533 run_create_pr_with_client(&cmd, &repo_view, &context, &claude_client).await
1534}
1535
1536pub(crate) async fn run_create_pr_with_client(
1544 cmd: &CreatePrCommand,
1545 repo_view: &crate::data::RepositoryView,
1546 context: &crate::data::context::CommitContext,
1547 claude_client: &crate::claude::client::ClaudeClient,
1548) -> Result<CreatePrOutcome> {
1549 let pr_template = cmd.resolve_pr_template(repo_view);
1550
1551 let ai_result = if cmd.from_commits {
1552 claude_client
1553 .generate_pr_content_with_context_from_commits(repo_view, &pr_template, context)
1554 .await
1555 } else {
1556 claude_client
1557 .generate_pr_content_with_context(repo_view, &pr_template, context)
1558 .await
1559 };
1560 let pr_content = match ai_result {
1561 Ok(content) => content,
1562 Err(e) if !ai_error_is_transient(&e) => {
1566 return Err(e).context("AI PR generation failed with a non-retryable error");
1567 }
1568 Err(e) => cmd.fallback_pr_content(&e, pr_template, repo_view)?,
1569 };
1570
1571 let pr_yaml = crate::data::to_yaml(&pr_content).context("Failed to serialise PrContent")?;
1572
1573 Ok(CreatePrOutcome {
1574 title: pr_content.title,
1575 description: pr_content.description,
1576 pr_yaml,
1577 })
1578}
1579
1580#[cfg(test)]
1581#[allow(clippy::unwrap_used, clippy::expect_used)]
1582mod run_create_pr_tests {
1583 use super::*;
1584 use crate::claude::client::ClaudeClient;
1585 use crate::claude::error::ClaudeError;
1586 use crate::claude::test_utils::ConfigurableMockAiClient;
1587 use crate::data::context::CommitContext;
1588 use crate::data::{
1589 AiInfo, BranchInfo, FieldExplanation, RepositoryView, VersionInfo, WorkingDirectoryInfo,
1590 };
1591 use crate::git::commit::FileChanges;
1592 use crate::git::{CommitAnalysis, CommitInfo};
1593
1594 #[tokio::test]
1595 async fn run_create_pr_invalid_repo_path_errors_before_ai() {
1596 let err = run_create_pr(
1597 None,
1598 None,
1599 Some(std::path::Path::new("/no/such/path/exists")),
1600 )
1601 .await
1602 .unwrap_err();
1603 let msg = format!("{err:#}").to_lowercase();
1604 assert!(
1609 msg.contains("git")
1610 || msg.contains("repository")
1611 || msg.contains("credential")
1612 || msg.contains("api")
1613 || msg.contains("directory"),
1614 "expected git/repository or preflight error, got: {msg}"
1615 );
1616 }
1617
1618 fn fresh_cmd() -> CreatePrCommand {
1619 CreatePrCommand {
1620 base: None,
1621 auto_apply: true,
1622 save_only: None,
1623 ready: false,
1624 draft: false,
1625 context_dir: None,
1626 no_push: true,
1627 from_commits: false,
1628 }
1629 }
1630
1631 fn sample_commit(hash: &str, message: &str) -> (CommitInfo, tempfile::NamedTempFile) {
1632 let tmp = tempfile::NamedTempFile::new().unwrap();
1633 let commit = CommitInfo {
1634 hash: hash.to_string(),
1635 author: "Test <test@test.com>".to_string(),
1636 date: chrono::Utc::now().fixed_offset(),
1637 original_message: message.to_string(),
1638 in_main_branches: vec![],
1639 analysis: CommitAnalysis {
1640 detected_type: "feat".to_string(),
1641 detected_scope: String::new(),
1642 proposed_message: message.to_string(),
1643 file_changes: FileChanges {
1644 total_files: 0,
1645 files_added: 0,
1646 files_deleted: 0,
1647 file_list: vec![],
1648 },
1649 diff_summary: String::new(),
1650 diff_file: tmp.path().to_string_lossy().to_string(),
1651 file_diffs: Vec::new(),
1652 },
1653 };
1654 (commit, tmp)
1655 }
1656
1657 fn repo_view_with_existing_pr(pr_template: Option<String>, body: &str) -> RepositoryView {
1660 let mut view = sample_repo_view(vec![], pr_template);
1661 view.branch_prs = Some(vec![crate::data::PullRequest {
1662 number: 42,
1663 title: "Existing PR".to_string(),
1664 state: "open".to_string(),
1665 url: "https://example.invalid/pr/42".to_string(),
1666 body: body.to_string(),
1667 base: "main".to_string(),
1668 }]);
1669 view
1670 }
1671
1672 #[test]
1673 fn body_is_safe_to_replace_only_when_empty_or_template() {
1674 let template = "# Pull Request\n\n## Description\n";
1675 assert!(CreatePrCommand::body_is_safe_to_replace("", template));
1676 assert!(CreatePrCommand::body_is_safe_to_replace(
1677 " \n ", template
1678 ));
1679 assert!(CreatePrCommand::body_is_safe_to_replace(
1681 "\n# Pull Request\n\n## Description\n\n",
1682 template
1683 ));
1684 assert!(!CreatePrCommand::body_is_safe_to_replace(
1685 "A real, hand-written description.",
1686 template
1687 ));
1688 }
1689
1690 #[test]
1693 fn refuse_template_clobber_rejects_populated_body() {
1694 let cmd = fresh_cmd();
1695 let view = repo_view_with_existing_pr(None, "A real description worth keeping.");
1696
1697 let err = cmd
1698 .refuse_template_clobber(&view)
1699 .expect_err("must refuse to destroy a populated description");
1700 let chain = format!("{err:#}");
1701 assert!(chain.contains("#42"), "should name the PR: {chain}");
1702 assert!(
1703 chain.contains("Refusing to overwrite"),
1704 "should say what it refused: {chain}"
1705 );
1706 }
1707
1708 #[test]
1709 fn refuse_template_clobber_allows_empty_body() {
1710 let cmd = fresh_cmd();
1711 let view = repo_view_with_existing_pr(None, " ");
1712 assert!(cmd.refuse_template_clobber(&view).is_ok());
1713 }
1714
1715 #[test]
1716 fn refuse_template_clobber_allows_unfilled_template_body() {
1717 let cmd = fresh_cmd();
1718 let template = "# Custom template\n";
1719 let view = repo_view_with_existing_pr(Some(template.to_string()), template);
1720 assert!(cmd.refuse_template_clobber(&view).is_ok());
1721 }
1722
1723 #[test]
1724 fn refuse_template_clobber_allows_when_no_existing_pr() {
1725 let cmd = fresh_cmd();
1726 let view = sample_repo_view(vec![], None);
1727 assert!(cmd.refuse_template_clobber(&view).is_ok());
1728 }
1729
1730 fn sample_repo_view(commits: Vec<CommitInfo>, pr_template: Option<String>) -> RepositoryView {
1731 RepositoryView {
1732 versions: Some(VersionInfo {
1733 omni_dev: "0.0.0".to_string(),
1734 }),
1735 explanation: FieldExplanation::default(),
1736 working_directory: WorkingDirectoryInfo {
1737 clean: true,
1738 untracked_changes: vec![],
1739 },
1740 remotes: vec![],
1741 ai: AiInfo {
1742 scratch: String::new(),
1743 },
1744 branch_info: Some(BranchInfo {
1745 branch: "feature/test".to_string(),
1746 }),
1747 pr_template,
1748 pr_template_location: None,
1749 branch_prs: None,
1750 commits,
1751 }
1752 }
1753
1754 #[tokio::test]
1755 async fn run_create_pr_with_client_ai_success_returns_content() {
1756 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1757 let repo_view = sample_repo_view(vec![c1], None);
1758 let context = CommitContext::new();
1759 let cmd = fresh_cmd();
1760
1761 let yaml = "title: My PR\ndescription: |\n Body text\n".to_string();
1762 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1763 let client = ClaudeClient::new(Box::new(mock));
1764
1765 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1766 .await
1767 .unwrap();
1768 assert_eq!(outcome.title, "My PR");
1769 assert!(outcome.description.contains("Body text"));
1770 assert!(outcome.pr_yaml.contains("title:"));
1771 }
1772
1773 #[tokio::test]
1774 async fn run_create_pr_with_client_ai_failure_falls_back_to_commit_summary() {
1775 let (c1, _tmp) = sample_commit("abcdef00", "feat: single commit subject");
1776 let repo_view = sample_repo_view(vec![c1], None);
1777 let context = CommitContext::new();
1778 let cmd = fresh_cmd();
1779
1780 let mock = ConfigurableMockAiClient::new(vec![]);
1782 let client = ClaudeClient::new(Box::new(mock));
1783
1784 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1785 .await
1786 .unwrap();
1787 assert!(
1788 outcome.title.contains("feat: single commit subject")
1789 || outcome.title.contains("Pull Request")
1790 || outcome.title.contains("feature/test"),
1791 "fallback title unexpected: {}",
1792 outcome.title
1793 );
1794 }
1795
1796 #[tokio::test]
1799 async fn run_create_pr_with_client_permanent_ai_failure_errors() {
1800 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1801 let repo_view = sample_repo_view(vec![c1], None);
1802 let context = CommitContext::new();
1803 let cmd = fresh_cmd();
1804
1805 let mock = ConfigurableMockAiClient::new(vec![Err(ClaudeError::ApiHttpError {
1806 status: 404,
1807 body: "model: claude-sonnet-4-8 not found".to_string(),
1808 }
1809 .into())]);
1810 let client = ClaudeClient::new(Box::new(mock));
1811
1812 let err = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1813 .await
1814 .expect_err("a 404 must not be reported as success");
1815 let chain = format!("{err:#}");
1816 assert!(
1817 chain.contains("non-retryable"),
1818 "error should explain why it did not fall back: {chain}"
1819 );
1820 assert!(
1821 chain.contains("404"),
1822 "error should name the underlying failure: {chain}"
1823 );
1824 }
1825
1826 #[tokio::test]
1829 async fn run_create_pr_with_client_transient_ai_failure_falls_back() {
1830 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1831 let repo_view = sample_repo_view(vec![c1], None);
1832 let context = CommitContext::new();
1833 let cmd = fresh_cmd();
1834
1835 let mock = ConfigurableMockAiClient::new(vec![Err(ClaudeError::ApiHttpError {
1836 status: 503,
1837 body: "upstream unavailable".to_string(),
1838 }
1839 .into())]);
1840 let client = ClaudeClient::new(Box::new(mock));
1841
1842 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1843 .await
1844 .expect("a 5xx should still fall back to the template");
1845 assert!(!outcome.title.is_empty());
1846 }
1847
1848 #[tokio::test]
1849 async fn run_create_pr_with_client_uses_repo_template_when_present() {
1850 let (c1, _tmp) = sample_commit("abcdef00", "feat: x");
1851 let repo_view = sample_repo_view(vec![c1], Some("# Custom template\n".to_string()));
1852 let context = CommitContext::new();
1853 let cmd = fresh_cmd();
1854
1855 let mock = ConfigurableMockAiClient::new(vec![]);
1857 let client = ClaudeClient::new(Box::new(mock));
1858
1859 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1860 .await
1861 .unwrap();
1862 assert!(
1863 outcome.description.contains("# Custom template"),
1864 "fallback description should include repo template: {}",
1865 outcome.description
1866 );
1867 }
1868
1869 #[tokio::test]
1870 async fn run_create_pr_with_client_from_commits_omits_diff() {
1871 let dir = tempfile::tempdir().unwrap();
1874 let diff_path = dir.path().join("recognisable.diff");
1875 std::fs::write(
1876 &diff_path,
1877 "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+UNIQUE_DIFF_MARKER\n",
1878 )
1879 .unwrap();
1880
1881 let commit = CommitInfo {
1882 hash: format!("{:0>40}", 0),
1883 author: "Test <test@test.com>".to_string(),
1884 date: chrono::Utc::now().fixed_offset(),
1885 original_message: "feat: UNIQUE_COMMIT_SUBJECT_MARKER".to_string(),
1886 in_main_branches: vec![],
1887 analysis: CommitAnalysis {
1888 detected_type: "feat".to_string(),
1889 detected_scope: String::new(),
1890 proposed_message: "feat: t".to_string(),
1891 file_changes: FileChanges {
1892 total_files: 1,
1893 files_added: 0,
1894 files_deleted: 0,
1895 file_list: vec![],
1896 },
1897 diff_summary: String::new(),
1898 diff_file: diff_path.to_string_lossy().to_string(),
1899 file_diffs: Vec::new(),
1900 },
1901 };
1902 let repo_view = sample_repo_view(vec![commit], None);
1903 let context = CommitContext::new();
1904 let mut cmd = fresh_cmd();
1905 cmd.from_commits = true;
1906
1907 let yaml = "title: feat: x\ndescription: y\n".to_string();
1908 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1909 let prompt_handle = mock.prompt_handle();
1910 let client = ClaudeClient::new(Box::new(mock));
1911
1912 run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1913 .await
1914 .unwrap();
1915
1916 let prompts = prompt_handle.prompts();
1917 assert_eq!(prompts.len(), 1, "expected one AI call");
1918 let (_, user_prompt) = &prompts[0];
1919 assert!(
1920 user_prompt.contains("UNIQUE_COMMIT_SUBJECT_MARKER"),
1921 "commit subject should be in the prompt"
1922 );
1923 assert!(
1924 !user_prompt.contains("UNIQUE_DIFF_MARKER"),
1925 "diff content must NOT appear in the prompt when from_commits is set"
1926 );
1927 assert!(
1928 !user_prompt.contains("diff --git"),
1929 "diff hunks must NOT appear in the prompt when from_commits is set"
1930 );
1931 }
1932
1933 #[test]
1934 fn create_pr_outcome_clone_and_debug() {
1935 let outcome = CreatePrOutcome {
1936 title: "t".to_string(),
1937 description: "d".to_string(),
1938 pr_yaml: "y".to_string(),
1939 };
1940 let cloned = outcome.clone();
1941 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
1942 }
1943
1944 fn init_repo_with_remote_and_template(template_marker: &str) -> tempfile::TempDir {
1948 use git2::{Repository, Signature};
1949 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1950 std::fs::create_dir_all(&tmp_root).unwrap();
1951 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1952 let repo_path = temp_dir.path();
1953 let repo = Repository::init(repo_path).unwrap();
1954 {
1955 let mut config = repo.config().unwrap();
1956 config.set_str("user.name", "Test").unwrap();
1957 config.set_str("user.email", "test@example.com").unwrap();
1958 config.set_str("init.defaultBranch", "main").unwrap();
1959 }
1960 repo.set_head("refs/heads/main").unwrap();
1961
1962 let signature = Signature::now("Test", "test@example.com").unwrap();
1963
1964 std::fs::write(repo_path.join("f.txt"), "base").unwrap();
1966 let mut idx = repo.index().unwrap();
1967 idx.add_path(std::path::Path::new("f.txt")).unwrap();
1968 idx.write().unwrap();
1969 let tree_id = idx.write_tree().unwrap();
1970 let tree = repo.find_tree(tree_id).unwrap();
1971 let base_oid = repo
1972 .commit(
1973 Some("HEAD"),
1974 &signature,
1975 &signature,
1976 "base: init",
1977 &tree,
1978 &[],
1979 )
1980 .unwrap();
1981
1982 repo.remote("origin", "https://example.com/test/repo.git")
1984 .unwrap();
1985 repo.reference(
1987 "refs/remotes/origin/main",
1988 base_oid,
1989 true,
1990 "set origin/main",
1991 )
1992 .unwrap();
1993
1994 let base_commit = repo.find_commit(base_oid).unwrap();
1996 repo.branch("feature/test", &base_commit, true).unwrap();
1997 repo.set_head("refs/heads/feature/test").unwrap();
1998 std::fs::write(repo_path.join("f.txt"), "feature").unwrap();
1999 let mut idx = repo.index().unwrap();
2000 idx.add_path(std::path::Path::new("f.txt")).unwrap();
2001 idx.write().unwrap();
2002 let tree_id = idx.write_tree().unwrap();
2003 let tree = repo.find_tree(tree_id).unwrap();
2004 repo.commit(
2005 Some("HEAD"),
2006 &signature,
2007 &signature,
2008 "feat: feature work",
2009 &tree,
2010 &[&base_commit],
2011 )
2012 .unwrap();
2013
2014 let github_dir = repo_path.join(".github");
2016 std::fs::create_dir_all(&github_dir).unwrap();
2017 std::fs::write(github_dir.join("pull_request_template.md"), template_marker).unwrap();
2018
2019 temp_dir
2020 }
2021
2022 #[test]
2029 fn generate_repository_view_anchors_to_injected_repo() {
2030 let marker = "## INJECTED_PR_TEMPLATE_MARKER_42";
2031 let temp_dir = init_repo_with_remote_and_template(marker);
2032 let cmd = fresh_cmd();
2033
2034 let repo_view = cmd.generate_repository_view(temp_dir.path()).unwrap();
2035
2036 assert_eq!(
2038 repo_view.pr_template.as_deref(),
2039 Some(marker),
2040 "PR template must be read from the injected repo root"
2041 );
2042 assert_eq!(
2044 repo_view.branch_info.as_ref().map(|b| b.branch.as_str()),
2045 Some("feature/test")
2046 );
2047 assert_eq!(
2048 repo_view.commits.len(),
2049 1,
2050 "exactly the one commit ahead of origin/main"
2051 );
2052 assert!(repo_view.commits[0]
2053 .original_message
2054 .contains("feature work"));
2055 }
2056}
2057
2058#[cfg(test)]
2059mod tests {
2060 use super::*;
2061
2062 #[test]
2065 fn parse_bool_true_variants() {
2066 assert_eq!(parse_bool_string("true"), Some(true));
2067 assert_eq!(parse_bool_string("1"), Some(true));
2068 assert_eq!(parse_bool_string("yes"), Some(true));
2069 }
2070
2071 #[test]
2072 fn parse_bool_false_variants() {
2073 assert_eq!(parse_bool_string("false"), Some(false));
2074 assert_eq!(parse_bool_string("0"), Some(false));
2075 assert_eq!(parse_bool_string("no"), Some(false));
2076 }
2077
2078 #[test]
2079 fn parse_bool_invalid() {
2080 assert_eq!(parse_bool_string("maybe"), None);
2081 assert_eq!(parse_bool_string(""), None);
2082 }
2083
2084 #[test]
2085 fn parse_bool_case_insensitive() {
2086 assert_eq!(parse_bool_string("TRUE"), Some(true));
2087 assert_eq!(parse_bool_string("Yes"), Some(true));
2088 assert_eq!(parse_bool_string("FALSE"), Some(false));
2089 assert_eq!(parse_bool_string("No"), Some(false));
2090 }
2091
2092 #[test]
2095 fn breaking_change_type_contains() {
2096 assert!(is_breaking_change("BREAKING", "normal message"));
2097 }
2098
2099 #[test]
2100 fn breaking_change_message_contains() {
2101 assert!(is_breaking_change("feat", "BREAKING CHANGE: removed API"));
2102 }
2103
2104 #[test]
2105 fn breaking_change_none() {
2106 assert!(!is_breaking_change("feat", "add new feature"));
2107 }
2108
2109 #[test]
2112 fn check_checkbox_found() {
2113 let mut desc = "- [ ] New feature\n- [ ] Bug fix".to_string();
2114 check_checkbox(&mut desc, "- [ ] New feature");
2115 assert!(desc.contains("- [x] New feature"));
2116 assert!(desc.contains("- [ ] Bug fix"));
2117 }
2118
2119 #[test]
2120 fn check_checkbox_not_found() {
2121 let mut desc = "- [ ] Bug fix".to_string();
2122 let original = desc.clone();
2123 check_checkbox(&mut desc, "- [ ] New feature");
2124 assert_eq!(desc, original);
2125 }
2126
2127 #[test]
2130 fn scopes_section_single() {
2131 let scopes = vec!["cli".to_string()];
2132 assert_eq!(
2133 format_scopes_section(&scopes),
2134 "**Affected areas:** cli\n\n"
2135 );
2136 }
2137
2138 #[test]
2139 fn scopes_section_multiple() {
2140 let scopes = vec!["cli".to_string(), "git".to_string()];
2141 let result = format_scopes_section(&scopes);
2142 assert!(result.contains("cli"));
2143 assert!(result.contains("git"));
2144 assert!(result.starts_with("**Affected areas:**"));
2145 }
2146
2147 #[test]
2148 fn scopes_section_empty() {
2149 assert_eq!(format_scopes_section(&[]), "");
2150 }
2151
2152 #[test]
2155 fn commit_list_formatting() {
2156 let entries = vec![
2157 ("abc12345", "feat: add feature"),
2158 ("def67890", "fix: resolve bug"),
2159 ];
2160 let result = format_commit_list(&entries);
2161 assert!(result.contains("### Commits in this PR:"));
2162 assert!(result.contains("- `abc12345` feat: add feature"));
2163 assert!(result.contains("- `def67890` fix: resolve bug"));
2164 }
2165
2166 #[test]
2169 fn clean_branch_simple() {
2170 assert_eq!(clean_branch_name("feat/add-login"), "feat add login");
2171 }
2172
2173 #[test]
2174 fn clean_branch_underscores() {
2175 assert_eq!(clean_branch_name("user_name/fix_bug"), "user name fix bug");
2176 }
2177
2178 #[test]
2181 fn first_line_multiline() {
2182 assert_eq!(extract_first_line("first\nsecond\nthird"), "first");
2183 }
2184
2185 #[test]
2186 fn first_line_single() {
2187 assert_eq!(extract_first_line("only line"), "only line");
2188 }
2189
2190 #[test]
2191 fn first_line_empty() {
2192 assert_eq!(extract_first_line(""), "");
2193 }
2194
2195 #[test]
2198 fn draft_status_true() {
2199 let (icon, text) = format_draft_status(true);
2200 assert_eq!(text, "draft");
2201 assert!(!icon.is_empty());
2202 }
2203
2204 #[test]
2205 fn draft_status_false() {
2206 let (icon, text) = format_draft_status(false);
2207 assert_eq!(text, "ready for review");
2208 assert!(!icon.is_empty());
2209 }
2210
2211 #[test]
2214 fn push_action_skip_when_no_push() {
2215 assert_eq!(determine_push_action(true, false), PushAction::Skip);
2216 assert_eq!(determine_push_action(true, true), PushAction::Skip);
2217 }
2218
2219 #[test]
2220 fn push_action_sync_existing_branch() {
2221 assert_eq!(determine_push_action(false, true), PushAction::SyncExisting);
2222 }
2223
2224 #[test]
2225 fn push_action_push_new_branch() {
2226 assert_eq!(determine_push_action(false, false), PushAction::PushNew);
2227 }
2228}