1use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::{debug, error};
6
7use super::info::InfoCommand;
8
9#[derive(Parser)]
11pub struct CreatePrCommand {
12 #[arg(long, value_name = "BRANCH")]
14 pub base: Option<String>,
15
16 #[arg(long)]
18 pub model: Option<String>,
19
20 #[arg(long)]
22 pub auto_apply: bool,
23
24 #[arg(long, value_name = "FILE")]
26 pub save_only: Option<String>,
27
28 #[arg(long, conflicts_with = "draft")]
30 pub ready: bool,
31
32 #[arg(long, conflicts_with = "ready")]
34 pub draft: bool,
35
36 #[arg(long)]
38 pub context_dir: Option<std::path::PathBuf>,
39
40 #[arg(long)]
42 pub no_push: bool,
43}
44
45#[derive(Debug, PartialEq)]
47enum PrAction {
48 CreateNew,
49 UpdateExisting,
50 Cancel,
51}
52
53#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
55pub struct PrContent {
56 pub title: String,
58 pub description: String,
60}
61
62impl CreatePrCommand {
63 fn should_create_as_draft(&self) -> bool {
71 use crate::utils::settings::get_env_var;
72
73 if self.ready {
75 return false;
76 }
77 if self.draft {
78 return true;
79 }
80
81 get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
83 .ok()
84 .and_then(|val| parse_bool_string(&val))
85 .unwrap_or(true) }
87
88 pub async fn execute(self) -> Result<()> {
90 let ai_info = crate::utils::check_pr_command_prerequisites(self.model.as_deref())?;
93 println!(
94 "ā {} credentials verified (model: {})",
95 ai_info.provider, ai_info.model
96 );
97 println!("ā GitHub CLI verified");
98
99 println!("š Starting pull request creation process...");
100
101 let repo_view = self.generate_repository_view()?;
103
104 self.validate_branch_state(&repo_view)?;
106
107 use crate::claude::context::ProjectDiscovery;
109 let repo_root = std::path::PathBuf::from(".");
110 let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
111 let discovery = ProjectDiscovery::new(repo_root, context_dir);
112 let project_context = discovery.discover().unwrap_or_default();
113 self.show_guidance_files_status(&project_context)?;
114
115 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), None)?;
117 self.show_model_info_from_client(&claude_client)?;
118
119 self.show_commit_range_info(&repo_view)?;
121
122 let context = {
124 use crate::claude::context::{BranchAnalyzer, FileAnalyzer, WorkPatternAnalyzer};
125 use crate::data::context::CommitContext;
126 let mut context = CommitContext::new();
127 context.project = project_context;
128
129 if let Some(branch_info) = &repo_view.branch_info {
131 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
132 }
133
134 if !repo_view.commits.is_empty() {
135 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
136 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
137 }
138 context
139 };
140 self.show_context_summary(&context)?;
141
142 debug!("About to generate PR content from AI");
144 let (pr_content, _claude_client) = self
145 .generate_pr_content_with_client_internal(&repo_view, claude_client)
146 .await?;
147
148 self.show_context_information(&repo_view).await?;
150 debug!(
151 generated_title = %pr_content.title,
152 generated_description_length = pr_content.description.len(),
153 generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
154 "Generated PR content from AI"
155 );
156
157 if let Some(save_path) = self.save_only {
159 let pr_yaml = crate::data::to_yaml(&pr_content)
160 .context("Failed to serialize PR content to YAML")?;
161 std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
162 println!("š¾ PR details saved to: {save_path}");
163 return Ok(());
164 }
165
166 debug!("About to serialize PR content to YAML");
168 let temp_dir = tempfile::tempdir()?;
169 let pr_file = temp_dir.path().join("pr-details.yaml");
170
171 debug!(
172 pre_serialize_title = %pr_content.title,
173 pre_serialize_description_length = pr_content.description.len(),
174 pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
175 "About to serialize PR content with to_yaml"
176 );
177
178 let pr_yaml =
179 crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
180
181 debug!(
182 file_path = %pr_file.display(),
183 yaml_content_length = pr_yaml.len(),
184 yaml_content = %pr_yaml,
185 original_title = %pr_content.title,
186 original_description_length = pr_content.description.len(),
187 "Writing PR details to temporary YAML file"
188 );
189
190 std::fs::write(&pr_file, &pr_yaml)?;
191
192 let pr_action = if self.auto_apply {
194 if repo_view
196 .branch_prs
197 .as_ref()
198 .is_some_and(|prs| !prs.is_empty())
199 {
200 PrAction::UpdateExisting
201 } else {
202 PrAction::CreateNew
203 }
204 } else {
205 self.handle_pr_file(&pr_file, &repo_view)?
206 };
207
208 if pr_action == PrAction::Cancel {
209 println!("ā PR operation cancelled by user");
210 return Ok(());
211 }
212
213 let final_pr_yaml =
215 std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
216
217 debug!(
218 yaml_length = final_pr_yaml.len(),
219 yaml_content = %final_pr_yaml,
220 "Read PR details YAML from file"
221 );
222
223 let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
224 .context("Failed to parse PR details YAML. Please check the file format.")?;
225
226 debug!(
227 title = %final_pr_content.title,
228 description_length = final_pr_content.description.len(),
229 description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
230 "Parsed PR content from YAML"
231 );
232
233 let is_draft = self.should_create_as_draft();
235
236 match pr_action {
237 PrAction::CreateNew => {
238 self.create_github_pr(
239 &repo_view,
240 &final_pr_content.title,
241 &final_pr_content.description,
242 is_draft,
243 self.base.as_deref(),
244 )?;
245 println!("ā
Pull request created successfully!");
246 }
247 PrAction::UpdateExisting => {
248 self.update_github_pr(
249 &repo_view,
250 &final_pr_content.title,
251 &final_pr_content.description,
252 self.base.as_deref(),
253 )?;
254 println!("ā
Pull request updated successfully!");
255 }
256 PrAction::Cancel => unreachable!(), }
258
259 Ok(())
260 }
261
262 fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
264 use crate::data::{
265 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
266 WorkingDirectoryInfo,
267 };
268 use crate::git::{GitRepository, RemoteInfo};
269 use crate::utils::ai_scratch;
270
271 let repo = GitRepository::open()
273 .context("Failed to open git repository. Make sure you're in a git repository.")?;
274
275 let current_branch = repo.get_current_branch().context(
277 "Failed to get current branch. Make sure you're not in detached HEAD state.",
278 )?;
279
280 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
282
283 let primary_remote = remotes
285 .iter()
286 .find(|r| r.name == "origin")
287 .or_else(|| remotes.first())
288 .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
289
290 let base_branch = if let Some(branch) = self.base.as_ref() {
292 let remote_ref = format!("refs/remotes/{branch}");
295 if repo.repository().find_reference(&remote_ref).is_ok() {
296 branch.clone()
297 } else {
298 let with_remote = format!("{}/{}", primary_remote.name, branch);
300 let remote_ref = format!("refs/remotes/{with_remote}");
301 if repo.repository().find_reference(&remote_ref).is_ok() {
302 with_remote
303 } else {
304 anyhow::bail!(
305 "Remote branch '{branch}' does not exist (also tried '{with_remote}')"
306 );
307 }
308 }
309 } else {
310 let main_branch = &primary_remote.main_branch;
312 if main_branch == "unknown" {
313 let remote_name = &primary_remote.name;
314 anyhow::bail!("Could not determine main branch for remote '{remote_name}'");
315 }
316
317 let remote_main = format!("{}/{}", primary_remote.name, main_branch);
318
319 let remote_ref = format!("refs/remotes/{remote_main}");
321 if repo.repository().find_reference(&remote_ref).is_err() {
322 anyhow::bail!(
323 "Remote main branch '{remote_main}' does not exist. Try running 'git fetch' first."
324 );
325 }
326
327 remote_main
328 };
329
330 let commit_range = format!("{base_branch}..HEAD");
332
333 let wd_status = repo.get_working_directory_status()?;
335 let working_directory = WorkingDirectoryInfo {
336 clean: wd_status.clean,
337 untracked_changes: wd_status
338 .untracked_changes
339 .into_iter()
340 .map(|fs| FileStatusInfo {
341 status: fs.status,
342 file: fs.file,
343 })
344 .collect(),
345 };
346
347 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
349
350 let commits = repo.get_commits_in_range(&commit_range)?;
352
353 let pr_template_result = InfoCommand::read_pr_template().ok();
355 let (pr_template, pr_template_location) = match pr_template_result {
356 Some((content, location)) => (Some(content), Some(location)),
357 None => (None, None),
358 };
359
360 let branch_prs = InfoCommand::get_branch_prs(¤t_branch)
362 .ok()
363 .filter(|prs| !prs.is_empty());
364
365 let versions = Some(VersionInfo {
367 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
368 });
369
370 let ai_scratch_path =
372 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
373 let ai_info = AiInfo {
374 scratch: ai_scratch_path.to_string_lossy().to_string(),
375 };
376
377 let mut repo_view = RepositoryView {
379 versions,
380 explanation: FieldExplanation::default(),
381 working_directory,
382 remotes,
383 ai: ai_info,
384 branch_info: Some(BranchInfo {
385 branch: current_branch,
386 }),
387 pr_template,
388 pr_template_location,
389 branch_prs,
390 commits,
391 };
392
393 repo_view.update_field_presence();
395
396 Ok(repo_view)
397 }
398
399 fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
401 if !repo_view.working_directory.clean {
403 anyhow::bail!(
404 "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
405 );
406 }
407
408 if !repo_view.working_directory.untracked_changes.is_empty() {
410 let file_list: Vec<&str> = repo_view
411 .working_directory
412 .untracked_changes
413 .iter()
414 .map(|f| f.file.as_str())
415 .collect();
416 anyhow::bail!(
417 "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
418 file_list.join(", ")
419 );
420 }
421
422 if repo_view.commits.is_empty() {
424 anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
425 }
426
427 if let Some(existing_prs) = &repo_view.branch_prs {
429 if !existing_prs.is_empty() {
430 let pr_info: Vec<String> = existing_prs
431 .iter()
432 .map(|pr| format!("#{} ({})", pr.number, pr.state))
433 .collect();
434
435 println!(
436 "š Existing PR(s) found for this branch: {}",
437 pr_info.join(", ")
438 );
439 }
441 }
442
443 Ok(())
444 }
445
446 async fn show_context_information(
448 &self,
449 _repo_view: &crate::data::RepositoryView,
450 ) -> Result<()> {
451 Ok(())
456 }
457
458 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
460 let base_branch = match self.base.as_ref() {
462 Some(branch) => {
463 let primary_remote_name = repo_view
466 .remotes
467 .iter()
468 .find(|r| r.name == "origin")
469 .or_else(|| repo_view.remotes.first())
470 .map_or("origin", |r| r.name.as_str());
471 if branch.starts_with(&format!("{primary_remote_name}/")) {
473 branch.clone()
474 } else {
475 format!("{primary_remote_name}/{branch}")
476 }
477 }
478 None => {
479 repo_view
481 .remotes
482 .iter()
483 .find(|r| r.name == "origin")
484 .or_else(|| repo_view.remotes.first())
485 .map_or_else(
486 || "unknown".to_string(),
487 |r| format!("{}/{}", r.name, r.main_branch),
488 )
489 }
490 };
491
492 let commit_range = format!("{base_branch}..HEAD");
493 let commit_count = repo_view.commits.len();
494
495 let current_branch = repo_view
497 .branch_info
498 .as_ref()
499 .map_or("unknown", |bi| bi.branch.as_str());
500
501 println!("š Branch Analysis:");
502 println!(" šæ Current branch: {current_branch}");
503 println!(" š Commit range: {commit_range}");
504 println!(" š Commits found: {commit_count} commits");
505 println!();
506
507 Ok(())
508 }
509
510 async fn collect_context(
512 &self,
513 repo_view: &crate::data::RepositoryView,
514 ) -> Result<crate::data::context::CommitContext> {
515 use crate::claude::context::{
516 BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
517 };
518 use crate::data::context::{CommitContext, ProjectContext};
519 use crate::git::GitRepository;
520
521 let mut context = CommitContext::new();
522
523 let context_dir = crate::claude::context::resolve_context_dir(self.context_dir.as_deref());
525
526 let repo_root = std::path::PathBuf::from(".");
528 let discovery = ProjectDiscovery::new(repo_root, context_dir);
529 match discovery.discover() {
530 Ok(project_context) => {
531 context.project = project_context;
532 }
533 Err(_e) => {
534 context.project = ProjectContext::default();
535 }
536 }
537
538 let repo = GitRepository::open()?;
540 let current_branch = repo
541 .get_current_branch()
542 .unwrap_or_else(|_| "HEAD".to_string());
543 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
544
545 if !repo_view.commits.is_empty() {
547 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
548 }
549
550 if !repo_view.commits.is_empty() {
552 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
553 }
554
555 Ok(context)
556 }
557
558 fn show_guidance_files_status(
560 &self,
561 project_context: &crate::data::context::ProjectContext,
562 ) -> Result<()> {
563 use crate::claude::context::{
564 config_source_label, resolve_context_dir_with_source, ConfigSourceLabel,
565 };
566
567 let (context_dir, dir_source) =
568 resolve_context_dir_with_source(self.context_dir.as_deref());
569
570 println!("š Project guidance files status:");
571 println!(" š Config dir: {} ({dir_source})", context_dir.display());
572
573 let pr_guidelines_source = if project_context.pr_guidelines.is_some() {
575 match config_source_label(&context_dir, "pr-guidelines.md") {
576 ConfigSourceLabel::NotFound => "ā
(source unknown)".to_string(),
577 label => format!("ā
{label}"),
578 }
579 } else {
580 "ā None found".to_string()
581 };
582 println!(" š PR guidelines: {pr_guidelines_source}");
583
584 let scopes_count = project_context.valid_scopes.len();
586 let scopes_source = if scopes_count > 0 {
587 match config_source_label(&context_dir, "scopes.yaml") {
588 ConfigSourceLabel::NotFound => {
589 format!("ā
(source unknown + ecosystem defaults) ({scopes_count} scopes)")
590 }
591 label => format!("ā
{label} ({scopes_count} scopes)"),
592 }
593 } else {
594 "ā None found".to_string()
595 };
596 println!(" šÆ Valid scopes: {scopes_source}");
597
598 let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
600 let pr_template_status = if pr_template_path.exists() {
601 format!("ā
Project: {}", pr_template_path.display())
602 } else {
603 "ā None found".to_string()
604 };
605 println!(" š PR template: {pr_template_status}");
606
607 println!();
608 Ok(())
609 }
610
611 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
613 use crate::data::context::{VerbosityLevel, WorkPattern};
614
615 println!("š Context Analysis:");
616
617 if !context.project.valid_scopes.is_empty() {
619 let scope_names: Vec<&str> = context
620 .project
621 .valid_scopes
622 .iter()
623 .map(|s| s.name.as_str())
624 .collect();
625 println!(" š Valid scopes: {}", scope_names.join(", "));
626 }
627
628 if context.branch.is_feature_branch {
630 println!(
631 " šæ Branch: {} ({})",
632 context.branch.description, context.branch.work_type
633 );
634 if let Some(ref ticket) = context.branch.ticket_id {
635 println!(" š« Ticket: {ticket}");
636 }
637 }
638
639 match context.range.work_pattern {
641 WorkPattern::Sequential => println!(" š Pattern: Sequential development"),
642 WorkPattern::Refactoring => println!(" š§¹ Pattern: Refactoring work"),
643 WorkPattern::BugHunt => println!(" š Pattern: Bug investigation"),
644 WorkPattern::Documentation => println!(" š Pattern: Documentation updates"),
645 WorkPattern::Configuration => println!(" āļø Pattern: Configuration changes"),
646 WorkPattern::Unknown => {}
647 }
648
649 if let Some(label) = super::formatting::format_file_analysis(&context.files) {
651 println!(" {label}");
652 }
653
654 match context.suggested_verbosity() {
656 VerbosityLevel::Comprehensive => {
657 println!(" š Detail level: Comprehensive (significant changes detected)");
658 }
659 VerbosityLevel::Detailed => println!(" š Detail level: Detailed"),
660 VerbosityLevel::Concise => println!(" š Detail level: Concise"),
661 }
662
663 println!();
664 Ok(())
665 }
666
667 async fn generate_pr_content_with_client_internal(
669 &self,
670 repo_view: &crate::data::RepositoryView,
671 claude_client: crate::claude::client::ClaudeClient,
672 ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
673 use tracing::debug;
674
675 let pr_template = match &repo_view.pr_template {
677 Some(template) => template.clone(),
678 None => self.get_default_pr_template(),
679 };
680
681 debug!(
682 pr_template_length = pr_template.len(),
683 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
684 "Using PR template for generation"
685 );
686
687 println!("š¤ Generating AI-powered PR description...");
688
689 debug!("Collecting context for PR generation");
691 let context = self.collect_context(repo_view).await?;
692 debug!("Context collection completed");
693
694 debug!("About to call Claude AI for PR content generation");
696 match claude_client
697 .generate_pr_content_with_context(repo_view, &pr_template, &context)
698 .await
699 {
700 Ok(pr_content) => {
701 debug!(
702 ai_generated_title = %pr_content.title,
703 ai_generated_description_length = pr_content.description.len(),
704 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
705 "AI successfully generated PR content"
706 );
707 Ok((pr_content, claude_client))
708 }
709 Err(e) => {
710 debug!(error = %e, "AI PR generation failed, falling back to basic description");
711 let mut description = pr_template;
713 self.enhance_description_with_commits(&mut description, repo_view)?;
714
715 let title = self.generate_title_from_commits(repo_view);
717
718 debug!(
719 fallback_title = %title,
720 fallback_description_length = description.len(),
721 "Created fallback PR content"
722 );
723
724 Ok((PrContent { title, description }, claude_client))
725 }
726 }
727 }
728
729 fn get_default_pr_template(&self) -> String {
731 r#"# Pull Request
732
733## Description
734<!-- Provide a brief description of what this PR does -->
735
736## Type of Change
737<!-- Mark the relevant option with an "x" -->
738- [ ] Bug fix (non-breaking change which fixes an issue)
739- [ ] New feature (non-breaking change which adds functionality)
740- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
741- [ ] Documentation update
742- [ ] Refactoring (no functional changes)
743- [ ] Performance improvement
744- [ ] Test coverage improvement
745
746## Changes Made
747<!-- List the specific changes made in this PR -->
748-
749-
750-
751
752## Testing
753- [ ] All existing tests pass
754- [ ] New tests added for new functionality
755- [ ] Manual testing performed
756
757## Additional Notes
758<!-- Add any additional notes for reviewers -->
759"#.to_string()
760 }
761
762 fn enhance_description_with_commits(
764 &self,
765 description: &mut String,
766 repo_view: &crate::data::RepositoryView,
767 ) -> Result<()> {
768 if repo_view.commits.is_empty() {
769 return Ok(());
770 }
771
772 description.push_str("\n---\n");
774 description.push_str("## š Commit Summary\n");
775 description
776 .push_str("*This section was automatically generated based on commit analysis*\n\n");
777
778 let mut types_found = std::collections::HashSet::new();
780 let mut scopes_found = std::collections::HashSet::new();
781 let mut has_breaking_changes = false;
782
783 for commit in &repo_view.commits {
784 let detected_type = &commit.analysis.detected_type;
785 types_found.insert(detected_type.clone());
786 if is_breaking_change(detected_type, &commit.original_message) {
787 has_breaking_changes = true;
788 }
789
790 let detected_scope = &commit.analysis.detected_scope;
791 if !detected_scope.is_empty() {
792 scopes_found.insert(detected_scope.clone());
793 }
794 }
795
796 if types_found.contains("feat") {
798 check_checkbox(description, "- [ ] New feature");
799 }
800 if types_found.contains("fix") {
801 check_checkbox(description, "- [ ] Bug fix");
802 }
803 if types_found.contains("docs") {
804 check_checkbox(description, "- [ ] Documentation update");
805 }
806 if types_found.contains("refactor") {
807 check_checkbox(description, "- [ ] Refactoring");
808 }
809 if has_breaking_changes {
810 check_checkbox(description, "- [ ] Breaking change");
811 }
812
813 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
815 let scopes_section = format_scopes_section(&scopes_list);
816 if !scopes_section.is_empty() {
817 description.push_str(&scopes_section);
818 }
819
820 let commit_entries: Vec<(&str, &str)> = repo_view
822 .commits
823 .iter()
824 .map(|c| {
825 let short = &c.hash[..crate::git::SHORT_HASH_LEN];
826 let first = extract_first_line(&c.original_message);
827 (short, first)
828 })
829 .collect();
830 description.push_str(&format_commit_list(&commit_entries));
831
832 let total_files: usize = repo_view
834 .commits
835 .iter()
836 .map(|c| c.analysis.file_changes.total_files)
837 .sum();
838
839 if total_files > 0 {
840 description.push_str(&format!("\n**Files changed:** {total_files} files\n"));
841 }
842
843 Ok(())
844 }
845
846 fn handle_pr_file(
848 &self,
849 pr_file: &std::path::Path,
850 repo_view: &crate::data::RepositoryView,
851 ) -> Result<PrAction> {
852 use std::io::{self, Write};
853
854 println!("\nš PR details generated.");
855 println!("š¾ Details saved to: {}", pr_file.display());
856
857 let is_draft = self.should_create_as_draft();
859 let (status_icon, status_text) = format_draft_status(is_draft);
860 println!("{status_icon} PR will be created as: {status_text}");
861 println!();
862
863 let has_existing_prs = repo_view
865 .branch_prs
866 .as_ref()
867 .is_some_and(|prs| !prs.is_empty());
868
869 loop {
870 if has_existing_prs {
871 print!("ā [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
872 } else {
873 print!(
874 "ā [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
875 );
876 }
877 io::stdout().flush()?;
878
879 let mut input = String::new();
880 io::stdin().read_line(&mut input)?;
881
882 match input.trim().to_lowercase().as_str() {
883 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
884 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
885 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
886 "s" | "show" => {
887 self.show_pr_file(pr_file)?;
888 println!();
889 }
890 "e" | "edit" => {
891 self.edit_pr_file(pr_file)?;
892 println!();
893 }
894 "q" | "quit" => return Ok(PrAction::Cancel),
895 _ => {
896 if has_existing_prs {
897 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
898 } else {
899 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
900 }
901 }
902 }
903 }
904 }
905
906 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
908 use std::fs;
909
910 println!("\nš PR details file contents:");
911 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
912
913 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
914 println!("{contents}");
915 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
916
917 Ok(())
918 }
919
920 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
922 use std::env;
923 use std::io::{self, Write};
924 use std::process::Command;
925
926 let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
928 e
929 } else {
930 println!("š§ Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
932 print!("Please enter the command to use as your editor: ");
933 io::stdout().flush().context("Failed to flush stdout")?;
934
935 let mut input = String::new();
936 io::stdin()
937 .read_line(&mut input)
938 .context("Failed to read user input")?;
939 input.trim().to_string()
940 };
941
942 if editor.is_empty() {
943 println!("ā No editor specified. Returning to menu.");
944 return Ok(());
945 }
946
947 println!("š Opening PR details file in editor: {editor}");
948
949 let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
950
951 let mut command = Command::new(editor_cmd);
952 command.args(args);
953 command.arg(pr_file.to_string_lossy().as_ref());
954
955 match command.status() {
956 Ok(status) => {
957 if status.success() {
958 println!("ā
Editor session completed.");
959 } else {
960 println!(
961 "ā ļø Editor exited with non-zero status: {:?}",
962 status.code()
963 );
964 }
965 }
966 Err(e) => {
967 println!("ā Failed to execute editor '{editor}': {e}");
968 println!(" Please check that the editor command is correct and available in your PATH.");
969 }
970 }
971
972 Ok(())
973 }
974
975 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
977 if repo_view.commits.is_empty() {
978 return "Pull Request".to_string();
979 }
980
981 if repo_view.commits.len() == 1 {
983 let first = extract_first_line(&repo_view.commits[0].original_message);
984 let trimmed = first.trim();
985 return if trimmed.is_empty() {
986 "Pull Request".to_string()
987 } else {
988 trimmed.to_string()
989 };
990 }
991
992 let branch_name = repo_view
994 .branch_info
995 .as_ref()
996 .map_or("feature", |bi| bi.branch.as_str());
997
998 format!("feat: {}", clean_branch_name(branch_name))
999 }
1000
1001 fn create_github_pr(
1003 &self,
1004 repo_view: &crate::data::RepositoryView,
1005 title: &str,
1006 description: &str,
1007 is_draft: bool,
1008 new_base: Option<&str>,
1009 ) -> Result<()> {
1010 use std::process::Command;
1011
1012 let branch_name = repo_view
1014 .branch_info
1015 .as_ref()
1016 .map(|bi| &bi.branch)
1017 .context("Branch info not available")?;
1018
1019 let pr_status = if is_draft {
1020 "draft"
1021 } else {
1022 "ready for review"
1023 };
1024 println!("š Creating pull request ({pr_status})...");
1025 println!(" š Title: {title}");
1026 println!(" šæ Branch: {branch_name}");
1027 if let Some(base) = new_base {
1028 println!(" šÆ Base: {base}");
1029 }
1030
1031 let push_action = if self.no_push {
1033 determine_push_action(true, false)
1034 } else {
1035 debug!("Opening git repository to check branch status");
1036 let git_repo =
1037 crate::git::GitRepository::open().context("Failed to open git repository")?;
1038
1039 debug!(
1040 "Checking if branch '{}' exists on remote 'origin'",
1041 branch_name
1042 );
1043 let branch_on_remote = git_repo.branch_exists_on_remote(branch_name, "origin")?;
1044 let action = determine_push_action(false, branch_on_remote);
1045
1046 debug!("Push action for branch '{}': {:?}", branch_name, action);
1047 println!("š¤ Pushing branch to remote...");
1048 git_repo
1049 .push_branch(branch_name, "origin")
1050 .context("Failed to push branch to remote")?;
1051
1052 action
1053 };
1054
1055 if push_action == PushAction::Skip {
1056 debug!("Skipping push (--no-push flag set)");
1057 }
1058
1059 debug!("Creating PR with gh CLI - title: '{}'", title);
1061 debug!("PR description length: {} characters", description.len());
1062 debug!("PR draft status: {}", is_draft);
1063 if let Some(base) = new_base {
1064 debug!("PR base branch: {}", base);
1065 }
1066
1067 let mut args = vec![
1068 "pr",
1069 "create",
1070 "--head",
1071 branch_name,
1072 "--title",
1073 title,
1074 "--body",
1075 description,
1076 ];
1077
1078 if let Some(base) = new_base {
1079 args.push("--base");
1080 args.push(base);
1081 }
1082
1083 if is_draft {
1084 args.push("--draft");
1085 }
1086
1087 let pr_result = Command::new("gh")
1088 .args(&args)
1089 .output()
1090 .context("Failed to create pull request")?;
1091
1092 if pr_result.status.success() {
1093 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
1094 let pr_url = pr_url.trim();
1095 debug!("PR created successfully with URL: {}", pr_url);
1096 println!("š Pull request created: {pr_url}");
1097 } else {
1098 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1099 error!("gh CLI failed to create PR: {}", error_msg);
1100 anyhow::bail!("Failed to create pull request: {error_msg}");
1101 }
1102
1103 Ok(())
1104 }
1105
1106 fn update_github_pr(
1108 &self,
1109 repo_view: &crate::data::RepositoryView,
1110 title: &str,
1111 description: &str,
1112 new_base: Option<&str>,
1113 ) -> Result<()> {
1114 use std::io::{self, Write};
1115 use std::process::Command;
1116
1117 let existing_pr = repo_view
1119 .branch_prs
1120 .as_ref()
1121 .and_then(|prs| prs.first())
1122 .context("No existing PR found to update")?;
1123
1124 let pr_number = existing_pr.number;
1125 let current_base = &existing_pr.base;
1126
1127 println!("š Updating pull request #{pr_number}...");
1128 println!(" š Title: {title}");
1129
1130 let change_base = if let Some(base) = new_base {
1132 if !current_base.is_empty() && current_base != base {
1133 print!(" šÆ Current base: {current_base} ā New base: {base}. Change? [y/N]: ");
1134 io::stdout().flush()?;
1135
1136 let mut input = String::new();
1137 io::stdin().read_line(&mut input)?;
1138 let response = input.trim().to_lowercase();
1139 response == "y" || response == "yes"
1140 } else {
1141 false
1142 }
1143 } else {
1144 false
1145 };
1146
1147 debug!(
1148 pr_number = pr_number,
1149 title = %title,
1150 description_length = description.len(),
1151 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1152 change_base = change_base,
1153 "Updating GitHub PR with title and description"
1154 );
1155
1156 let pr_number_str = pr_number.to_string();
1158 let mut gh_args = vec![
1159 "pr",
1160 "edit",
1161 &pr_number_str,
1162 "--title",
1163 title,
1164 "--body",
1165 description,
1166 ];
1167
1168 if change_base {
1169 if let Some(base) = new_base {
1170 gh_args.push("--base");
1171 gh_args.push(base);
1172 }
1173 }
1174
1175 debug!(
1176 args = ?gh_args,
1177 "Executing gh command to update PR"
1178 );
1179
1180 let pr_result = Command::new("gh")
1181 .args(&gh_args)
1182 .output()
1183 .context("Failed to update pull request")?;
1184
1185 if pr_result.status.success() {
1186 println!("š Pull request updated: {}", existing_pr.url);
1188 if change_base {
1189 if let Some(base) = new_base {
1190 println!(" šÆ Base branch changed to: {base}");
1191 }
1192 }
1193 } else {
1194 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1195 anyhow::bail!("Failed to update pull request: {error_msg}");
1196 }
1197
1198 Ok(())
1199 }
1200
1201 fn show_model_info_from_client(
1203 &self,
1204 client: &crate::claude::client::ClaudeClient,
1205 ) -> Result<()> {
1206 use crate::claude::model_config::get_model_registry;
1207
1208 println!("š¤ AI Model Configuration:");
1209
1210 let metadata = client.get_ai_client_metadata();
1212 let registry = get_model_registry();
1213
1214 if let Some(spec) = registry.get_model_spec(&metadata.model) {
1215 if metadata.model != spec.api_identifier {
1217 println!(
1218 " š” Model: {} ā \x1b[33m{}\x1b[0m",
1219 metadata.model, spec.api_identifier
1220 );
1221 } else {
1222 println!(" š” Model: \x1b[33m{}\x1b[0m", metadata.model);
1223 }
1224
1225 println!(" š·ļø Provider: {}", spec.provider);
1226 println!(" š Generation: {}", spec.generation);
1227 println!(" ā Tier: {} ({})", spec.tier, {
1228 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1229 &tier_info.description
1230 } else {
1231 "No description available"
1232 }
1233 });
1234 println!(" š¤ Max output tokens: {}", metadata.max_response_length);
1235 println!(" š„ Input context: {}", metadata.max_context_length);
1236
1237 if let Some((ref key, ref value)) = metadata.active_beta {
1238 println!(" š¬ Beta header: {key}: {value}");
1239 }
1240
1241 if spec.legacy {
1242 println!(" ā ļø Legacy model (consider upgrading to newer version)");
1243 }
1244 } else {
1245 println!(" š” Model: \x1b[33m{}\x1b[0m", metadata.model);
1247 println!(" š·ļø Provider: {}", metadata.provider);
1248 println!(" ā ļø Model not found in registry, using client metadata:");
1249 println!(" š¤ Max output tokens: {}", metadata.max_response_length);
1250 println!(" š„ Input context: {}", metadata.max_context_length);
1251 }
1252
1253 println!();
1254 Ok(())
1255 }
1256}
1257
1258#[derive(Debug, PartialEq)]
1262enum PushAction {
1263 Skip,
1265 SyncExisting,
1267 PushNew,
1269}
1270
1271fn determine_push_action(no_push: bool, branch_on_remote: bool) -> PushAction {
1273 if no_push {
1274 PushAction::Skip
1275 } else if branch_on_remote {
1276 PushAction::SyncExisting
1277 } else {
1278 PushAction::PushNew
1279 }
1280}
1281
1282fn parse_bool_string(val: &str) -> Option<bool> {
1287 match val.to_lowercase().as_str() {
1288 "true" | "1" | "yes" => Some(true),
1289 "false" | "0" | "no" => Some(false),
1290 _ => None,
1291 }
1292}
1293
1294fn is_breaking_change(detected_type: &str, original_message: &str) -> bool {
1296 detected_type.contains("BREAKING") || original_message.contains("BREAKING CHANGE")
1297}
1298
1299fn check_checkbox(description: &mut String, search_text: &str) {
1301 if let Some(pos) = description.find(search_text) {
1302 description.replace_range(pos..pos + 5, "- [x]");
1303 }
1304}
1305
1306fn format_scopes_section(scopes: &[String]) -> String {
1310 if scopes.is_empty() {
1311 return String::new();
1312 }
1313 format!("**Affected areas:** {}\n\n", scopes.join(", "))
1314}
1315
1316fn format_commit_list(entries: &[(&str, &str)]) -> String {
1318 let mut output = String::from("### Commits in this PR:\n");
1319 for (hash, message) in entries {
1320 output.push_str(&format!("- `{hash}` {message}\n"));
1321 }
1322 output
1323}
1324
1325fn clean_branch_name(branch: &str) -> String {
1327 branch.replace(['/', '-', '_'], " ")
1328}
1329
1330fn extract_first_line(text: &str) -> &str {
1332 text.lines().next().unwrap_or("").trim()
1333}
1334
1335fn format_draft_status(is_draft: bool) -> (&'static str, &'static str) {
1337 if is_draft {
1338 ("\u{1f4cb}", "draft")
1339 } else {
1340 ("\u{2705}", "ready for review")
1341 }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347
1348 #[test]
1351 fn parse_bool_true_variants() {
1352 assert_eq!(parse_bool_string("true"), Some(true));
1353 assert_eq!(parse_bool_string("1"), Some(true));
1354 assert_eq!(parse_bool_string("yes"), Some(true));
1355 }
1356
1357 #[test]
1358 fn parse_bool_false_variants() {
1359 assert_eq!(parse_bool_string("false"), Some(false));
1360 assert_eq!(parse_bool_string("0"), Some(false));
1361 assert_eq!(parse_bool_string("no"), Some(false));
1362 }
1363
1364 #[test]
1365 fn parse_bool_invalid() {
1366 assert_eq!(parse_bool_string("maybe"), None);
1367 assert_eq!(parse_bool_string(""), None);
1368 }
1369
1370 #[test]
1371 fn parse_bool_case_insensitive() {
1372 assert_eq!(parse_bool_string("TRUE"), Some(true));
1373 assert_eq!(parse_bool_string("Yes"), Some(true));
1374 assert_eq!(parse_bool_string("FALSE"), Some(false));
1375 assert_eq!(parse_bool_string("No"), Some(false));
1376 }
1377
1378 #[test]
1381 fn breaking_change_type_contains() {
1382 assert!(is_breaking_change("BREAKING", "normal message"));
1383 }
1384
1385 #[test]
1386 fn breaking_change_message_contains() {
1387 assert!(is_breaking_change("feat", "BREAKING CHANGE: removed API"));
1388 }
1389
1390 #[test]
1391 fn breaking_change_none() {
1392 assert!(!is_breaking_change("feat", "add new feature"));
1393 }
1394
1395 #[test]
1398 fn check_checkbox_found() {
1399 let mut desc = "- [ ] New feature\n- [ ] Bug fix".to_string();
1400 check_checkbox(&mut desc, "- [ ] New feature");
1401 assert!(desc.contains("- [x] New feature"));
1402 assert!(desc.contains("- [ ] Bug fix"));
1403 }
1404
1405 #[test]
1406 fn check_checkbox_not_found() {
1407 let mut desc = "- [ ] Bug fix".to_string();
1408 let original = desc.clone();
1409 check_checkbox(&mut desc, "- [ ] New feature");
1410 assert_eq!(desc, original);
1411 }
1412
1413 #[test]
1416 fn scopes_section_single() {
1417 let scopes = vec!["cli".to_string()];
1418 assert_eq!(
1419 format_scopes_section(&scopes),
1420 "**Affected areas:** cli\n\n"
1421 );
1422 }
1423
1424 #[test]
1425 fn scopes_section_multiple() {
1426 let scopes = vec!["cli".to_string(), "git".to_string()];
1427 let result = format_scopes_section(&scopes);
1428 assert!(result.contains("cli"));
1429 assert!(result.contains("git"));
1430 assert!(result.starts_with("**Affected areas:**"));
1431 }
1432
1433 #[test]
1434 fn scopes_section_empty() {
1435 assert_eq!(format_scopes_section(&[]), "");
1436 }
1437
1438 #[test]
1441 fn commit_list_formatting() {
1442 let entries = vec![
1443 ("abc12345", "feat: add feature"),
1444 ("def67890", "fix: resolve bug"),
1445 ];
1446 let result = format_commit_list(&entries);
1447 assert!(result.contains("### Commits in this PR:"));
1448 assert!(result.contains("- `abc12345` feat: add feature"));
1449 assert!(result.contains("- `def67890` fix: resolve bug"));
1450 }
1451
1452 #[test]
1455 fn clean_branch_simple() {
1456 assert_eq!(clean_branch_name("feat/add-login"), "feat add login");
1457 }
1458
1459 #[test]
1460 fn clean_branch_underscores() {
1461 assert_eq!(clean_branch_name("user_name/fix_bug"), "user name fix bug");
1462 }
1463
1464 #[test]
1467 fn first_line_multiline() {
1468 assert_eq!(extract_first_line("first\nsecond\nthird"), "first");
1469 }
1470
1471 #[test]
1472 fn first_line_single() {
1473 assert_eq!(extract_first_line("only line"), "only line");
1474 }
1475
1476 #[test]
1477 fn first_line_empty() {
1478 assert_eq!(extract_first_line(""), "");
1479 }
1480
1481 #[test]
1484 fn draft_status_true() {
1485 let (icon, text) = format_draft_status(true);
1486 assert_eq!(text, "draft");
1487 assert!(!icon.is_empty());
1488 }
1489
1490 #[test]
1491 fn draft_status_false() {
1492 let (icon, text) = format_draft_status(false);
1493 assert_eq!(text, "ready for review");
1494 assert!(!icon.is_empty());
1495 }
1496
1497 #[test]
1500 fn push_action_skip_when_no_push() {
1501 assert_eq!(determine_push_action(true, false), PushAction::Skip);
1502 assert_eq!(determine_push_action(true, true), PushAction::Skip);
1503 }
1504
1505 #[test]
1506 fn push_action_sync_existing_branch() {
1507 assert_eq!(determine_push_action(false, true), PushAction::SyncExisting);
1508 }
1509
1510 #[test]
1511 fn push_action_push_new_branch() {
1512 assert_eq!(determine_push_action(false, false), PushAction::PushNew);
1513 }
1514}