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 use std::process::Command;
1146
1147 let branch_name = repo_view
1149 .branch_info
1150 .as_ref()
1151 .map(|bi| &bi.branch)
1152 .context("Branch info not available")?;
1153
1154 let pr_status = if is_draft {
1155 "draft"
1156 } else {
1157 "ready for review"
1158 };
1159 println!("🚀 Creating pull request ({pr_status})...");
1160 println!(" 📋 Title: {title}");
1161 println!(" 🌿 Branch: {branch_name}");
1162 if let Some(base) = new_base {
1163 println!(" 🎯 Base: {base}");
1164 }
1165
1166 let push_action = if self.no_push {
1168 determine_push_action(true, false)
1169 } else {
1170 debug!("Opening git repository to check branch status");
1171 let git_repo = crate::git::GitRepository::open_at(repo_root)
1172 .context("Failed to open git repository at the given path")?;
1173
1174 debug!(
1175 "Checking if branch '{}' exists on remote 'origin'",
1176 branch_name
1177 );
1178 let branch_on_remote = git_repo.branch_exists_on_remote(branch_name, "origin")?;
1179 let action = determine_push_action(false, branch_on_remote);
1180
1181 debug!("Push action for branch '{}': {:?}", branch_name, action);
1182 println!("📤 Pushing branch to remote...");
1183 git_repo
1184 .push_branch(branch_name, "origin")
1185 .context("Failed to push branch to remote")?;
1186
1187 action
1188 };
1189
1190 if push_action == PushAction::Skip {
1191 debug!("Skipping push (--no-push flag set)");
1192 }
1193
1194 debug!("Creating PR with gh CLI - title: '{}'", title);
1196 debug!("PR description length: {} characters", description.len());
1197 debug!("PR draft status: {}", is_draft);
1198 if let Some(base) = new_base {
1199 debug!("PR base branch: {}", base);
1200 }
1201
1202 let mut args = vec![
1203 "pr",
1204 "create",
1205 "--head",
1206 branch_name,
1207 "--title",
1208 title,
1209 "--body",
1210 description,
1211 ];
1212
1213 if let Some(base) = new_base {
1214 args.push("--base");
1215 args.push(base);
1216 }
1217
1218 if is_draft {
1219 args.push("--draft");
1220 }
1221
1222 let pr_result = Command::new("gh")
1223 .current_dir(repo_root)
1224 .args(&args)
1225 .output()
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 use std::process::Command;
1253
1254 let existing_pr = repo_view
1256 .branch_prs
1257 .as_ref()
1258 .and_then(|prs| prs.first())
1259 .context("No existing PR found to update")?;
1260
1261 let pr_number = existing_pr.number;
1262 let current_base = &existing_pr.base;
1263
1264 println!("🚀 Updating pull request #{pr_number}...");
1265 println!(" 📋 Title: {title}");
1266
1267 let change_base = if let Some(base) = new_base {
1269 if !current_base.is_empty() && current_base != base {
1270 print!(" 🎯 Current base: {current_base} → New base: {base}. Change? [y/N]: ");
1271 io::stdout().flush()?;
1272
1273 let mut input = String::new();
1274 io::stdin().read_line(&mut input)?;
1275 let response = input.trim().to_lowercase();
1276 response == "y" || response == "yes"
1277 } else {
1278 false
1279 }
1280 } else {
1281 false
1282 };
1283
1284 debug!(
1285 pr_number = pr_number,
1286 title = %title,
1287 description_length = description.len(),
1288 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1289 change_base = change_base,
1290 "Updating GitHub PR with title and description"
1291 );
1292
1293 let pr_number_str = pr_number.to_string();
1295 let mut gh_args = vec![
1296 "pr",
1297 "edit",
1298 &pr_number_str,
1299 "--title",
1300 title,
1301 "--body",
1302 description,
1303 ];
1304
1305 if change_base {
1306 if let Some(base) = new_base {
1307 gh_args.push("--base");
1308 gh_args.push(base);
1309 }
1310 }
1311
1312 debug!(
1313 args = ?gh_args,
1314 "Executing gh command to update PR"
1315 );
1316
1317 let pr_result = Command::new("gh")
1318 .current_dir(repo_root)
1319 .args(&gh_args)
1320 .output()
1321 .context("Failed to update pull request")?;
1322
1323 if pr_result.status.success() {
1324 println!("🎉 Pull request updated: {}", existing_pr.url);
1326 if change_base {
1327 if let Some(base) = new_base {
1328 println!(" 🎯 Base branch changed to: {base}");
1329 }
1330 }
1331 } else {
1332 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1333 anyhow::bail!("Failed to update pull request: {error_msg}");
1334 }
1335
1336 Ok(())
1337 }
1338
1339 fn show_model_info_from_client(
1341 &self,
1342 client: &crate::claude::client::ClaudeClient,
1343 ) -> Result<()> {
1344 use crate::claude::model_config::get_model_registry;
1345
1346 println!("🤖 AI Model Configuration:");
1347
1348 let metadata = client.get_ai_client_metadata();
1350 let registry = get_model_registry();
1356
1357 if let Some(spec) = registry.get_model_spec(&metadata.model) {
1358 if metadata.model != spec.api_identifier {
1360 println!(
1361 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
1362 metadata.model, spec.api_identifier
1363 );
1364 } else {
1365 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1366 }
1367
1368 println!(" 🏷️ Provider: {}", spec.provider);
1369 println!(" 📊 Generation: {}", spec.generation);
1370 println!(" ⭐ Tier: {} ({})", spec.tier, {
1371 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1372 &tier_info.description
1373 } else {
1374 "No description available"
1375 }
1376 });
1377 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1378 println!(" 📥 Input context: {}", metadata.max_context_length);
1379
1380 if let Some((ref key, ref value)) = metadata.active_beta {
1381 println!(" 🔬 Beta header: {key}: {value}");
1382 }
1383
1384 if spec.legacy {
1385 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
1386 }
1387 } else {
1388 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1390 println!(" 🏷️ Provider: {}", metadata.provider);
1391 println!(" ⚠️ Model not found in registry, using client metadata:");
1392 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1393 println!(" 📥 Input context: {}", metadata.max_context_length);
1394 }
1395
1396 println!();
1397 Ok(())
1398 }
1399}
1400
1401#[derive(Debug, PartialEq)]
1405enum PushAction {
1406 Skip,
1408 SyncExisting,
1410 PushNew,
1412}
1413
1414fn determine_push_action(no_push: bool, branch_on_remote: bool) -> PushAction {
1416 if no_push {
1417 PushAction::Skip
1418 } else if branch_on_remote {
1419 PushAction::SyncExisting
1420 } else {
1421 PushAction::PushNew
1422 }
1423}
1424
1425fn parse_bool_string(val: &str) -> Option<bool> {
1430 match val.to_lowercase().as_str() {
1431 "true" | "1" | "yes" => Some(true),
1432 "false" | "0" | "no" => Some(false),
1433 _ => None,
1434 }
1435}
1436
1437fn is_breaking_change(detected_type: &str, original_message: &str) -> bool {
1439 detected_type.contains("BREAKING") || original_message.contains("BREAKING CHANGE")
1440}
1441
1442fn check_checkbox(description: &mut String, search_text: &str) {
1444 if let Some(pos) = description.find(search_text) {
1445 description.replace_range(pos..pos + 5, "- [x]");
1446 }
1447}
1448
1449fn format_scopes_section(scopes: &[String]) -> String {
1453 if scopes.is_empty() {
1454 return String::new();
1455 }
1456 format!("**Affected areas:** {}\n\n", scopes.join(", "))
1457}
1458
1459fn format_commit_list(entries: &[(&str, &str)]) -> String {
1461 let mut output = String::from("### Commits in this PR:\n");
1462 for (hash, message) in entries {
1463 output.push_str(&format!("- `{hash}` {message}\n"));
1464 }
1465 output
1466}
1467
1468fn clean_branch_name(branch: &str) -> String {
1470 branch.replace(['/', '-', '_'], " ")
1471}
1472
1473fn extract_first_line(text: &str) -> &str {
1475 text.lines().next().unwrap_or("").trim()
1476}
1477
1478fn format_draft_status(is_draft: bool) -> (&'static str, &'static str) {
1480 if is_draft {
1481 ("\u{1f4cb}", "draft")
1482 } else {
1483 ("\u{2705}", "ready for review")
1484 }
1485}
1486
1487#[derive(Debug, Clone)]
1489pub struct CreatePrOutcome {
1490 pub title: String,
1492 pub description: String,
1494 pub pr_yaml: String,
1496}
1497
1498pub async fn run_create_pr(
1507 model: Option<String>,
1508 base_branch: Option<&str>,
1509 repo_path: Option<&std::path::Path>,
1510) -> Result<CreatePrOutcome> {
1511 let repo_root = match repo_path {
1515 Some(p) => p.to_path_buf(),
1516 None => std::env::current_dir().context("Failed to determine current directory")?,
1517 };
1518
1519 crate::utils::check_pr_command_prerequisites(model.as_deref(), &repo_root)?;
1520
1521 let cmd = CreatePrCommand {
1522 base: base_branch.map(str::to_string),
1523 auto_apply: true,
1524 save_only: None,
1525 ready: false,
1526 draft: false,
1527 context_dir: None,
1528 no_push: true,
1529 from_commits: false,
1530 };
1531
1532 let repo_view = cmd.generate_repository_view(&repo_root)?;
1533 let context = cmd.collect_context(&repo_root, &repo_view).await?;
1534 let claude_client = crate::claude::create_default_claude_client(model, None).await?;
1535 run_create_pr_with_client(&cmd, &repo_view, &context, &claude_client).await
1536}
1537
1538pub(crate) async fn run_create_pr_with_client(
1546 cmd: &CreatePrCommand,
1547 repo_view: &crate::data::RepositoryView,
1548 context: &crate::data::context::CommitContext,
1549 claude_client: &crate::claude::client::ClaudeClient,
1550) -> Result<CreatePrOutcome> {
1551 let pr_template = cmd.resolve_pr_template(repo_view);
1552
1553 let ai_result = if cmd.from_commits {
1554 claude_client
1555 .generate_pr_content_with_context_from_commits(repo_view, &pr_template, context)
1556 .await
1557 } else {
1558 claude_client
1559 .generate_pr_content_with_context(repo_view, &pr_template, context)
1560 .await
1561 };
1562 let pr_content = match ai_result {
1563 Ok(content) => content,
1564 Err(e) if !ai_error_is_transient(&e) => {
1568 return Err(e).context("AI PR generation failed with a non-retryable error");
1569 }
1570 Err(e) => cmd.fallback_pr_content(&e, pr_template, repo_view)?,
1571 };
1572
1573 let pr_yaml = crate::data::to_yaml(&pr_content).context("Failed to serialise PrContent")?;
1574
1575 Ok(CreatePrOutcome {
1576 title: pr_content.title,
1577 description: pr_content.description,
1578 pr_yaml,
1579 })
1580}
1581
1582#[cfg(test)]
1583#[allow(clippy::unwrap_used, clippy::expect_used)]
1584mod run_create_pr_tests {
1585 use super::*;
1586 use crate::claude::client::ClaudeClient;
1587 use crate::claude::error::ClaudeError;
1588 use crate::claude::test_utils::ConfigurableMockAiClient;
1589 use crate::data::context::CommitContext;
1590 use crate::data::{
1591 AiInfo, BranchInfo, FieldExplanation, RepositoryView, VersionInfo, WorkingDirectoryInfo,
1592 };
1593 use crate::git::commit::FileChanges;
1594 use crate::git::{CommitAnalysis, CommitInfo};
1595
1596 #[tokio::test]
1597 async fn run_create_pr_invalid_repo_path_errors_before_ai() {
1598 let err = run_create_pr(
1599 None,
1600 None,
1601 Some(std::path::Path::new("/no/such/path/exists")),
1602 )
1603 .await
1604 .unwrap_err();
1605 let msg = format!("{err:#}").to_lowercase();
1606 assert!(
1611 msg.contains("git")
1612 || msg.contains("repository")
1613 || msg.contains("credential")
1614 || msg.contains("api")
1615 || msg.contains("directory"),
1616 "expected git/repository or preflight error, got: {msg}"
1617 );
1618 }
1619
1620 fn fresh_cmd() -> CreatePrCommand {
1621 CreatePrCommand {
1622 base: None,
1623 auto_apply: true,
1624 save_only: None,
1625 ready: false,
1626 draft: false,
1627 context_dir: None,
1628 no_push: true,
1629 from_commits: false,
1630 }
1631 }
1632
1633 fn sample_commit(hash: &str, message: &str) -> (CommitInfo, tempfile::NamedTempFile) {
1634 let tmp = tempfile::NamedTempFile::new().unwrap();
1635 let commit = CommitInfo {
1636 hash: hash.to_string(),
1637 author: "Test <test@test.com>".to_string(),
1638 date: chrono::Utc::now().fixed_offset(),
1639 original_message: message.to_string(),
1640 in_main_branches: vec![],
1641 analysis: CommitAnalysis {
1642 detected_type: "feat".to_string(),
1643 detected_scope: String::new(),
1644 proposed_message: message.to_string(),
1645 file_changes: FileChanges {
1646 total_files: 0,
1647 files_added: 0,
1648 files_deleted: 0,
1649 file_list: vec![],
1650 },
1651 diff_summary: String::new(),
1652 diff_file: tmp.path().to_string_lossy().to_string(),
1653 file_diffs: Vec::new(),
1654 },
1655 };
1656 (commit, tmp)
1657 }
1658
1659 fn repo_view_with_existing_pr(pr_template: Option<String>, body: &str) -> RepositoryView {
1662 let mut view = sample_repo_view(vec![], pr_template);
1663 view.branch_prs = Some(vec![crate::data::PullRequest {
1664 number: 42,
1665 title: "Existing PR".to_string(),
1666 state: "open".to_string(),
1667 url: "https://example.invalid/pr/42".to_string(),
1668 body: body.to_string(),
1669 base: "main".to_string(),
1670 }]);
1671 view
1672 }
1673
1674 #[test]
1675 fn body_is_safe_to_replace_only_when_empty_or_template() {
1676 let template = "# Pull Request\n\n## Description\n";
1677 assert!(CreatePrCommand::body_is_safe_to_replace("", template));
1678 assert!(CreatePrCommand::body_is_safe_to_replace(
1679 " \n ", template
1680 ));
1681 assert!(CreatePrCommand::body_is_safe_to_replace(
1683 "\n# Pull Request\n\n## Description\n\n",
1684 template
1685 ));
1686 assert!(!CreatePrCommand::body_is_safe_to_replace(
1687 "A real, hand-written description.",
1688 template
1689 ));
1690 }
1691
1692 #[test]
1695 fn refuse_template_clobber_rejects_populated_body() {
1696 let cmd = fresh_cmd();
1697 let view = repo_view_with_existing_pr(None, "A real description worth keeping.");
1698
1699 let err = cmd
1700 .refuse_template_clobber(&view)
1701 .expect_err("must refuse to destroy a populated description");
1702 let chain = format!("{err:#}");
1703 assert!(chain.contains("#42"), "should name the PR: {chain}");
1704 assert!(
1705 chain.contains("Refusing to overwrite"),
1706 "should say what it refused: {chain}"
1707 );
1708 }
1709
1710 #[test]
1711 fn refuse_template_clobber_allows_empty_body() {
1712 let cmd = fresh_cmd();
1713 let view = repo_view_with_existing_pr(None, " ");
1714 assert!(cmd.refuse_template_clobber(&view).is_ok());
1715 }
1716
1717 #[test]
1718 fn refuse_template_clobber_allows_unfilled_template_body() {
1719 let cmd = fresh_cmd();
1720 let template = "# Custom template\n";
1721 let view = repo_view_with_existing_pr(Some(template.to_string()), template);
1722 assert!(cmd.refuse_template_clobber(&view).is_ok());
1723 }
1724
1725 #[test]
1726 fn refuse_template_clobber_allows_when_no_existing_pr() {
1727 let cmd = fresh_cmd();
1728 let view = sample_repo_view(vec![], None);
1729 assert!(cmd.refuse_template_clobber(&view).is_ok());
1730 }
1731
1732 fn sample_repo_view(commits: Vec<CommitInfo>, pr_template: Option<String>) -> RepositoryView {
1733 RepositoryView {
1734 versions: Some(VersionInfo {
1735 omni_dev: "0.0.0".to_string(),
1736 }),
1737 explanation: FieldExplanation::default(),
1738 working_directory: WorkingDirectoryInfo {
1739 clean: true,
1740 untracked_changes: vec![],
1741 },
1742 remotes: vec![],
1743 ai: AiInfo {
1744 scratch: String::new(),
1745 },
1746 branch_info: Some(BranchInfo {
1747 branch: "feature/test".to_string(),
1748 }),
1749 pr_template,
1750 pr_template_location: None,
1751 branch_prs: None,
1752 commits,
1753 }
1754 }
1755
1756 #[tokio::test]
1757 async fn run_create_pr_with_client_ai_success_returns_content() {
1758 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1759 let repo_view = sample_repo_view(vec![c1], None);
1760 let context = CommitContext::new();
1761 let cmd = fresh_cmd();
1762
1763 let yaml = "title: My PR\ndescription: |\n Body text\n".to_string();
1764 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1765 let client = ClaudeClient::new(Box::new(mock));
1766
1767 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1768 .await
1769 .unwrap();
1770 assert_eq!(outcome.title, "My PR");
1771 assert!(outcome.description.contains("Body text"));
1772 assert!(outcome.pr_yaml.contains("title:"));
1773 }
1774
1775 #[tokio::test]
1776 async fn run_create_pr_with_client_ai_failure_falls_back_to_commit_summary() {
1777 let (c1, _tmp) = sample_commit("abcdef00", "feat: single commit subject");
1778 let repo_view = sample_repo_view(vec![c1], None);
1779 let context = CommitContext::new();
1780 let cmd = fresh_cmd();
1781
1782 let mock = ConfigurableMockAiClient::new(vec![]);
1784 let client = ClaudeClient::new(Box::new(mock));
1785
1786 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1787 .await
1788 .unwrap();
1789 assert!(
1790 outcome.title.contains("feat: single commit subject")
1791 || outcome.title.contains("Pull Request")
1792 || outcome.title.contains("feature/test"),
1793 "fallback title unexpected: {}",
1794 outcome.title
1795 );
1796 }
1797
1798 #[tokio::test]
1801 async fn run_create_pr_with_client_permanent_ai_failure_errors() {
1802 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1803 let repo_view = sample_repo_view(vec![c1], None);
1804 let context = CommitContext::new();
1805 let cmd = fresh_cmd();
1806
1807 let mock = ConfigurableMockAiClient::new(vec![Err(ClaudeError::ApiHttpError {
1808 status: 404,
1809 body: "model: claude-sonnet-4-8 not found".to_string(),
1810 }
1811 .into())]);
1812 let client = ClaudeClient::new(Box::new(mock));
1813
1814 let err = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1815 .await
1816 .expect_err("a 404 must not be reported as success");
1817 let chain = format!("{err:#}");
1818 assert!(
1819 chain.contains("non-retryable"),
1820 "error should explain why it did not fall back: {chain}"
1821 );
1822 assert!(
1823 chain.contains("404"),
1824 "error should name the underlying failure: {chain}"
1825 );
1826 }
1827
1828 #[tokio::test]
1831 async fn run_create_pr_with_client_transient_ai_failure_falls_back() {
1832 let (c1, _tmp) = sample_commit("abcdef00", "feat: work");
1833 let repo_view = sample_repo_view(vec![c1], None);
1834 let context = CommitContext::new();
1835 let cmd = fresh_cmd();
1836
1837 let mock = ConfigurableMockAiClient::new(vec![Err(ClaudeError::ApiHttpError {
1838 status: 503,
1839 body: "upstream unavailable".to_string(),
1840 }
1841 .into())]);
1842 let client = ClaudeClient::new(Box::new(mock));
1843
1844 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1845 .await
1846 .expect("a 5xx should still fall back to the template");
1847 assert!(!outcome.title.is_empty());
1848 }
1849
1850 #[tokio::test]
1851 async fn run_create_pr_with_client_uses_repo_template_when_present() {
1852 let (c1, _tmp) = sample_commit("abcdef00", "feat: x");
1853 let repo_view = sample_repo_view(vec![c1], Some("# Custom template\n".to_string()));
1854 let context = CommitContext::new();
1855 let cmd = fresh_cmd();
1856
1857 let mock = ConfigurableMockAiClient::new(vec![]);
1859 let client = ClaudeClient::new(Box::new(mock));
1860
1861 let outcome = run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1862 .await
1863 .unwrap();
1864 assert!(
1865 outcome.description.contains("# Custom template"),
1866 "fallback description should include repo template: {}",
1867 outcome.description
1868 );
1869 }
1870
1871 #[tokio::test]
1872 async fn run_create_pr_with_client_from_commits_omits_diff() {
1873 let dir = tempfile::tempdir().unwrap();
1876 let diff_path = dir.path().join("recognisable.diff");
1877 std::fs::write(
1878 &diff_path,
1879 "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+UNIQUE_DIFF_MARKER\n",
1880 )
1881 .unwrap();
1882
1883 let commit = CommitInfo {
1884 hash: format!("{:0>40}", 0),
1885 author: "Test <test@test.com>".to_string(),
1886 date: chrono::Utc::now().fixed_offset(),
1887 original_message: "feat: UNIQUE_COMMIT_SUBJECT_MARKER".to_string(),
1888 in_main_branches: vec![],
1889 analysis: CommitAnalysis {
1890 detected_type: "feat".to_string(),
1891 detected_scope: String::new(),
1892 proposed_message: "feat: t".to_string(),
1893 file_changes: FileChanges {
1894 total_files: 1,
1895 files_added: 0,
1896 files_deleted: 0,
1897 file_list: vec![],
1898 },
1899 diff_summary: String::new(),
1900 diff_file: diff_path.to_string_lossy().to_string(),
1901 file_diffs: Vec::new(),
1902 },
1903 };
1904 let repo_view = sample_repo_view(vec![commit], None);
1905 let context = CommitContext::new();
1906 let mut cmd = fresh_cmd();
1907 cmd.from_commits = true;
1908
1909 let yaml = "title: feat: x\ndescription: y\n".to_string();
1910 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
1911 let prompt_handle = mock.prompt_handle();
1912 let client = ClaudeClient::new(Box::new(mock));
1913
1914 run_create_pr_with_client(&cmd, &repo_view, &context, &client)
1915 .await
1916 .unwrap();
1917
1918 let prompts = prompt_handle.prompts();
1919 assert_eq!(prompts.len(), 1, "expected one AI call");
1920 let (_, user_prompt) = &prompts[0];
1921 assert!(
1922 user_prompt.contains("UNIQUE_COMMIT_SUBJECT_MARKER"),
1923 "commit subject should be in the prompt"
1924 );
1925 assert!(
1926 !user_prompt.contains("UNIQUE_DIFF_MARKER"),
1927 "diff content must NOT appear in the prompt when from_commits is set"
1928 );
1929 assert!(
1930 !user_prompt.contains("diff --git"),
1931 "diff hunks must NOT appear in the prompt when from_commits is set"
1932 );
1933 }
1934
1935 #[test]
1936 fn create_pr_outcome_clone_and_debug() {
1937 let outcome = CreatePrOutcome {
1938 title: "t".to_string(),
1939 description: "d".to_string(),
1940 pr_yaml: "y".to_string(),
1941 };
1942 let cloned = outcome.clone();
1943 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
1944 }
1945
1946 fn init_repo_with_remote_and_template(template_marker: &str) -> tempfile::TempDir {
1950 use git2::{Repository, Signature};
1951 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1952 std::fs::create_dir_all(&tmp_root).unwrap();
1953 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1954 let repo_path = temp_dir.path();
1955 let repo = Repository::init(repo_path).unwrap();
1956 {
1957 let mut config = repo.config().unwrap();
1958 config.set_str("user.name", "Test").unwrap();
1959 config.set_str("user.email", "test@example.com").unwrap();
1960 config.set_str("init.defaultBranch", "main").unwrap();
1961 }
1962 repo.set_head("refs/heads/main").unwrap();
1963
1964 let signature = Signature::now("Test", "test@example.com").unwrap();
1965
1966 std::fs::write(repo_path.join("f.txt"), "base").unwrap();
1968 let mut idx = repo.index().unwrap();
1969 idx.add_path(std::path::Path::new("f.txt")).unwrap();
1970 idx.write().unwrap();
1971 let tree_id = idx.write_tree().unwrap();
1972 let tree = repo.find_tree(tree_id).unwrap();
1973 let base_oid = repo
1974 .commit(
1975 Some("HEAD"),
1976 &signature,
1977 &signature,
1978 "base: init",
1979 &tree,
1980 &[],
1981 )
1982 .unwrap();
1983
1984 repo.remote("origin", "https://example.com/test/repo.git")
1986 .unwrap();
1987 repo.reference(
1989 "refs/remotes/origin/main",
1990 base_oid,
1991 true,
1992 "set origin/main",
1993 )
1994 .unwrap();
1995
1996 let base_commit = repo.find_commit(base_oid).unwrap();
1998 repo.branch("feature/test", &base_commit, true).unwrap();
1999 repo.set_head("refs/heads/feature/test").unwrap();
2000 std::fs::write(repo_path.join("f.txt"), "feature").unwrap();
2001 let mut idx = repo.index().unwrap();
2002 idx.add_path(std::path::Path::new("f.txt")).unwrap();
2003 idx.write().unwrap();
2004 let tree_id = idx.write_tree().unwrap();
2005 let tree = repo.find_tree(tree_id).unwrap();
2006 repo.commit(
2007 Some("HEAD"),
2008 &signature,
2009 &signature,
2010 "feat: feature work",
2011 &tree,
2012 &[&base_commit],
2013 )
2014 .unwrap();
2015
2016 let github_dir = repo_path.join(".github");
2018 std::fs::create_dir_all(&github_dir).unwrap();
2019 std::fs::write(github_dir.join("pull_request_template.md"), template_marker).unwrap();
2020
2021 temp_dir
2022 }
2023
2024 #[test]
2031 fn generate_repository_view_anchors_to_injected_repo() {
2032 let marker = "## INJECTED_PR_TEMPLATE_MARKER_42";
2033 let temp_dir = init_repo_with_remote_and_template(marker);
2034 let cmd = fresh_cmd();
2035
2036 let repo_view = cmd.generate_repository_view(temp_dir.path()).unwrap();
2037
2038 assert_eq!(
2040 repo_view.pr_template.as_deref(),
2041 Some(marker),
2042 "PR template must be read from the injected repo root"
2043 );
2044 assert_eq!(
2046 repo_view.branch_info.as_ref().map(|b| b.branch.as_str()),
2047 Some("feature/test")
2048 );
2049 assert_eq!(
2050 repo_view.commits.len(),
2051 1,
2052 "exactly the one commit ahead of origin/main"
2053 );
2054 assert!(repo_view.commits[0]
2055 .original_message
2056 .contains("feature work"));
2057 }
2058}
2059
2060#[cfg(test)]
2061mod tests {
2062 use super::*;
2063
2064 #[test]
2067 fn parse_bool_true_variants() {
2068 assert_eq!(parse_bool_string("true"), Some(true));
2069 assert_eq!(parse_bool_string("1"), Some(true));
2070 assert_eq!(parse_bool_string("yes"), Some(true));
2071 }
2072
2073 #[test]
2074 fn parse_bool_false_variants() {
2075 assert_eq!(parse_bool_string("false"), Some(false));
2076 assert_eq!(parse_bool_string("0"), Some(false));
2077 assert_eq!(parse_bool_string("no"), Some(false));
2078 }
2079
2080 #[test]
2081 fn parse_bool_invalid() {
2082 assert_eq!(parse_bool_string("maybe"), None);
2083 assert_eq!(parse_bool_string(""), None);
2084 }
2085
2086 #[test]
2087 fn parse_bool_case_insensitive() {
2088 assert_eq!(parse_bool_string("TRUE"), Some(true));
2089 assert_eq!(parse_bool_string("Yes"), Some(true));
2090 assert_eq!(parse_bool_string("FALSE"), Some(false));
2091 assert_eq!(parse_bool_string("No"), Some(false));
2092 }
2093
2094 #[test]
2097 fn breaking_change_type_contains() {
2098 assert!(is_breaking_change("BREAKING", "normal message"));
2099 }
2100
2101 #[test]
2102 fn breaking_change_message_contains() {
2103 assert!(is_breaking_change("feat", "BREAKING CHANGE: removed API"));
2104 }
2105
2106 #[test]
2107 fn breaking_change_none() {
2108 assert!(!is_breaking_change("feat", "add new feature"));
2109 }
2110
2111 #[test]
2114 fn check_checkbox_found() {
2115 let mut desc = "- [ ] New feature\n- [ ] Bug fix".to_string();
2116 check_checkbox(&mut desc, "- [ ] New feature");
2117 assert!(desc.contains("- [x] New feature"));
2118 assert!(desc.contains("- [ ] Bug fix"));
2119 }
2120
2121 #[test]
2122 fn check_checkbox_not_found() {
2123 let mut desc = "- [ ] Bug fix".to_string();
2124 let original = desc.clone();
2125 check_checkbox(&mut desc, "- [ ] New feature");
2126 assert_eq!(desc, original);
2127 }
2128
2129 #[test]
2132 fn scopes_section_single() {
2133 let scopes = vec!["cli".to_string()];
2134 assert_eq!(
2135 format_scopes_section(&scopes),
2136 "**Affected areas:** cli\n\n"
2137 );
2138 }
2139
2140 #[test]
2141 fn scopes_section_multiple() {
2142 let scopes = vec!["cli".to_string(), "git".to_string()];
2143 let result = format_scopes_section(&scopes);
2144 assert!(result.contains("cli"));
2145 assert!(result.contains("git"));
2146 assert!(result.starts_with("**Affected areas:**"));
2147 }
2148
2149 #[test]
2150 fn scopes_section_empty() {
2151 assert_eq!(format_scopes_section(&[]), "");
2152 }
2153
2154 #[test]
2157 fn commit_list_formatting() {
2158 let entries = vec![
2159 ("abc12345", "feat: add feature"),
2160 ("def67890", "fix: resolve bug"),
2161 ];
2162 let result = format_commit_list(&entries);
2163 assert!(result.contains("### Commits in this PR:"));
2164 assert!(result.contains("- `abc12345` feat: add feature"));
2165 assert!(result.contains("- `def67890` fix: resolve bug"));
2166 }
2167
2168 #[test]
2171 fn clean_branch_simple() {
2172 assert_eq!(clean_branch_name("feat/add-login"), "feat add login");
2173 }
2174
2175 #[test]
2176 fn clean_branch_underscores() {
2177 assert_eq!(clean_branch_name("user_name/fix_bug"), "user name fix bug");
2178 }
2179
2180 #[test]
2183 fn first_line_multiline() {
2184 assert_eq!(extract_first_line("first\nsecond\nthird"), "first");
2185 }
2186
2187 #[test]
2188 fn first_line_single() {
2189 assert_eq!(extract_first_line("only line"), "only line");
2190 }
2191
2192 #[test]
2193 fn first_line_empty() {
2194 assert_eq!(extract_first_line(""), "");
2195 }
2196
2197 #[test]
2200 fn draft_status_true() {
2201 let (icon, text) = format_draft_status(true);
2202 assert_eq!(text, "draft");
2203 assert!(!icon.is_empty());
2204 }
2205
2206 #[test]
2207 fn draft_status_false() {
2208 let (icon, text) = format_draft_status(false);
2209 assert_eq!(text, "ready for review");
2210 assert!(!icon.is_empty());
2211 }
2212
2213 #[test]
2216 fn push_action_skip_when_no_push() {
2217 assert_eq!(determine_push_action(true, false), PushAction::Skip);
2218 assert_eq!(determine_push_action(true, true), PushAction::Skip);
2219 }
2220
2221 #[test]
2222 fn push_action_sync_existing_branch() {
2223 assert_eq!(determine_push_action(false, true), PushAction::SyncExisting);
2224 }
2225
2226 #[test]
2227 fn push_action_push_new_branch() {
2228 assert_eq!(determine_push_action(false, false), PushAction::PushNew);
2229 }
2230}