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