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
37#[derive(Debug, PartialEq)]
39enum PrAction {
40 CreateNew,
41 UpdateExisting,
42 Cancel,
43}
44
45#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
47pub struct PrContent {
48 pub title: String,
50 pub description: String,
52}
53
54impl CreatePrCommand {
55 fn should_create_as_draft(&self) -> bool {
63 use crate::utils::settings::get_env_var;
64
65 if self.ready {
67 return false;
68 }
69 if self.draft {
70 return true;
71 }
72
73 get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
75 .ok()
76 .and_then(|val| match val.to_lowercase().as_str() {
77 "true" | "1" | "yes" => Some(true),
78 "false" | "0" | "no" => Some(false),
79 _ => None,
80 })
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 = std::path::PathBuf::from(".omni-dev");
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, 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 }
133 context
134 };
135 self.show_context_summary(&context)?;
136
137 debug!("About to generate PR content from AI");
139 let (pr_content, _claude_client) = self
140 .generate_pr_content_with_client_internal(&repo_view, claude_client)
141 .await?;
142
143 self.show_context_information(&repo_view).await?;
145 debug!(
146 generated_title = %pr_content.title,
147 generated_description_length = pr_content.description.len(),
148 generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
149 "Generated PR content from AI"
150 );
151
152 if let Some(save_path) = self.save_only {
154 let pr_yaml = crate::data::to_yaml(&pr_content)
155 .context("Failed to serialize PR content to YAML")?;
156 std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
157 println!("š¾ PR details saved to: {}", save_path);
158 return Ok(());
159 }
160
161 debug!("About to serialize PR content to YAML");
163 let temp_dir = tempfile::tempdir()?;
164 let pr_file = temp_dir.path().join("pr-details.yaml");
165
166 debug!(
167 pre_serialize_title = %pr_content.title,
168 pre_serialize_description_length = pr_content.description.len(),
169 pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
170 "About to serialize PR content with to_yaml"
171 );
172
173 let pr_yaml =
174 crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
175
176 debug!(
177 file_path = %pr_file.display(),
178 yaml_content_length = pr_yaml.len(),
179 yaml_content = %pr_yaml,
180 original_title = %pr_content.title,
181 original_description_length = pr_content.description.len(),
182 "Writing PR details to temporary YAML file"
183 );
184
185 std::fs::write(&pr_file, &pr_yaml)?;
186
187 let pr_action = if self.auto_apply {
189 if repo_view
191 .branch_prs
192 .as_ref()
193 .is_some_and(|prs| !prs.is_empty())
194 {
195 PrAction::UpdateExisting
196 } else {
197 PrAction::CreateNew
198 }
199 } else {
200 self.handle_pr_file(&pr_file, &repo_view)?
201 };
202
203 if pr_action == PrAction::Cancel {
204 println!("ā PR operation cancelled by user");
205 return Ok(());
206 }
207
208 let final_pr_yaml =
210 std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
211
212 debug!(
213 yaml_length = final_pr_yaml.len(),
214 yaml_content = %final_pr_yaml,
215 "Read PR details YAML from file"
216 );
217
218 let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
219 .context("Failed to parse PR details YAML. Please check the file format.")?;
220
221 debug!(
222 title = %final_pr_content.title,
223 description_length = final_pr_content.description.len(),
224 description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
225 "Parsed PR content from YAML"
226 );
227
228 let is_draft = self.should_create_as_draft();
230
231 match pr_action {
232 PrAction::CreateNew => {
233 self.create_github_pr(
234 &repo_view,
235 &final_pr_content.title,
236 &final_pr_content.description,
237 is_draft,
238 self.base.as_deref(),
239 )?;
240 println!("ā
Pull request created successfully!");
241 }
242 PrAction::UpdateExisting => {
243 self.update_github_pr(
244 &repo_view,
245 &final_pr_content.title,
246 &final_pr_content.description,
247 self.base.as_deref(),
248 )?;
249 println!("ā
Pull request updated successfully!");
250 }
251 PrAction::Cancel => unreachable!(), }
253
254 Ok(())
255 }
256
257 fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
259 use crate::data::{
260 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
261 WorkingDirectoryInfo,
262 };
263 use crate::git::{GitRepository, RemoteInfo};
264 use crate::utils::ai_scratch;
265
266 let repo = GitRepository::open()
268 .context("Failed to open git repository. Make sure you're in a git repository.")?;
269
270 let current_branch = repo.get_current_branch().context(
272 "Failed to get current branch. Make sure you're not in detached HEAD state.",
273 )?;
274
275 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
277
278 let primary_remote = remotes
280 .iter()
281 .find(|r| r.name == "origin")
282 .or_else(|| remotes.first())
283 .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
284
285 let base_branch = match self.base.as_ref() {
287 Some(branch) => {
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 '{}' does not exist (also tried '{}')",
302 branch,
303 with_remote
304 );
305 }
306 }
307 }
308 None => {
309 let main_branch = &primary_remote.main_branch;
311 if main_branch == "unknown" {
312 anyhow::bail!(
313 "Could not determine main branch for remote '{}'",
314 primary_remote.name
315 );
316 }
317
318 let remote_main = format!("{}/{}", primary_remote.name, main_branch);
319
320 let remote_ref = format!("refs/remotes/{}", remote_main);
322 if repo.repository().find_reference(&remote_ref).is_err() {
323 anyhow::bail!(
324 "Remote main branch '{}' does not exist. Try running 'git fetch' first.",
325 remote_main
326 );
327 }
328
329 remote_main
330 }
331 };
332
333 let commit_range = format!("{}..HEAD", base_branch);
335
336 let wd_status = repo.get_working_directory_status()?;
338 let working_directory = WorkingDirectoryInfo {
339 clean: wd_status.clean,
340 untracked_changes: wd_status
341 .untracked_changes
342 .into_iter()
343 .map(|fs| FileStatusInfo {
344 status: fs.status,
345 file: fs.file,
346 })
347 .collect(),
348 };
349
350 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
352
353 let commits = repo.get_commits_in_range(&commit_range)?;
355
356 let pr_template_result = InfoCommand::read_pr_template().ok();
358 let (pr_template, pr_template_location) = match pr_template_result {
359 Some((content, location)) => (Some(content), Some(location)),
360 None => (None, None),
361 };
362
363 let branch_prs = InfoCommand::get_branch_prs(¤t_branch)
365 .ok()
366 .filter(|prs| !prs.is_empty());
367
368 let versions = Some(VersionInfo {
370 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
371 });
372
373 let ai_scratch_path =
375 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
376 let ai_info = AiInfo {
377 scratch: ai_scratch_path.to_string_lossy().to_string(),
378 };
379
380 let mut repo_view = RepositoryView {
382 versions,
383 explanation: FieldExplanation::default(),
384 working_directory,
385 remotes,
386 ai: ai_info,
387 branch_info: Some(BranchInfo {
388 branch: current_branch,
389 }),
390 pr_template,
391 pr_template_location,
392 branch_prs,
393 commits,
394 };
395
396 repo_view.update_field_presence();
398
399 Ok(repo_view)
400 }
401
402 fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
404 if !repo_view.working_directory.clean {
406 anyhow::bail!(
407 "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
408 );
409 }
410
411 if !repo_view.working_directory.untracked_changes.is_empty() {
413 let file_list: Vec<&str> = repo_view
414 .working_directory
415 .untracked_changes
416 .iter()
417 .map(|f| f.file.as_str())
418 .collect();
419 anyhow::bail!(
420 "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
421 file_list.join(", ")
422 );
423 }
424
425 if repo_view.commits.is_empty() {
427 anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
428 }
429
430 if let Some(existing_prs) = &repo_view.branch_prs {
432 if !existing_prs.is_empty() {
433 let pr_info: Vec<String> = existing_prs
434 .iter()
435 .map(|pr| format!("#{} ({})", pr.number, pr.state))
436 .collect();
437
438 println!(
439 "š Existing PR(s) found for this branch: {}",
440 pr_info.join(", ")
441 );
442 }
444 }
445
446 Ok(())
447 }
448
449 async fn show_context_information(
451 &self,
452 _repo_view: &crate::data::RepositoryView,
453 ) -> Result<()> {
454 Ok(())
459 }
460
461 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
463 let base_branch = match self.base.as_ref() {
465 Some(branch) => {
466 let primary_remote_name = repo_view
469 .remotes
470 .iter()
471 .find(|r| r.name == "origin")
472 .or_else(|| repo_view.remotes.first())
473 .map(|r| r.name.as_str())
474 .unwrap_or("origin");
475 if branch.starts_with(&format!("{}/", primary_remote_name)) {
477 branch.clone()
478 } else {
479 format!("{}/{}", primary_remote_name, branch)
480 }
481 }
482 None => {
483 repo_view
485 .remotes
486 .iter()
487 .find(|r| r.name == "origin")
488 .or_else(|| repo_view.remotes.first())
489 .map(|r| format!("{}/{}", r.name, r.main_branch))
490 .unwrap_or_else(|| "unknown".to_string())
491 }
492 };
493
494 let commit_range = format!("{}..HEAD", base_branch);
495 let commit_count = repo_view.commits.len();
496
497 let current_branch = repo_view
499 .branch_info
500 .as_ref()
501 .map(|bi| bi.branch.as_str())
502 .unwrap_or("unknown");
503
504 println!("š Branch Analysis:");
505 println!(" šæ Current branch: {}", current_branch);
506 println!(" š Commit range: {}", commit_range);
507 println!(" š Commits found: {} commits", commit_count);
508 println!();
509
510 Ok(())
511 }
512
513 async fn collect_context(
515 &self,
516 repo_view: &crate::data::RepositoryView,
517 ) -> Result<crate::data::context::CommitContext> {
518 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
519 use crate::data::context::CommitContext;
520 use crate::git::GitRepository;
521
522 let mut context = CommitContext::new();
523
524 let context_dir = std::path::PathBuf::from(".omni-dev");
526
527 let repo_root = std::path::PathBuf::from(".");
529 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
530 match discovery.discover() {
531 Ok(project_context) => {
532 context.project = project_context;
533 }
534 Err(_e) => {
535 context.project = Default::default();
536 }
537 }
538
539 let repo = GitRepository::open()?;
541 let current_branch = repo
542 .get_current_branch()
543 .unwrap_or_else(|_| "HEAD".to_string());
544 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
545
546 if !repo_view.commits.is_empty() {
548 context.range = WorkPatternAnalyzer::analyze_commit_range(&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 let context_dir = std::path::PathBuf::from(".omni-dev");
560
561 println!("š Project guidance files status:");
562
563 let pr_guidelines_found = project_context.pr_guidelines.is_some();
565 let pr_guidelines_source = if pr_guidelines_found {
566 let local_path = context_dir.join("local").join("pr-guidelines.md");
567 let project_path = context_dir.join("pr-guidelines.md");
568 let home_path = dirs::home_dir()
569 .map(|h| h.join(".omni-dev").join("pr-guidelines.md"))
570 .unwrap_or_default();
571
572 if local_path.exists() {
573 format!("ā
Local override: {}", local_path.display())
574 } else if project_path.exists() {
575 format!("ā
Project: {}", project_path.display())
576 } else if home_path.exists() {
577 format!("ā
Global: {}", home_path.display())
578 } else {
579 "ā
(source unknown)".to_string()
580 }
581 } else {
582 "ā None found".to_string()
583 };
584 println!(" š PR guidelines: {}", pr_guidelines_source);
585
586 let scopes_count = project_context.valid_scopes.len();
588 let scopes_source = if scopes_count > 0 {
589 let local_path = context_dir.join("local").join("scopes.yaml");
590 let project_path = context_dir.join("scopes.yaml");
591 let home_path = dirs::home_dir()
592 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
593 .unwrap_or_default();
594
595 let source = if local_path.exists() {
596 format!("Local override: {}", local_path.display())
597 } else if project_path.exists() {
598 format!("Project: {}", project_path.display())
599 } else if home_path.exists() {
600 format!("Global: {}", home_path.display())
601 } else {
602 "(source unknown + ecosystem defaults)".to_string()
603 };
604 format!("ā
{} ({} scopes)", source, scopes_count)
605 } else {
606 "ā None found".to_string()
607 };
608 println!(" šÆ Valid scopes: {}", scopes_source);
609
610 let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
612 let pr_template_status = if pr_template_path.exists() {
613 format!("ā
Project: {}", pr_template_path.display())
614 } else {
615 "ā None found".to_string()
616 };
617 println!(" š PR template: {}", pr_template_status);
618
619 println!();
620 Ok(())
621 }
622
623 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
625 use crate::data::context::{VerbosityLevel, WorkPattern};
626
627 println!("š Context Analysis:");
628
629 if !context.project.valid_scopes.is_empty() {
631 let scope_names: Vec<&str> = context
632 .project
633 .valid_scopes
634 .iter()
635 .map(|s| s.name.as_str())
636 .collect();
637 println!(" š Valid scopes: {}", scope_names.join(", "));
638 }
639
640 if context.branch.is_feature_branch {
642 println!(
643 " šæ Branch: {} ({})",
644 context.branch.description, context.branch.work_type
645 );
646 if let Some(ref ticket) = context.branch.ticket_id {
647 println!(" š« Ticket: {}", ticket);
648 }
649 }
650
651 match context.range.work_pattern {
653 WorkPattern::Sequential => println!(" š Pattern: Sequential development"),
654 WorkPattern::Refactoring => println!(" š§¹ Pattern: Refactoring work"),
655 WorkPattern::BugHunt => println!(" š Pattern: Bug investigation"),
656 WorkPattern::Documentation => println!(" š Pattern: Documentation updates"),
657 WorkPattern::Configuration => println!(" āļø Pattern: Configuration changes"),
658 WorkPattern::Unknown => {}
659 }
660
661 match context.suggested_verbosity() {
663 VerbosityLevel::Comprehensive => {
664 println!(" š Detail level: Comprehensive (significant changes detected)")
665 }
666 VerbosityLevel::Detailed => println!(" š Detail level: Detailed"),
667 VerbosityLevel::Concise => println!(" š Detail level: Concise"),
668 }
669
670 println!();
671 Ok(())
672 }
673
674 async fn generate_pr_content_with_client_internal(
676 &self,
677 repo_view: &crate::data::RepositoryView,
678 claude_client: crate::claude::client::ClaudeClient,
679 ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
680 use tracing::debug;
681
682 let pr_template = match &repo_view.pr_template {
684 Some(template) => template.clone(),
685 None => self.get_default_pr_template(),
686 };
687
688 debug!(
689 pr_template_length = pr_template.len(),
690 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
691 "Using PR template for generation"
692 );
693
694 println!("š¤ Generating AI-powered PR description...");
695
696 debug!("Collecting context for PR generation");
698 let context = self.collect_context(repo_view).await?;
699 debug!("Context collection completed");
700
701 debug!("About to call Claude AI for PR content generation");
703 match claude_client
704 .generate_pr_content_with_context(repo_view, &pr_template, &context)
705 .await
706 {
707 Ok(pr_content) => {
708 debug!(
709 ai_generated_title = %pr_content.title,
710 ai_generated_description_length = pr_content.description.len(),
711 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
712 "AI successfully generated PR content"
713 );
714 Ok((pr_content, claude_client))
715 }
716 Err(e) => {
717 debug!(error = %e, "AI PR generation failed, falling back to basic description");
718 let mut description = pr_template;
720 self.enhance_description_with_commits(&mut description, repo_view)?;
721
722 let title = self.generate_title_from_commits(repo_view);
724
725 debug!(
726 fallback_title = %title,
727 fallback_description_length = description.len(),
728 "Created fallback PR content"
729 );
730
731 Ok((PrContent { title, description }, claude_client))
732 }
733 }
734 }
735
736 fn get_default_pr_template(&self) -> String {
738 r#"# Pull Request
739
740## Description
741<!-- Provide a brief description of what this PR does -->
742
743## Type of Change
744<!-- Mark the relevant option with an "x" -->
745- [ ] Bug fix (non-breaking change which fixes an issue)
746- [ ] New feature (non-breaking change which adds functionality)
747- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
748- [ ] Documentation update
749- [ ] Refactoring (no functional changes)
750- [ ] Performance improvement
751- [ ] Test coverage improvement
752
753## Changes Made
754<!-- List the specific changes made in this PR -->
755-
756-
757-
758
759## Testing
760- [ ] All existing tests pass
761- [ ] New tests added for new functionality
762- [ ] Manual testing performed
763
764## Additional Notes
765<!-- Add any additional notes for reviewers -->
766"#.to_string()
767 }
768
769 fn enhance_description_with_commits(
771 &self,
772 description: &mut String,
773 repo_view: &crate::data::RepositoryView,
774 ) -> Result<()> {
775 if repo_view.commits.is_empty() {
776 return Ok(());
777 }
778
779 description.push_str("\n---\n");
781 description.push_str("## š Commit Summary\n");
782 description
783 .push_str("*This section was automatically generated based on commit analysis*\n\n");
784
785 let mut types_found = std::collections::HashSet::new();
787 let mut scopes_found = std::collections::HashSet::new();
788 let mut has_breaking_changes = false;
789
790 for commit in &repo_view.commits {
791 let detected_type = &commit.analysis.detected_type;
792 types_found.insert(detected_type.clone());
793 if detected_type.contains("BREAKING")
794 || commit.original_message.contains("BREAKING CHANGE")
795 {
796 has_breaking_changes = true;
797 }
798
799 let detected_scope = &commit.analysis.detected_scope;
800 if !detected_scope.is_empty() {
801 scopes_found.insert(detected_scope.clone());
802 }
803 }
804
805 if let Some(feat_pos) = description.find("- [ ] New feature") {
807 if types_found.contains("feat") {
808 description.replace_range(feat_pos..feat_pos + 5, "- [x]");
809 }
810 }
811 if let Some(fix_pos) = description.find("- [ ] Bug fix") {
812 if types_found.contains("fix") {
813 description.replace_range(fix_pos..fix_pos + 5, "- [x]");
814 }
815 }
816 if let Some(docs_pos) = description.find("- [ ] Documentation update") {
817 if types_found.contains("docs") {
818 description.replace_range(docs_pos..docs_pos + 5, "- [x]");
819 }
820 }
821 if let Some(refactor_pos) = description.find("- [ ] Refactoring") {
822 if types_found.contains("refactor") {
823 description.replace_range(refactor_pos..refactor_pos + 5, "- [x]");
824 }
825 }
826 if let Some(breaking_pos) = description.find("- [ ] Breaking change") {
827 if has_breaking_changes {
828 description.replace_range(breaking_pos..breaking_pos + 5, "- [x]");
829 }
830 }
831
832 if !scopes_found.is_empty() {
834 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
835 description.push_str(&format!(
836 "**Affected areas:** {}\n\n",
837 scopes_list.join(", ")
838 ));
839 }
840
841 description.push_str("### Commits in this PR:\n");
843 for commit in &repo_view.commits {
844 let short_hash = &commit.hash[..crate::git::SHORT_HASH_LEN];
845 let first_line = commit.original_message.lines().next().unwrap_or("").trim();
846 description.push_str(&format!("- `{}` {}\n", short_hash, first_line));
847 }
848
849 let total_files: usize = repo_view
851 .commits
852 .iter()
853 .map(|c| c.analysis.file_changes.total_files)
854 .sum();
855
856 if total_files > 0 {
857 description.push_str(&format!("\n**Files changed:** {} files\n", total_files));
858 }
859
860 Ok(())
861 }
862
863 fn handle_pr_file(
865 &self,
866 pr_file: &std::path::Path,
867 repo_view: &crate::data::RepositoryView,
868 ) -> Result<PrAction> {
869 use std::io::{self, Write};
870
871 println!("\nš PR details generated.");
872 println!("š¾ Details saved to: {}", pr_file.display());
873
874 let is_draft = self.should_create_as_draft();
876 let status_icon = if is_draft { "š" } else { "ā
" };
877 let status_text = if is_draft {
878 "draft"
879 } else {
880 "ready for review"
881 };
882 println!("{} PR will be created as: {}", status_icon, status_text);
883 println!();
884
885 let has_existing_prs = repo_view
887 .branch_prs
888 .as_ref()
889 .is_some_and(|prs| !prs.is_empty());
890
891 loop {
892 if has_existing_prs {
893 print!("ā [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
894 } else {
895 print!(
896 "ā [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
897 );
898 }
899 io::stdout().flush()?;
900
901 let mut input = String::new();
902 io::stdin().read_line(&mut input)?;
903
904 match input.trim().to_lowercase().as_str() {
905 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
906 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
907 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
908 "s" | "show" => {
909 self.show_pr_file(pr_file)?;
910 println!();
911 }
912 "e" | "edit" => {
913 self.edit_pr_file(pr_file)?;
914 println!();
915 }
916 "q" | "quit" => return Ok(PrAction::Cancel),
917 _ => {
918 if has_existing_prs {
919 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
920 } else {
921 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
922 }
923 }
924 }
925 }
926 }
927
928 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
930 use std::fs;
931
932 println!("\nš PR details file contents:");
933 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
934
935 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
936 println!("{}", contents);
937 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
938
939 Ok(())
940 }
941
942 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
944 use std::env;
945 use std::io::{self, Write};
946 use std::process::Command;
947
948 let editor = env::var("OMNI_DEV_EDITOR")
950 .or_else(|_| env::var("EDITOR"))
951 .unwrap_or_else(|_| {
952 println!(
954 "š§ Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
955 );
956 print!("Please enter the command to use as your editor: ");
957 io::stdout().flush().expect("Failed to flush stdout");
958
959 let mut input = String::new();
960 io::stdin()
961 .read_line(&mut input)
962 .expect("Failed to read user input");
963 input.trim().to_string()
964 });
965
966 if editor.is_empty() {
967 println!("ā No editor specified. Returning to menu.");
968 return Ok(());
969 }
970
971 println!("š Opening PR details file in editor: {}", editor);
972
973 let mut cmd_parts = editor.split_whitespace();
975 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
976 let args: Vec<&str> = cmd_parts.collect();
977
978 let mut command = Command::new(editor_cmd);
979 command.args(args);
980 command.arg(pr_file.to_string_lossy().as_ref());
981
982 match command.status() {
983 Ok(status) => {
984 if status.success() {
985 println!("ā
Editor session completed.");
986 } else {
987 println!(
988 "ā ļø Editor exited with non-zero status: {:?}",
989 status.code()
990 );
991 }
992 }
993 Err(e) => {
994 println!("ā Failed to execute editor '{}': {}", editor, e);
995 println!(" Please check that the editor command is correct and available in your PATH.");
996 }
997 }
998
999 Ok(())
1000 }
1001
1002 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
1004 if repo_view.commits.is_empty() {
1005 return "Pull Request".to_string();
1006 }
1007
1008 if repo_view.commits.len() == 1 {
1010 return repo_view.commits[0]
1011 .original_message
1012 .lines()
1013 .next()
1014 .unwrap_or("Pull Request")
1015 .trim()
1016 .to_string();
1017 }
1018
1019 let branch_name = repo_view
1021 .branch_info
1022 .as_ref()
1023 .map(|bi| bi.branch.as_str())
1024 .unwrap_or("feature");
1025
1026 let cleaned_branch = branch_name.replace(['/', '-', '_'], " ");
1027
1028 format!("feat: {}", cleaned_branch)
1029 }
1030
1031 fn create_github_pr(
1033 &self,
1034 repo_view: &crate::data::RepositoryView,
1035 title: &str,
1036 description: &str,
1037 is_draft: bool,
1038 new_base: Option<&str>,
1039 ) -> Result<()> {
1040 use std::process::Command;
1041
1042 let branch_name = repo_view
1044 .branch_info
1045 .as_ref()
1046 .map(|bi| &bi.branch)
1047 .context("Branch info not available")?;
1048
1049 let pr_status = if is_draft {
1050 "draft"
1051 } else {
1052 "ready for review"
1053 };
1054 println!("š Creating pull request ({})...", pr_status);
1055 println!(" š Title: {}", title);
1056 println!(" šæ Branch: {}", branch_name);
1057 if let Some(base) = new_base {
1058 println!(" šÆ Base: {}", base);
1059 }
1060
1061 debug!("Opening git repository to check branch status");
1063 let git_repo =
1064 crate::git::GitRepository::open().context("Failed to open git repository")?;
1065
1066 debug!(
1067 "Checking if branch '{}' exists on remote 'origin'",
1068 branch_name
1069 );
1070 if !git_repo.branch_exists_on_remote(branch_name, "origin")? {
1071 println!("š¤ Pushing branch to remote...");
1072 debug!(
1073 "Branch '{}' not found on remote, attempting to push",
1074 branch_name
1075 );
1076 git_repo
1077 .push_branch(branch_name, "origin")
1078 .context("Failed to push branch to remote")?;
1079 } else {
1080 debug!("Branch '{}' already exists on remote 'origin'", branch_name);
1081 }
1082
1083 debug!("Creating PR with gh CLI - title: '{}'", title);
1085 debug!("PR description length: {} characters", description.len());
1086 debug!("PR draft status: {}", is_draft);
1087 if let Some(base) = new_base {
1088 debug!("PR base branch: {}", base);
1089 }
1090
1091 let mut args = vec![
1092 "pr",
1093 "create",
1094 "--head",
1095 branch_name,
1096 "--title",
1097 title,
1098 "--body",
1099 description,
1100 ];
1101
1102 if let Some(base) = new_base {
1103 args.push("--base");
1104 args.push(base);
1105 }
1106
1107 if is_draft {
1108 args.push("--draft");
1109 }
1110
1111 let pr_result = Command::new("gh")
1112 .args(&args)
1113 .output()
1114 .context("Failed to create pull request")?;
1115
1116 if pr_result.status.success() {
1117 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
1118 let pr_url = pr_url.trim();
1119 debug!("PR created successfully with URL: {}", pr_url);
1120 println!("š Pull request created: {}", pr_url);
1121 } else {
1122 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1123 error!("gh CLI failed to create PR: {}", error_msg);
1124 anyhow::bail!("Failed to create pull request: {}", error_msg);
1125 }
1126
1127 Ok(())
1128 }
1129
1130 fn update_github_pr(
1132 &self,
1133 repo_view: &crate::data::RepositoryView,
1134 title: &str,
1135 description: &str,
1136 new_base: Option<&str>,
1137 ) -> Result<()> {
1138 use std::io::{self, Write};
1139 use std::process::Command;
1140
1141 let existing_pr = repo_view
1143 .branch_prs
1144 .as_ref()
1145 .and_then(|prs| prs.first())
1146 .context("No existing PR found to update")?;
1147
1148 let pr_number = existing_pr.number;
1149 let current_base = &existing_pr.base;
1150
1151 println!("š Updating pull request #{}...", pr_number);
1152 println!(" š Title: {}", title);
1153
1154 let change_base = if let Some(base) = new_base {
1156 if !current_base.is_empty() && current_base != base {
1157 print!(
1158 " šÆ Current base: {} ā New base: {}. Change? [y/N]: ",
1159 current_base, base
1160 );
1161 io::stdout().flush()?;
1162
1163 let mut input = String::new();
1164 io::stdin().read_line(&mut input)?;
1165 let response = input.trim().to_lowercase();
1166 response == "y" || response == "yes"
1167 } else {
1168 false
1169 }
1170 } else {
1171 false
1172 };
1173
1174 debug!(
1175 pr_number = pr_number,
1176 title = %title,
1177 description_length = description.len(),
1178 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1179 change_base = change_base,
1180 "Updating GitHub PR with title and description"
1181 );
1182
1183 let pr_number_str = pr_number.to_string();
1185 let mut gh_args = vec![
1186 "pr",
1187 "edit",
1188 &pr_number_str,
1189 "--title",
1190 title,
1191 "--body",
1192 description,
1193 ];
1194
1195 if change_base {
1196 if let Some(base) = new_base {
1197 gh_args.push("--base");
1198 gh_args.push(base);
1199 }
1200 }
1201
1202 debug!(
1203 args = ?gh_args,
1204 "Executing gh command to update PR"
1205 );
1206
1207 let pr_result = Command::new("gh")
1208 .args(&gh_args)
1209 .output()
1210 .context("Failed to update pull request")?;
1211
1212 if pr_result.status.success() {
1213 println!("š Pull request updated: {}", existing_pr.url);
1215 if change_base {
1216 if let Some(base) = new_base {
1217 println!(" šÆ Base branch changed to: {}", base);
1218 }
1219 }
1220 } else {
1221 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
1222 anyhow::bail!("Failed to update pull request: {}", error_msg);
1223 }
1224
1225 Ok(())
1226 }
1227
1228 fn show_model_info_from_client(
1230 &self,
1231 client: &crate::claude::client::ClaudeClient,
1232 ) -> Result<()> {
1233 use crate::claude::model_config::get_model_registry;
1234
1235 println!("š¤ AI Model Configuration:");
1236
1237 let metadata = client.get_ai_client_metadata();
1239 let registry = get_model_registry();
1240
1241 if let Some(spec) = registry.get_model_spec(&metadata.model) {
1242 if metadata.model != spec.api_identifier {
1244 println!(
1245 " š” Model: {} ā \x1b[33m{}\x1b[0m",
1246 metadata.model, spec.api_identifier
1247 );
1248 } else {
1249 println!(" š” Model: \x1b[33m{}\x1b[0m", metadata.model);
1250 }
1251
1252 println!(" š·ļø Provider: {}", spec.provider);
1253 println!(" š Generation: {}", spec.generation);
1254 println!(" ā Tier: {} ({})", spec.tier, {
1255 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1256 &tier_info.description
1257 } else {
1258 "No description available"
1259 }
1260 });
1261 println!(" š¤ Max output tokens: {}", metadata.max_response_length);
1262 println!(" š„ Input context: {}", metadata.max_context_length);
1263
1264 if let Some((ref key, ref value)) = metadata.active_beta {
1265 println!(" š¬ Beta header: {}: {}", key, value);
1266 }
1267
1268 if spec.legacy {
1269 println!(" ā ļø Legacy model (consider upgrading to newer version)");
1270 }
1271 } else {
1272 println!(" š” Model: \x1b[33m{}\x1b[0m", metadata.model);
1274 println!(" š·ļø Provider: {}", metadata.provider);
1275 println!(" ā ļø Model not found in registry, using client metadata:");
1276 println!(" š¤ Max output tokens: {}", metadata.max_response_length);
1277 println!(" š„ Input context: {}", metadata.max_context_length);
1278 }
1279
1280 println!();
1281 Ok(())
1282 }
1283}