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