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).await?;
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 async fn show_context_information(
503 &self,
504 _repo_view: &crate::data::RepositoryView,
505 ) -> Result<()> {
506 Ok(())
511 }
512
513 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
515 let base_branch = match self.base.as_ref() {
517 Some(branch) => {
518 let primary_remote_name = repo_view
521 .remotes
522 .iter()
523 .find(|r| r.name == "origin")
524 .or_else(|| repo_view.remotes.first())
525 .map_or("origin", |r| r.name.as_str());
526 if branch.starts_with(&format!("{primary_remote_name}/")) {
528 branch.clone()
529 } else {
530 format!("{primary_remote_name}/{branch}")
531 }
532 }
533 None => {
534 repo_view
536 .remotes
537 .iter()
538 .find(|r| r.name == "origin")
539 .or_else(|| repo_view.remotes.first())
540 .map_or_else(
541 || "unknown".to_string(),
542 |r| format!("{}/{}", r.name, r.main_branch),
543 )
544 }
545 };
546
547 let commit_range = format!("{base_branch}..HEAD");
548 let commit_count = repo_view.commits.len();
549
550 let current_branch = repo_view
552 .branch_info
553 .as_ref()
554 .map_or("unknown", |bi| bi.branch.as_str());
555
556 println!("📊 Branch Analysis:");
557 println!(" 🌿 Current branch: {current_branch}");
558 println!(" 📏 Commit range: {commit_range}");
559 println!(" 📝 Commits found: {commit_count} commits");
560 println!();
561
562 Ok(())
563 }
564
565 async fn collect_context(
567 &self,
568 repo_root: &std::path::Path,
569 repo_view: &crate::data::RepositoryView,
570 ) -> Result<crate::data::context::CommitContext> {
571 use crate::claude::context::{
572 BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
573 };
574 use crate::data::context::{CommitContext, ProjectContext};
575 use crate::git::GitRepository;
576
577 let mut context = CommitContext::new();
578
579 let context_dir =
581 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
582
583 let discovery = ProjectDiscovery::new(repo_root.to_path_buf(), context_dir);
585 match discovery.discover() {
586 Ok(project_context) => {
587 context.project = project_context;
588 }
589 Err(_e) => {
590 context.project = ProjectContext::default();
591 }
592 }
593
594 let repo = GitRepository::open_at(repo_root)?;
596 let current_branch = repo
597 .get_current_branch()
598 .unwrap_or_else(|_| "HEAD".to_string());
599 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
600
601 if !repo_view.commits.is_empty() {
603 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
604 }
605
606 if !repo_view.commits.is_empty() {
608 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
609 }
610
611 Ok(context)
612 }
613
614 fn show_guidance_files_status(
616 &self,
617 repo_root: &std::path::Path,
618 project_context: &crate::data::context::ProjectContext,
619 ) -> Result<()> {
620 use crate::claude::context::{
621 config_source_label, resolve_context_dir_with_source_at, ConfigSourceLabel,
622 };
623
624 let (context_dir, dir_source) =
625 resolve_context_dir_with_source_at(self.context_dir.as_deref(), repo_root);
626
627 println!("📋 Project guidance files status:");
628 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
629
630 let pr_guidelines_source = if project_context.pr_guidelines.is_some() {
632 match config_source_label(&context_dir, "pr-guidelines.md") {
633 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
634 label => format!("✅ {label}"),
635 }
636 } else {
637 "❌ None found".to_string()
638 };
639 println!(" 🔀 PR guidelines: {pr_guidelines_source}");
640
641 let scopes_count = project_context.valid_scopes.len();
643 let scopes_source = if scopes_count > 0 {
644 match config_source_label(&context_dir, "scopes.yaml") {
645 ConfigSourceLabel::NotFound => {
646 format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
647 }
648 label => format!("✅ {label} ({scopes_count} scopes)"),
649 }
650 } else {
651 "❌ None found".to_string()
652 };
653 println!(" 🎯 Valid scopes: {scopes_source}");
654
655 let pr_template_path = repo_root.join(".github/pull_request_template.md");
657 let pr_template_status = if pr_template_path.exists() {
658 format!("✅ Project: {}", pr_template_path.display())
659 } else {
660 "❌ None found".to_string()
661 };
662 println!(" 📋 PR template: {pr_template_status}");
663
664 println!();
665 Ok(())
666 }
667
668 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
670 use crate::data::context::{VerbosityLevel, WorkPattern};
671
672 println!("🔍 Context Analysis:");
673
674 if !context.project.valid_scopes.is_empty() {
676 let scope_names: Vec<&str> = context
677 .project
678 .valid_scopes
679 .iter()
680 .map(|s| s.name.as_str())
681 .collect();
682 println!(" 📁 Valid scopes: {}", scope_names.join(", "));
683 }
684
685 if context.branch.is_feature_branch {
687 println!(
688 " 🌿 Branch: {} ({})",
689 context.branch.description, context.branch.work_type
690 );
691 if let Some(ref ticket) = context.branch.ticket_id {
692 println!(" 🎫 Ticket: {ticket}");
693 }
694 }
695
696 match context.range.work_pattern {
698 WorkPattern::Sequential => println!(" 🔄 Pattern: Sequential development"),
699 WorkPattern::Refactoring => println!(" 🧹 Pattern: Refactoring work"),
700 WorkPattern::BugHunt => println!(" 🐛 Pattern: Bug investigation"),
701 WorkPattern::Documentation => println!(" 📖 Pattern: Documentation updates"),
702 WorkPattern::Configuration => println!(" ⚙️ Pattern: Configuration changes"),
703 WorkPattern::Unknown => {}
704 }
705
706 if let Some(label) = super::formatting::format_file_analysis(&context.files) {
708 println!(" {label}");
709 }
710
711 match context.suggested_verbosity() {
713 VerbosityLevel::Comprehensive => {
714 println!(" 📝 Detail level: Comprehensive (significant changes detected)");
715 }
716 VerbosityLevel::Detailed => println!(" 📝 Detail level: Detailed"),
717 VerbosityLevel::Concise => println!(" 📝 Detail level: Concise"),
718 }
719
720 println!();
721 Ok(())
722 }
723
724 async fn generate_pr_content_with_client_internal(
730 &self,
731 repo_root: &std::path::Path,
732 repo_view: &crate::data::RepositoryView,
733 claude_client: crate::claude::client::ClaudeClient,
734 ) -> Result<(GeneratedPr, crate::claude::client::ClaudeClient)> {
735 use tracing::debug;
736
737 let pr_template = self.resolve_pr_template(repo_view);
738
739 debug!(
740 pr_template_length = pr_template.len(),
741 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
742 "Using PR template for generation"
743 );
744
745 println!("🤖 Generating AI-powered PR description...");
746
747 debug!("Collecting context for PR generation");
749 let context = self.collect_context(repo_root, repo_view).await?;
750 debug!("Context collection completed");
751
752 debug!(
754 from_commits = self.from_commits,
755 "About to call Claude AI for PR content generation"
756 );
757 let ai_result = if self.from_commits {
758 claude_client
759 .generate_pr_content_with_context_from_commits(repo_view, &pr_template, &context)
760 .await
761 } else {
762 claude_client
763 .generate_pr_content_with_context(repo_view, &pr_template, &context)
764 .await
765 };
766 match ai_result {
767 Ok(pr_content) => {
768 debug!(
769 ai_generated_title = %pr_content.title,
770 ai_generated_description_length = pr_content.description.len(),
771 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
772 "AI successfully generated PR content"
773 );
774 Ok((GeneratedPr::from_ai(pr_content), claude_client))
775 }
776 Err(e) if !ai_error_is_transient(&e) => {
779 Err(e).context("AI PR generation failed with a non-retryable error")
780 }
781 Err(e) => {
782 let content = self.fallback_pr_content(&e, pr_template, repo_view)?;
783 Ok((GeneratedPr::from_fallback(content), claude_client))
784 }
785 }
786 }
787
788 fn fallback_pr_content(
794 &self,
795 error: &anyhow::Error,
796 pr_template: String,
797 repo_view: &crate::data::RepositoryView,
798 ) -> Result<PrContent> {
799 warn!(error = %format!("{error:#}"), "AI PR generation failed, falling back to the PR template");
800 eprintln!("warning: AI PR generation failed: {error:#}");
801 eprintln!(
802 "warning: falling back to the PR template — the description below is not AI-generated."
803 );
804
805 let mut description = pr_template;
806 self.enhance_description_with_commits(&mut description, repo_view)?;
807 let title = self.generate_title_from_commits(repo_view);
808
809 debug!(
810 fallback_title = %title,
811 fallback_description_length = description.len(),
812 "Created fallback PR content"
813 );
814
815 Ok(PrContent { title, description })
816 }
817
818 fn resolve_pr_template(&self, repo_view: &crate::data::RepositoryView) -> String {
821 match &repo_view.pr_template {
822 Some(template) => template.clone(),
823 None => self.get_default_pr_template(),
824 }
825 }
826
827 fn refuse_template_clobber(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
835 let Some(existing) = repo_view.branch_prs.as_ref().and_then(|prs| prs.first()) else {
836 return Ok(());
837 };
838
839 if Self::body_is_safe_to_replace(&existing.body, &self.resolve_pr_template(repo_view)) {
840 return Ok(());
841 }
842
843 bail!(
844 "Refusing to overwrite the description of PR #{} with template content \
845 after AI generation failed.\n\
846 The existing description would be lost and cannot be recovered by this tool.\n\
847 Re-run without --auto-apply to review the content first, or resolve the AI \
848 failure reported above.",
849 existing.number
850 );
851 }
852
853 fn body_is_safe_to_replace(body: &str, template: &str) -> bool {
859 let body = body.trim();
860 body.is_empty() || body == template.trim()
861 }
862
863 fn get_default_pr_template(&self) -> String {
865 r#"# Pull Request
866
867## Description
868<!-- Provide a brief description of what this PR does -->
869
870## Type of Change
871<!-- Mark the relevant option with an "x" -->
872- [ ] Bug fix (non-breaking change which fixes an issue)
873- [ ] New feature (non-breaking change which adds functionality)
874- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
875- [ ] Documentation update
876- [ ] Refactoring (no functional changes)
877- [ ] Performance improvement
878- [ ] Test coverage improvement
879
880## Changes Made
881<!-- List the specific changes made in this PR -->
882-
883-
884-
885
886## Testing
887- [ ] All existing tests pass
888- [ ] New tests added for new functionality
889- [ ] Manual testing performed
890
891## Additional Notes
892<!-- Add any additional notes for reviewers -->
893"#.to_string()
894 }
895
896 fn enhance_description_with_commits(
898 &self,
899 description: &mut String,
900 repo_view: &crate::data::RepositoryView,
901 ) -> Result<()> {
902 if repo_view.commits.is_empty() {
903 return Ok(());
904 }
905
906 description.push_str("\n---\n");
908 description.push_str("## 📝 Commit Summary\n");
909 description
910 .push_str("*This section was automatically generated based on commit analysis*\n\n");
911
912 let mut types_found = std::collections::HashSet::new();
914 let mut scopes_found = std::collections::HashSet::new();
915 let mut has_breaking_changes = false;
916
917 for commit in &repo_view.commits {
918 let detected_type = &commit.analysis.detected_type;
919 types_found.insert(detected_type.clone());
920 if is_breaking_change(detected_type, &commit.original_message) {
921 has_breaking_changes = true;
922 }
923
924 let detected_scope = &commit.analysis.detected_scope;
925 if !detected_scope.is_empty() {
926 scopes_found.insert(detected_scope.clone());
927 }
928 }
929
930 if types_found.contains("feat") {
932 check_checkbox(description, "- [ ] New feature");
933 }
934 if types_found.contains("fix") {
935 check_checkbox(description, "- [ ] Bug fix");
936 }
937 if types_found.contains("docs") {
938 check_checkbox(description, "- [ ] Documentation update");
939 }
940 if types_found.contains("refactor") {
941 check_checkbox(description, "- [ ] Refactoring");
942 }
943 if has_breaking_changes {
944 check_checkbox(description, "- [ ] Breaking change");
945 }
946
947 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
949 let scopes_section = format_scopes_section(&scopes_list);
950 if !scopes_section.is_empty() {
951 description.push_str(&scopes_section);
952 }
953
954 let commit_entries: Vec<(&str, &str)> = repo_view
956 .commits
957 .iter()
958 .map(|c| {
959 let short = &c.hash[..crate::git::SHORT_HASH_LEN];
960 let first = extract_first_line(&c.original_message);
961 (short, first)
962 })
963 .collect();
964 description.push_str(&format_commit_list(&commit_entries));
965
966 let total_files: usize = repo_view
968 .commits
969 .iter()
970 .map(|c| c.analysis.file_changes.total_files)
971 .sum();
972
973 if total_files > 0 {
974 description.push_str(&format!("\n**Files changed:** {total_files} files\n"));
975 }
976
977 Ok(())
978 }
979
980 fn handle_pr_file(
982 &self,
983 pr_file: &std::path::Path,
984 repo_view: &crate::data::RepositoryView,
985 ) -> Result<PrAction> {
986 use std::io::{self, Write};
987
988 println!("\n📝 PR details generated.");
989 println!("💾 Details saved to: {}", pr_file.display());
990
991 let is_draft = self.should_create_as_draft();
993 let (status_icon, status_text) = format_draft_status(is_draft);
994 println!("{status_icon} PR will be created as: {status_text}");
995 println!();
996
997 let has_existing_prs = repo_view
999 .branch_prs
1000 .as_ref()
1001 .is_some_and(|prs| !prs.is_empty());
1002
1003 loop {
1004 if has_existing_prs {
1005 print!("❓ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
1006 } else {
1007 print!(
1008 "❓ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
1009 );
1010 }
1011 io::stdout().flush()?;
1012
1013 let mut input = String::new();
1014 io::stdin().read_line(&mut input)?;
1015
1016 match input.trim().to_lowercase().as_str() {
1017 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
1018 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
1019 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
1020 "s" | "show" => {
1021 self.show_pr_file(pr_file)?;
1022 println!();
1023 }
1024 "e" | "edit" => {
1025 self.edit_pr_file(pr_file)?;
1026 println!();
1027 }
1028 "q" | "quit" => return Ok(PrAction::Cancel),
1029 _ => {
1030 if has_existing_prs {
1031 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
1032 } else {
1033 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
1034 }
1035 }
1036 }
1037 }
1038 }
1039
1040 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
1042 use std::fs;
1043
1044 println!("\n📄 PR details file contents:");
1045 println!("─────────────────────────────");
1046
1047 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
1048 println!("{contents}");
1049 println!("─────────────────────────────");
1050
1051 Ok(())
1052 }
1053
1054 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
1056 use std::env;
1057 use std::io::{self, Write};
1058 use std::process::Command;
1059
1060 let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
1062 e
1063 } else {
1064 println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
1066 print!("Please enter the command to use as your editor: ");
1067 io::stdout().flush().context("Failed to flush stdout")?;
1068
1069 let mut input = String::new();
1070 io::stdin()
1071 .read_line(&mut input)
1072 .context("Failed to read user input")?;
1073 input.trim().to_string()
1074 };
1075
1076 if editor.is_empty() {
1077 println!("❌ No editor specified. Returning to menu.");
1078 return Ok(());
1079 }
1080
1081 println!("📝 Opening PR details file in editor: {editor}");
1082
1083 let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
1084
1085 let mut command = Command::new(editor_cmd);
1086 command.args(args);
1087 command.arg(pr_file.to_string_lossy().as_ref());
1088
1089 match command.status() {
1090 Ok(status) => {
1091 if status.success() {
1092 println!("✅ Editor session completed.");
1093 } else {
1094 println!(
1095 "⚠️ Editor exited with non-zero status: {:?}",
1096 status.code()
1097 );
1098 }
1099 }
1100 Err(e) => {
1101 println!("❌ Failed to execute editor '{editor}': {e}");
1102 println!(" Please check that the editor command is correct and available in your PATH.");
1103 }
1104 }
1105
1106 Ok(())
1107 }
1108
1109 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
1111 if repo_view.commits.is_empty() {
1112 return "Pull Request".to_string();
1113 }
1114
1115 if repo_view.commits.len() == 1 {
1117 let first = extract_first_line(&repo_view.commits[0].original_message);
1118 let trimmed = first.trim();
1119 return if trimmed.is_empty() {
1120 "Pull Request".to_string()
1121 } else {
1122 trimmed.to_string()
1123 };
1124 }
1125
1126 let branch_name = repo_view
1128 .branch_info
1129 .as_ref()
1130 .map_or("feature", |bi| bi.branch.as_str());
1131
1132 format!("feat: {}", clean_branch_name(branch_name))
1133 }
1134
1135 fn create_github_pr(
1137 &self,
1138 repo_root: &std::path::Path,
1139 repo_view: &crate::data::RepositoryView,
1140 title: &str,
1141 description: &str,
1142 is_draft: bool,
1143 new_base: Option<&str>,
1144 ) -> Result<()> {
1145 let branch_name = repo_view
1147 .branch_info
1148 .as_ref()
1149 .map(|bi| &bi.branch)
1150 .context("Branch info not available")?;
1151
1152 let pr_status = if is_draft {
1153 "draft"
1154 } else {
1155 "ready for review"
1156 };
1157 println!("🚀 Creating pull request ({pr_status})...");
1158 println!(" 📋 Title: {title}");
1159 println!(" 🌿 Branch: {branch_name}");
1160 if let Some(base) = new_base {
1161 println!(" 🎯 Base: {base}");
1162 }
1163
1164 let push_action = if self.no_push {
1166 determine_push_action(true, false)
1167 } else {
1168 debug!("Opening git repository to check branch status");
1169 let git_repo = crate::git::GitRepository::open_at(repo_root)
1170 .context("Failed to open git repository at the given path")?;
1171
1172 debug!(
1173 "Checking if branch '{}' exists on remote 'origin'",
1174 branch_name
1175 );
1176 let branch_on_remote = git_repo.branch_exists_on_remote(branch_name, "origin")?;
1177 let action = determine_push_action(false, branch_on_remote);
1178
1179 debug!("Push action for branch '{}': {:?}", branch_name, action);
1180 println!("📤 Pushing branch to remote...");
1181 git_repo
1182 .push_branch(branch_name, "origin")
1183 .context("Failed to push branch to remote")?;
1184
1185 action
1186 };
1187
1188 if push_action == PushAction::Skip {
1189 debug!("Skipping push (--no-push flag set)");
1190 }
1191
1192 debug!("Creating PR with gh CLI - title: '{}'", title);
1194 debug!("PR description length: {} characters", description.len());
1195 debug!("PR draft status: {}", is_draft);
1196 if let Some(base) = new_base {
1197 debug!("PR base branch: {}", base);
1198 }
1199
1200 let mut args = vec![
1201 "pr",
1202 "create",
1203 "--head",
1204 branch_name,
1205 "--title",
1206 title,
1207 "--body",
1208 description,
1209 ];
1210
1211 if let Some(base) = new_base {
1212 args.push("--base");
1213 args.push(base);
1214 }
1215
1216 if is_draft {
1217 args.push("--draft");
1218 }
1219
1220 let pr_result = crate::github_metrics::run_gh(
1221 &crate::pr_status::resolve_gh_binary(),
1222 args,
1223 "pr create",
1224 Some(repo_root),
1225 )
1226 .context("Failed to create pull request")?;
1227
1228 if pr_result.status.success() {
1229 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
1230 let pr_url = pr_url.trim();
1231 debug!("PR created successfully with URL: {}", pr_url);
1232 println!("🎉 Pull request created: {pr_url}");
1233 } else {
1234 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1235 error!("gh CLI failed to create PR: {}", error_msg);
1236 anyhow::bail!("Failed to create pull request: {error_msg}");
1237 }
1238
1239 Ok(())
1240 }
1241
1242 fn update_github_pr(
1244 &self,
1245 repo_root: &std::path::Path,
1246 repo_view: &crate::data::RepositoryView,
1247 title: &str,
1248 description: &str,
1249 new_base: Option<&str>,
1250 ) -> Result<()> {
1251 use std::io::{self, Write};
1252
1253 let existing_pr = repo_view
1255 .branch_prs
1256 .as_ref()
1257 .and_then(|prs| prs.first())
1258 .context("No existing PR found to update")?;
1259
1260 let pr_number = existing_pr.number;
1261 let current_base = &existing_pr.base;
1262
1263 println!("🚀 Updating pull request #{pr_number}...");
1264 println!(" 📋 Title: {title}");
1265
1266 let change_base = if let Some(base) = new_base {
1268 if !current_base.is_empty() && current_base != base {
1269 print!(" 🎯 Current base: {current_base} → New base: {base}. Change? [y/N]: ");
1270 io::stdout().flush()?;
1271
1272 let mut input = String::new();
1273 io::stdin().read_line(&mut input)?;
1274 let response = input.trim().to_lowercase();
1275 response == "y" || response == "yes"
1276 } else {
1277 false
1278 }
1279 } else {
1280 false
1281 };
1282
1283 debug!(
1284 pr_number = pr_number,
1285 title = %title,
1286 description_length = description.len(),
1287 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1288 change_base = change_base,
1289 "Updating GitHub PR with title and description"
1290 );
1291
1292 let pr_number_str = pr_number.to_string();
1294 let mut gh_args = vec![
1295 "pr",
1296 "edit",
1297 &pr_number_str,
1298 "--title",
1299 title,
1300 "--body",
1301 description,
1302 ];
1303
1304 if change_base {
1305 if let Some(base) = new_base {
1306 gh_args.push("--base");
1307 gh_args.push(base);
1308 }
1309 }
1310
1311 debug!(
1312 args = ?gh_args,
1313 "Executing gh command to update PR"
1314 );
1315
1316 let pr_result = crate::github_metrics::run_gh(
1317 &crate::pr_status::resolve_gh_binary(),
1318 gh_args,
1319 "pr edit",
1320 Some(repo_root),
1321 )
1322 .context("Failed to update pull request")?;
1323
1324 if pr_result.status.success() {
1325 println!("🎉 Pull request updated: {}", existing_pr.url);
1327 if change_base {
1328 if let Some(base) = new_base {
1329 println!(" 🎯 Base branch changed to: {base}");
1330 }
1331 }
1332 } else {
1333 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1334 anyhow::bail!("Failed to update pull request: {error_msg}");
1335 }
1336
1337 Ok(())
1338 }
1339
1340 fn show_model_info_from_client(
1342 &self,
1343 client: &crate::claude::client::ClaudeClient,
1344 ) -> Result<()> {
1345 use crate::claude::model_config::get_model_registry;
1346
1347 println!("🤖 AI Model Configuration:");
1348
1349 let metadata = client.get_ai_client_metadata();
1351 let registry = get_model_registry();
1357
1358 if let Some(spec) = registry.get_model_spec(&metadata.model) {
1359 if metadata.model != spec.api_identifier {
1361 println!(
1362 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
1363 metadata.model, spec.api_identifier
1364 );
1365 } else {
1366 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1367 }
1368
1369 println!(" 🏷️ Provider: {}", spec.provider);
1370 println!(" 📊 Generation: {}", spec.generation);
1371 println!(" ⭐ Tier: {} ({})", spec.tier, {
1372 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1373 &tier_info.description
1374 } else {
1375 "No description available"
1376 }
1377 });
1378 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1379 println!(" 📥 Input context: {}", metadata.max_context_length);
1380
1381 if let Some((ref key, ref value)) = metadata.active_beta {
1382 println!(" 🔬 Beta header: {key}: {value}");
1383 }
1384
1385 if spec.legacy {
1386 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
1387 }
1388 } else {
1389 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1391 println!(" 🏷️ Provider: {}", metadata.provider);
1392 println!(" ⚠️ Model not found in registry, using client metadata:");
1393 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1394 println!(" 📥 Input context: {}", metadata.max_context_length);
1395 }
1396
1397 println!();
1398 Ok(())
1399 }
1400}
1401
1402#[derive(Debug, PartialEq)]
1406enum PushAction {
1407 Skip,
1409 SyncExisting,
1411 PushNew,
1413}
1414
1415fn determine_push_action(no_push: bool, branch_on_remote: bool) -> PushAction {
1417 if no_push {
1418 PushAction::Skip
1419 } else if branch_on_remote {
1420 PushAction::SyncExisting
1421 } else {
1422 PushAction::PushNew
1423 }
1424}
1425
1426fn parse_bool_string(val: &str) -> Option<bool> {
1431 match val.to_lowercase().as_str() {
1432 "true" | "1" | "yes" => Some(true),
1433 "false" | "0" | "no" => Some(false),
1434 _ => None,
1435 }
1436}
1437
1438fn is_breaking_change(detected_type: &str, original_message: &str) -> bool {
1440 detected_type.contains("BREAKING") || original_message.contains("BREAKING CHANGE")
1441}
1442
1443fn check_checkbox(description: &mut String, search_text: &str) {
1445 if let Some(pos) = description.find(search_text) {
1446 description.replace_range(pos..pos + 5, "- [x]");
1447 }
1448}
1449
1450fn format_scopes_section(scopes: &[String]) -> String {
1454 if scopes.is_empty() {
1455 return String::new();
1456 }
1457 format!("**Affected areas:** {}\n\n", scopes.join(", "))
1458}
1459
1460fn format_commit_list(entries: &[(&str, &str)]) -> String {
1462 let mut output = String::from("### Commits in this PR:\n");
1463 for (hash, message) in entries {
1464 output.push_str(&format!("- `{hash}` {message}\n"));
1465 }
1466 output
1467}
1468
1469fn clean_branch_name(branch: &str) -> String {
1471 branch.replace(['/', '-', '_'], " ")
1472}
1473
1474fn extract_first_line(text: &str) -> &str {
1476 text.lines().next().unwrap_or("").trim()
1477}
1478
1479fn format_draft_status(is_draft: bool) -> (&'static str, &'static str) {
1481 if is_draft {
1482 ("\u{1f4cb}", "draft")
1483 } else {
1484 ("\u{2705}", "ready for review")
1485 }
1486}
1487
1488#[derive(Debug, Clone)]
1490pub struct CreatePrOutcome {
1491 pub title: String,
1493 pub description: String,
1495 pub pr_yaml: String,
1497}
1498
1499pub async fn run_create_pr(
1508 model: Option<String>,
1509 base_branch: Option<&str>,
1510 repo_path: Option<&std::path::Path>,
1511) -> Result<CreatePrOutcome> {
1512 let repo_root = match repo_path {
1516 Some(p) => p.to_path_buf(),
1517 None => std::env::current_dir().context("Failed to determine current directory")?,
1518 };
1519
1520 crate::utils::check_pr_command_prerequisites(model.as_deref(), &repo_root)?;
1521
1522 let cmd = CreatePrCommand {
1523 base: base_branch.map(str::to_string),
1524 auto_apply: true,
1525 save_only: None,
1526 ready: false,
1527 draft: false,
1528 context_dir: None,
1529 no_push: true,
1530 from_commits: false,
1531 };
1532
1533 let repo_view = cmd.generate_repository_view(&repo_root)?;
1534 let context = cmd.collect_context(&repo_root, &repo_view).await?;
1535 let claude_client = crate::claude::create_default_claude_client(model, None).await?;
1536 run_create_pr_with_client(&cmd, &repo_view, &context, &claude_client).await
1537}
1538
1539pub(crate) async fn run_create_pr_with_client(
1547 cmd: &CreatePrCommand,
1548 repo_view: &crate::data::RepositoryView,
1549 context: &crate::data::context::CommitContext,
1550 claude_client: &crate::claude::client::ClaudeClient,
1551) -> Result<CreatePrOutcome> {
1552 let pr_template = cmd.resolve_pr_template(repo_view);
1553
1554 let ai_result = if cmd.from_commits {
1555 claude_client
1556 .generate_pr_content_with_context_from_commits(repo_view, &pr_template, context)
1557 .await
1558 } else {
1559 claude_client
1560 .generate_pr_content_with_context(repo_view, &pr_template, context)
1561 .await
1562 };
1563 let pr_content = match ai_result {
1564 Ok(content) => content,
1565 Err(e) if !ai_error_is_transient(&e) => {
1569 return Err(e).context("AI PR generation failed with a non-retryable error");
1570 }
1571 Err(e) => cmd.fallback_pr_content(&e, pr_template, repo_view)?,
1572 };
1573
1574 let pr_yaml = crate::data::to_yaml(&pr_content).context("Failed to serialise PrContent")?;
1575
1576 Ok(CreatePrOutcome {
1577 title: pr_content.title,
1578 description: pr_content.description,
1579 pr_yaml,
1580 })
1581}
1582
1583#[cfg(test)]
1584#[allow(clippy::unwrap_used, clippy::expect_used)]
1585mod run_create_pr_tests {
1586 use super::*;
1587 use crate::claude::client::ClaudeClient;
1588 use crate::claude::error::ClaudeError;
1589 use crate::claude::test_utils::ConfigurableMockAiClient;
1590 use crate::data::context::CommitContext;
1591 use crate::data::{
1592 AiInfo, BranchInfo, FieldExplanation, RepositoryView, VersionInfo, WorkingDirectoryInfo,
1593 };
1594 use crate::git::commit::FileChanges;
1595 use crate::git::{CommitAnalysis, CommitInfo};
1596
1597 #[tokio::test]
1598 async fn run_create_pr_invalid_repo_path_errors_before_ai() {
1599 let err = run_create_pr(
1600 None,
1601 None,
1602 Some(std::path::Path::new("/no/such/path/exists")),
1603 )
1604 .await
1605 .unwrap_err();
1606 let msg = format!("{err:#}").to_lowercase();
1607 assert!(
1612 msg.contains("git")
1613 || msg.contains("repository")
1614 || msg.contains("credential")
1615 || msg.contains("api")
1616 || msg.contains("directory"),
1617 "expected git/repository or preflight error, got: {msg}"
1618 );
1619 }
1620
1621 fn fresh_cmd() -> CreatePrCommand {
1622 CreatePrCommand {
1623 base: None,
1624 auto_apply: true,
1625 save_only: None,
1626 ready: false,
1627 draft: false,
1628 context_dir: None,
1629 no_push: true,
1630 from_commits: false,
1631 }
1632 }
1633
1634 fn sample_commit(hash: &str, message: &str) -> (CommitInfo, tempfile::NamedTempFile) {
1635 let tmp = tempfile::NamedTempFile::new().unwrap();
1636 let commit = CommitInfo {
1637 hash: hash.to_string(),
1638 author: "Test <test@test.com>".to_string(),
1639 date: chrono::Utc::now().fixed_offset(),
1640 original_message: message.to_string(),
1641 in_main_branches: vec![],
1642 analysis: CommitAnalysis {
1643 detected_type: "feat".to_string(),
1644 detected_scope: String::new(),
1645 proposed_message: message.to_string(),
1646 file_changes: FileChanges {
1647 total_files: 0,
1648 files_added: 0,
1649 files_deleted: 0,
1650 file_list: vec![],
1651 },
1652 diff_summary: String::new(),
1653 diff_file: tmp.path().to_string_lossy().to_string(),
1654 file_diffs: Vec::new(),
1655 },
1656 };
1657 (commit, tmp)
1658 }
1659
1660 fn repo_view_with_existing_pr(pr_template: Option<String>, body: &str) -> RepositoryView {
1663 let mut view = sample_repo_view(vec![], pr_template);
1664 view.branch_prs = Some(vec![crate::data::PullRequest {
1665 number: 42,
1666 title: "Existing PR".to_string(),
1667 state: "open".to_string(),
1668 url: "https://example.invalid/pr/42".to_string(),
1669 body: body.to_string(),
1670 base: "main".to_string(),
1671 }]);
1672 view
1673 }
1674
1675 #[test]
1676 fn body_is_safe_to_replace_only_when_empty_or_template() {
1677 let template = "# Pull Request\n\n## Description\n";
1678 assert!(CreatePrCommand::body_is_safe_to_replace("", template));
1679 assert!(CreatePrCommand::body_is_safe_to_replace(
1680 " \n ", template
1681 ));
1682 assert!(CreatePrCommand::body_is_safe_to_replace(
1684 "\n# Pull Request\n\n## Description\n\n",
1685 template
1686 ));
1687 assert!(!CreatePrCommand::body_is_safe_to_replace(
1688 "A real, hand-written description.",
1689 template
1690 ));
1691 }
1692
1693 #[test]
1696 fn refuse_template_clobber_rejects_populated_body() {
1697 let cmd = fresh_cmd();
1698 let view = repo_view_with_existing_pr(None, "A real description worth keeping.");
1699
1700 let err = cmd
1701 .refuse_template_clobber(&view)
1702 .expect_err("must refuse to destroy a populated description");
1703 let chain = format!("{err:#}");
1704 assert!(chain.contains("#42"), "should name the PR: {chain}");
1705 assert!(
1706 chain.contains("Refusing to overwrite"),
1707 "should say what it refused: {chain}"
1708 );
1709 }
1710
1711 #[test]
1712 fn refuse_template_clobber_allows_empty_body() {
1713 let cmd = fresh_cmd();
1714 let view = repo_view_with_existing_pr(None, " ");
1715 assert!(cmd.refuse_template_clobber(&view).is_ok());
1716 }
1717
1718 #[test]
1719 fn refuse_template_clobber_allows_unfilled_template_body() {
1720 let cmd = fresh_cmd();
1721 let template = "# Custom template\n";
1722 let view = repo_view_with_existing_pr(Some(template.to_string()), template);
1723 assert!(cmd.refuse_template_clobber(&view).is_ok());
1724 }
1725
1726 #[test]
1727 fn refuse_template_clobber_allows_when_no_existing_pr() {
1728 let cmd = fresh_cmd();
1729 let view = sample_repo_view(vec![], None);
1730 assert!(cmd.refuse_template_clobber(&view).is_ok());
1731 }
1732
1733 fn sample_repo_view(commits: Vec<CommitInfo>, pr_template: Option<String>) -> RepositoryView {
1734 RepositoryView {
1735 versions: Some(VersionInfo {
1736 omni_dev: "0.0.0".to_string(),
1737 }),
1738 explanation: FieldExplanation::default(),
1739 working_directory: WorkingDirectoryInfo {
1740 clean: true,
1741 untracked_changes: vec![],
1742 },
1743 remotes: vec![],
1744 ai: AiInfo {
1745 scratch: String::new(),
1746 },
1747 branch_info: Some(BranchInfo {
1748 branch: "feature/test".to_string(),
1749 }),
1750 pr_template,
1751 pr_template_location: None,
1752 branch_prs: None,
1753 commits,
1754 }
1755 }
1756
1757 #[tokio::test]
1758 async fn run_create_pr_with_client_ai_success_returns_content() {
1759 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1760 let repo_view = sample_repo_view(vec![c1], None);
1761 let context = CommitContext::new();
1762 let cmd = fresh_cmd();
1763
1764 let yaml = "title: My PR\ndescription: |\n Body text\n".to_string();
1765 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1766 let client = ClaudeClient::new(Box::new(mock));
1767
1768 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1769 .await
1770 .unwrap();
1771 assert_eq!(outcome.title, "My PR");
1772 assert!(outcome.description.contains("Body text"));
1773 assert!(outcome.pr_yaml.contains("title:"));
1774 }
1775
1776 #[tokio::test]
1777 async fn run_create_pr_with_client_ai_failure_falls_back_to_commit_summary() {
1778 let (c1, _tmp) = sample_commit("abcdef00", "feat: single commit subject");
1779 let repo_view = sample_repo_view(vec![c1], None);
1780 let context = CommitContext::new();
1781 let cmd = fresh_cmd();
1782
1783 let mock = ConfigurableMockAiClient::new(vec![]);
1785 let client = ClaudeClient::new(Box::new(mock));
1786
1787 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1788 .await
1789 .unwrap();
1790 assert!(
1791 outcome.title.contains("feat: single commit subject")
1792 || outcome.title.contains("Pull Request")
1793 || outcome.title.contains("feature/test"),
1794 "fallback title unexpected: {}",
1795 outcome.title
1796 );
1797 }
1798
1799 #[tokio::test]
1802 async fn run_create_pr_with_client_permanent_ai_failure_errors() {
1803 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1804 let repo_view = sample_repo_view(vec![c1], None);
1805 let context = CommitContext::new();
1806 let cmd = fresh_cmd();
1807
1808 let mock = ConfigurableMockAiClient::new(vec![Err(ClaudeError::ApiHttpError {
1809 status: 404,
1810 body: "model: claude-sonnet-4-8 not found".to_string(),
1811 }
1812 .into())]);
1813 let client = ClaudeClient::new(Box::new(mock));
1814
1815 let err = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1816 .await
1817 .expect_err("a 404 must not be reported as success");
1818 let chain = format!("{err:#}");
1819 assert!(
1820 chain.contains("non-retryable"),
1821 "error should explain why it did not fall back: {chain}"
1822 );
1823 assert!(
1824 chain.contains("404"),
1825 "error should name the underlying failure: {chain}"
1826 );
1827 }
1828
1829 #[tokio::test]
1832 async fn run_create_pr_with_client_transient_ai_failure_falls_back() {
1833 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1834 let repo_view = sample_repo_view(vec![c1], None);
1835 let context = CommitContext::new();
1836 let cmd = fresh_cmd();
1837
1838 let mock = ConfigurableMockAiClient::new(vec![Err(ClaudeError::ApiHttpError {
1839 status: 503,
1840 body: "upstream unavailable".to_string(),
1841 }
1842 .into())]);
1843 let client = ClaudeClient::new(Box::new(mock));
1844
1845 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1846 .await
1847 .expect("a 5xx should still fall back to the template");
1848 assert!(!outcome.title.is_empty());
1849 }
1850
1851 #[tokio::test]
1852 async fn run_create_pr_with_client_uses_repo_template_when_present() {
1853 let (c1, _tmp) = sample_commit("abcdef00", "feat: x");
1854 let repo_view = sample_repo_view(vec![c1], Some("# Custom template\n".to_string()));
1855 let context = CommitContext::new();
1856 let cmd = fresh_cmd();
1857
1858 let mock = ConfigurableMockAiClient::new(vec![]);
1860 let client = ClaudeClient::new(Box::new(mock));
1861
1862 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1863 .await
1864 .unwrap();
1865 assert!(
1866 outcome.description.contains("# Custom template"),
1867 "fallback description should include repo template: {}",
1868 outcome.description
1869 );
1870 }
1871
1872 #[tokio::test]
1873 async fn run_create_pr_with_client_from_commits_omits_diff() {
1874 let dir = tempfile::tempdir().unwrap();
1877 let diff_path = dir.path().join("recognisable.diff");
1878 std::fs::write(
1879 &diff_path,
1880 "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+UNIQUE_DIFF_MARKER\n",
1881 )
1882 .unwrap();
1883
1884 let commit = CommitInfo {
1885 hash: format!("{:0>40}", 0),
1886 author: "Test <test@test.com>".to_string(),
1887 date: chrono::Utc::now().fixed_offset(),
1888 original_message: "feat: UNIQUE_COMMIT_SUBJECT_MARKER".to_string(),
1889 in_main_branches: vec![],
1890 analysis: CommitAnalysis {
1891 detected_type: "feat".to_string(),
1892 detected_scope: String::new(),
1893 proposed_message: "feat: t".to_string(),
1894 file_changes: FileChanges {
1895 total_files: 1,
1896 files_added: 0,
1897 files_deleted: 0,
1898 file_list: vec![],
1899 },
1900 diff_summary: String::new(),
1901 diff_file: diff_path.to_string_lossy().to_string(),
1902 file_diffs: Vec::new(),
1903 },
1904 };
1905 let repo_view = sample_repo_view(vec![commit], None);
1906 let context = CommitContext::new();
1907 let mut cmd = fresh_cmd();
1908 cmd.from_commits = true;
1909
1910 let yaml = "title: feat: x\ndescription: y\n".to_string();
1911 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1912 let prompt_handle = mock.prompt_handle();
1913 let client = ClaudeClient::new(Box::new(mock));
1914
1915 run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1916 .await
1917 .unwrap();
1918
1919 let prompts = prompt_handle.prompts();
1920 assert_eq!(prompts.len(), 1, "expected one AI call");
1921 let (_, user_prompt) = &prompts[0];
1922 assert!(
1923 user_prompt.contains("UNIQUE_COMMIT_SUBJECT_MARKER"),
1924 "commit subject should be in the prompt"
1925 );
1926 assert!(
1927 !user_prompt.contains("UNIQUE_DIFF_MARKER"),
1928 "diff content must NOT appear in the prompt when from_commits is set"
1929 );
1930 assert!(
1931 !user_prompt.contains("diff --git"),
1932 "diff hunks must NOT appear in the prompt when from_commits is set"
1933 );
1934 }
1935
1936 #[test]
1937 fn create_pr_outcome_clone_and_debug() {
1938 let outcome = CreatePrOutcome {
1939 title: "t".to_string(),
1940 description: "d".to_string(),
1941 pr_yaml: "y".to_string(),
1942 };
1943 let cloned = outcome.clone();
1944 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
1945 }
1946
1947 fn init_repo_with_remote_and_template(template_marker: &str) -> tempfile::TempDir {
1951 use git2::{Repository, Signature};
1952 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1953 std::fs::create_dir_all(&tmp_root).unwrap();
1954 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1955 let repo_path = temp_dir.path();
1956 let repo = Repository::init(repo_path).unwrap();
1957 {
1958 let mut config = repo.config().unwrap();
1959 config.set_str("user.name", "Test").unwrap();
1960 config.set_str("user.email", "test@example.com").unwrap();
1961 config.set_str("init.defaultBranch", "main").unwrap();
1962 }
1963 repo.set_head("refs/heads/main").unwrap();
1964
1965 let signature = Signature::now("Test", "test@example.com").unwrap();
1966
1967 std::fs::write(repo_path.join("f.txt"), "base").unwrap();
1969 let mut idx = repo.index().unwrap();
1970 idx.add_path(std::path::Path::new("f.txt")).unwrap();
1971 idx.write().unwrap();
1972 let tree_id = idx.write_tree().unwrap();
1973 let tree = repo.find_tree(tree_id).unwrap();
1974 let base_oid = repo
1975 .commit(
1976 Some("HEAD"),
1977 &signature,
1978 &signature,
1979 "base: init",
1980 &tree,
1981 &[],
1982 )
1983 .unwrap();
1984
1985 repo.remote("origin", "https://example.com/test/repo.git")
1987 .unwrap();
1988 repo.reference(
1990 "refs/remotes/origin/main",
1991 base_oid,
1992 true,
1993 "set origin/main",
1994 )
1995 .unwrap();
1996
1997 let base_commit = repo.find_commit(base_oid).unwrap();
1999 repo.branch("feature/test", &base_commit, true).unwrap();
2000 repo.set_head("refs/heads/feature/test").unwrap();
2001 std::fs::write(repo_path.join("f.txt"), "feature").unwrap();
2002 let mut idx = repo.index().unwrap();
2003 idx.add_path(std::path::Path::new("f.txt")).unwrap();
2004 idx.write().unwrap();
2005 let tree_id = idx.write_tree().unwrap();
2006 let tree = repo.find_tree(tree_id).unwrap();
2007 repo.commit(
2008 Some("HEAD"),
2009 &signature,
2010 &signature,
2011 "feat: feature work",
2012 &tree,
2013 &[&base_commit],
2014 )
2015 .unwrap();
2016
2017 let github_dir = repo_path.join(".github");
2019 std::fs::create_dir_all(&github_dir).unwrap();
2020 std::fs::write(github_dir.join("pull_request_template.md"), template_marker).unwrap();
2021
2022 temp_dir
2023 }
2024
2025 #[test]
2032 fn generate_repository_view_anchors_to_injected_repo() {
2033 let marker = "## INJECTED_PR_TEMPLATE_MARKER_42";
2034 let temp_dir = init_repo_with_remote_and_template(marker);
2035 let cmd = fresh_cmd();
2036
2037 let repo_view = cmd.generate_repository_view(temp_dir.path()).unwrap();
2038
2039 assert_eq!(
2041 repo_view.pr_template.as_deref(),
2042 Some(marker),
2043 "PR template must be read from the injected repo root"
2044 );
2045 assert_eq!(
2047 repo_view.branch_info.as_ref().map(|b| b.branch.as_str()),
2048 Some("feature/test")
2049 );
2050 assert_eq!(
2051 repo_view.commits.len(),
2052 1,
2053 "exactly the one commit ahead of origin/main"
2054 );
2055 assert!(repo_view.commits[0]
2056 .original_message
2057 .contains("feature work"));
2058 }
2059}
2060
2061#[cfg(test)]
2062mod tests {
2063 use super::*;
2064
2065 #[test]
2068 fn parse_bool_true_variants() {
2069 assert_eq!(parse_bool_string("true"), Some(true));
2070 assert_eq!(parse_bool_string("1"), Some(true));
2071 assert_eq!(parse_bool_string("yes"), Some(true));
2072 }
2073
2074 #[test]
2075 fn parse_bool_false_variants() {
2076 assert_eq!(parse_bool_string("false"), Some(false));
2077 assert_eq!(parse_bool_string("0"), Some(false));
2078 assert_eq!(parse_bool_string("no"), Some(false));
2079 }
2080
2081 #[test]
2082 fn parse_bool_invalid() {
2083 assert_eq!(parse_bool_string("maybe"), None);
2084 assert_eq!(parse_bool_string(""), None);
2085 }
2086
2087 #[test]
2088 fn parse_bool_case_insensitive() {
2089 assert_eq!(parse_bool_string("TRUE"), Some(true));
2090 assert_eq!(parse_bool_string("Yes"), Some(true));
2091 assert_eq!(parse_bool_string("FALSE"), Some(false));
2092 assert_eq!(parse_bool_string("No"), Some(false));
2093 }
2094
2095 #[test]
2098 fn breaking_change_type_contains() {
2099 assert!(is_breaking_change("BREAKING", "normal message"));
2100 }
2101
2102 #[test]
2103 fn breaking_change_message_contains() {
2104 assert!(is_breaking_change("feat", "BREAKING CHANGE: removed API"));
2105 }
2106
2107 #[test]
2108 fn breaking_change_none() {
2109 assert!(!is_breaking_change("feat", "add new feature"));
2110 }
2111
2112 #[test]
2115 fn check_checkbox_found() {
2116 let mut desc = "- [ ] New feature\n- [ ] Bug fix".to_string();
2117 check_checkbox(&mut desc, "- [ ] New feature");
2118 assert!(desc.contains("- [x] New feature"));
2119 assert!(desc.contains("- [ ] Bug fix"));
2120 }
2121
2122 #[test]
2123 fn check_checkbox_not_found() {
2124 let mut desc = "- [ ] Bug fix".to_string();
2125 let original = desc.clone();
2126 check_checkbox(&mut desc, "- [ ] New feature");
2127 assert_eq!(desc, original);
2128 }
2129
2130 #[test]
2133 fn scopes_section_single() {
2134 let scopes = vec!["cli".to_string()];
2135 assert_eq!(
2136 format_scopes_section(&scopes),
2137 "**Affected areas:** cli\n\n"
2138 );
2139 }
2140
2141 #[test]
2142 fn scopes_section_multiple() {
2143 let scopes = vec!["cli".to_string(), "git".to_string()];
2144 let result = format_scopes_section(&scopes);
2145 assert!(result.contains("cli"));
2146 assert!(result.contains("git"));
2147 assert!(result.starts_with("**Affected areas:**"));
2148 }
2149
2150 #[test]
2151 fn scopes_section_empty() {
2152 assert_eq!(format_scopes_section(&[]), "");
2153 }
2154
2155 #[test]
2158 fn commit_list_formatting() {
2159 let entries = vec![
2160 ("abc12345", "feat: add feature"),
2161 ("def67890", "fix: resolve bug"),
2162 ];
2163 let result = format_commit_list(&entries);
2164 assert!(result.contains("### Commits in this PR:"));
2165 assert!(result.contains("- `abc12345` feat: add feature"));
2166 assert!(result.contains("- `def67890` fix: resolve bug"));
2167 }
2168
2169 #[test]
2172 fn clean_branch_simple() {
2173 assert_eq!(clean_branch_name("feat/add-login"), "feat add login");
2174 }
2175
2176 #[test]
2177 fn clean_branch_underscores() {
2178 assert_eq!(clean_branch_name("user_name/fix_bug"), "user name fix bug");
2179 }
2180
2181 #[test]
2184 fn first_line_multiline() {
2185 assert_eq!(extract_first_line("first\nsecond\nthird"), "first");
2186 }
2187
2188 #[test]
2189 fn first_line_single() {
2190 assert_eq!(extract_first_line("only line"), "only line");
2191 }
2192
2193 #[test]
2194 fn first_line_empty() {
2195 assert_eq!(extract_first_line(""), "");
2196 }
2197
2198 #[test]
2201 fn draft_status_true() {
2202 let (icon, text) = format_draft_status(true);
2203 assert_eq!(text, "draft");
2204 assert!(!icon.is_empty());
2205 }
2206
2207 #[test]
2208 fn draft_status_false() {
2209 let (icon, text) = format_draft_status(false);
2210 assert_eq!(text, "ready for review");
2211 assert!(!icon.is_empty());
2212 }
2213
2214 #[test]
2217 fn push_action_skip_when_no_push() {
2218 assert_eq!(determine_push_action(true, false), PushAction::Skip);
2219 assert_eq!(determine_push_action(true, true), PushAction::Skip);
2220 }
2221
2222 #[test]
2223 fn push_action_sync_existing_branch() {
2224 assert_eq!(determine_push_action(false, true), PushAction::SyncExisting);
2225 }
2226
2227 #[test]
2228 fn push_action_push_new_branch() {
2229 assert_eq!(determine_push_action(false, false), PushAction::PushNew);
2230 }
2231}