1use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use tracing::{debug, error};
6
7#[derive(Parser)]
9pub struct GitCommand {
10 #[command(subcommand)]
12 pub command: GitSubcommands,
13}
14
15#[derive(Subcommand)]
17pub enum GitSubcommands {
18 Commit(CommitCommand),
20 Branch(BranchCommand),
22}
23
24#[derive(Parser)]
26pub struct CommitCommand {
27 #[command(subcommand)]
29 pub command: CommitSubcommands,
30}
31
32#[derive(Subcommand)]
34pub enum CommitSubcommands {
35 Message(MessageCommand),
37}
38
39#[derive(Parser)]
41pub struct MessageCommand {
42 #[command(subcommand)]
44 pub command: MessageSubcommands,
45}
46
47#[derive(Subcommand)]
49pub enum MessageSubcommands {
50 View(ViewCommand),
52 Amend(AmendCommand),
54 Twiddle(TwiddleCommand),
56}
57
58#[derive(Parser)]
60pub struct ViewCommand {
61 #[arg(value_name = "COMMIT_RANGE")]
63 pub commit_range: Option<String>,
64}
65
66#[derive(Parser)]
68pub struct AmendCommand {
69 #[arg(value_name = "YAML_FILE")]
71 pub yaml_file: String,
72}
73
74#[derive(Parser)]
76pub struct TwiddleCommand {
77 #[arg(value_name = "COMMIT_RANGE")]
79 pub commit_range: Option<String>,
80
81 #[arg(long)]
83 pub model: Option<String>,
84
85 #[arg(long)]
87 pub auto_apply: bool,
88
89 #[arg(long, value_name = "FILE")]
91 pub save_only: Option<String>,
92
93 #[arg(long, default_value = "true")]
95 pub use_context: bool,
96
97 #[arg(long)]
99 pub context_dir: Option<std::path::PathBuf>,
100
101 #[arg(long)]
103 pub work_context: Option<String>,
104
105 #[arg(long)]
107 pub branch_context: Option<String>,
108
109 #[arg(long)]
111 pub no_context: bool,
112
113 #[arg(long, default_value = "4")]
115 pub batch_size: usize,
116}
117
118#[derive(Parser)]
120pub struct BranchCommand {
121 #[command(subcommand)]
123 pub command: BranchSubcommands,
124}
125
126#[derive(Subcommand)]
128pub enum BranchSubcommands {
129 Info(InfoCommand),
131 Create(CreateCommand),
133}
134
135#[derive(Parser)]
137pub struct InfoCommand {
138 #[arg(value_name = "BASE_BRANCH")]
140 pub base_branch: Option<String>,
141}
142
143#[derive(Parser)]
145pub struct CreateCommand {
146 #[command(subcommand)]
148 pub command: CreateSubcommands,
149}
150
151#[derive(Subcommand)]
153pub enum CreateSubcommands {
154 Pr(CreatePrCommand),
156}
157
158#[derive(Parser)]
160pub struct CreatePrCommand {
161 #[arg(value_name = "BASE_BRANCH")]
163 pub base_branch: Option<String>,
164
165 #[arg(long)]
167 pub auto_apply: bool,
168
169 #[arg(long, value_name = "FILE")]
171 pub save_only: Option<String>,
172}
173
174impl GitCommand {
175 pub fn execute(self) -> Result<()> {
177 match self.command {
178 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(),
179 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(),
180 }
181 }
182}
183
184impl CommitCommand {
185 pub fn execute(self) -> Result<()> {
187 match self.command {
188 CommitSubcommands::Message(message_cmd) => message_cmd.execute(),
189 }
190 }
191}
192
193impl MessageCommand {
194 pub fn execute(self) -> Result<()> {
196 match self.command {
197 MessageSubcommands::View(view_cmd) => view_cmd.execute(),
198 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
199 MessageSubcommands::Twiddle(twiddle_cmd) => {
200 let rt =
202 tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
203 rt.block_on(twiddle_cmd.execute())
204 }
205 }
206 }
207}
208
209impl ViewCommand {
210 pub fn execute(self) -> Result<()> {
212 use crate::data::{
213 AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
214 WorkingDirectoryInfo,
215 };
216 use crate::git::{GitRepository, RemoteInfo};
217 use crate::utils::ai_scratch;
218
219 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD");
220
221 let repo = GitRepository::open()
223 .context("Failed to open git repository. Make sure you're in a git repository.")?;
224
225 let wd_status = repo.get_working_directory_status()?;
227 let working_directory = WorkingDirectoryInfo {
228 clean: wd_status.clean,
229 untracked_changes: wd_status
230 .untracked_changes
231 .into_iter()
232 .map(|fs| FileStatusInfo {
233 status: fs.status,
234 file: fs.file,
235 })
236 .collect(),
237 };
238
239 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
241
242 let commits = repo.get_commits_in_range(commit_range)?;
244
245 let versions = Some(VersionInfo {
247 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
248 });
249
250 let ai_scratch_path =
252 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
253 let ai_info = AiInfo {
254 scratch: ai_scratch_path.to_string_lossy().to_string(),
255 };
256
257 let mut repo_view = RepositoryView {
259 versions,
260 explanation: FieldExplanation::default(),
261 working_directory,
262 remotes,
263 ai: ai_info,
264 branch_info: None,
265 pr_template: None,
266 pr_template_location: None,
267 branch_prs: None,
268 commits,
269 };
270
271 repo_view.update_field_presence();
273
274 let yaml_output = crate::data::to_yaml(&repo_view)?;
276 println!("{}", yaml_output);
277
278 Ok(())
279 }
280}
281
282impl AmendCommand {
283 pub fn execute(self) -> Result<()> {
285 use crate::git::AmendmentHandler;
286
287 println!("๐ Starting commit amendment process...");
288 println!("๐ Loading amendments from: {}", self.yaml_file);
289
290 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
292
293 handler
294 .apply_amendments(&self.yaml_file)
295 .context("Failed to apply amendments")?;
296
297 Ok(())
298 }
299}
300
301impl TwiddleCommand {
302 pub async fn execute(self) -> Result<()> {
304 let use_contextual = self.use_context && !self.no_context;
306
307 if use_contextual {
308 println!(
309 "๐ช Starting AI-powered commit message improvement with contextual intelligence..."
310 );
311 } else {
312 println!("๐ช Starting AI-powered commit message improvement...");
313 }
314
315 let full_repo_view = self.generate_repository_view().await?;
317
318 if full_repo_view.commits.len() > self.batch_size {
320 println!(
321 "๐ฆ Processing {} commits in batches of {} to ensure reliable analysis...",
322 full_repo_view.commits.len(),
323 self.batch_size
324 );
325 return self
326 .execute_with_batching(use_contextual, full_repo_view)
327 .await;
328 }
329
330 let context = if use_contextual {
332 Some(self.collect_context(&full_repo_view).await?)
333 } else {
334 None
335 };
336
337 if let Some(ref ctx) = context {
339 self.show_context_summary(ctx)?;
340 }
341
342 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
344
345 self.show_model_info_from_client(&claude_client)?;
347
348 if use_contextual && context.is_some() {
350 println!("๐ค Analyzing commits with enhanced contextual intelligence...");
351 } else {
352 println!("๐ค Analyzing commits with Claude AI...");
353 }
354
355 let amendments = if let Some(ctx) = context {
356 claude_client
357 .generate_contextual_amendments(&full_repo_view, &ctx)
358 .await?
359 } else {
360 claude_client.generate_amendments(&full_repo_view).await?
361 };
362
363 if let Some(save_path) = self.save_only {
365 amendments.save_to_file(save_path)?;
366 println!("๐พ Amendments saved to file");
367 return Ok(());
368 }
369
370 if !amendments.amendments.is_empty() {
372 let temp_dir = tempfile::tempdir()?;
374 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
375 amendments.save_to_file(&amendments_file)?;
376
377 if !self.auto_apply && !self.handle_amendments_file(&amendments_file, &amendments)? {
379 println!("โ Amendment cancelled by user");
380 return Ok(());
381 }
382
383 self.apply_amendments_from_file(&amendments_file).await?;
385 println!("โ
Commit messages improved successfully!");
386 } else {
387 println!("โจ No commits found to process!");
388 }
389
390 Ok(())
391 }
392
393 async fn execute_with_batching(
395 &self,
396 use_contextual: bool,
397 full_repo_view: crate::data::RepositoryView,
398 ) -> Result<()> {
399 use crate::data::amendments::AmendmentFile;
400
401 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
403
404 self.show_model_info_from_client(&claude_client)?;
406
407 let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
409
410 let total_batches = commit_batches.len();
411 let mut all_amendments = AmendmentFile {
412 amendments: Vec::new(),
413 };
414
415 println!("๐ Processing {} batches...", total_batches);
416
417 for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
418 println!(
419 "๐ Processing batch {}/{} ({} commits)...",
420 batch_num + 1,
421 total_batches,
422 commit_batch.len()
423 );
424
425 let batch_repo_view = crate::data::RepositoryView {
427 versions: full_repo_view.versions.clone(),
428 explanation: full_repo_view.explanation.clone(),
429 working_directory: full_repo_view.working_directory.clone(),
430 remotes: full_repo_view.remotes.clone(),
431 ai: full_repo_view.ai.clone(),
432 branch_info: full_repo_view.branch_info.clone(),
433 pr_template: full_repo_view.pr_template.clone(),
434 pr_template_location: full_repo_view.pr_template_location.clone(),
435 branch_prs: full_repo_view.branch_prs.clone(),
436 commits: commit_batch.to_vec(),
437 };
438
439 let batch_context = if use_contextual {
441 Some(self.collect_context(&batch_repo_view).await?)
442 } else {
443 None
444 };
445
446 let batch_amendments = if let Some(ctx) = batch_context {
448 claude_client
449 .generate_contextual_amendments(&batch_repo_view, &ctx)
450 .await?
451 } else {
452 claude_client.generate_amendments(&batch_repo_view).await?
453 };
454
455 all_amendments
457 .amendments
458 .extend(batch_amendments.amendments);
459
460 if batch_num + 1 < total_batches {
461 println!(" โ
Batch {}/{} completed", batch_num + 1, total_batches);
462 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
464 }
465 }
466
467 println!(
468 "โ
All batches completed! Found {} commits to improve.",
469 all_amendments.amendments.len()
470 );
471
472 if let Some(save_path) = &self.save_only {
474 all_amendments.save_to_file(save_path)?;
475 println!("๐พ Amendments saved to file");
476 return Ok(());
477 }
478
479 if !all_amendments.amendments.is_empty() {
481 let temp_dir = tempfile::tempdir()?;
483 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
484 all_amendments.save_to_file(&amendments_file)?;
485
486 if !self.auto_apply
488 && !self.handle_amendments_file(&amendments_file, &all_amendments)?
489 {
490 println!("โ Amendment cancelled by user");
491 return Ok(());
492 }
493
494 self.apply_amendments_from_file(&amendments_file).await?;
496 println!("โ
Commit messages improved successfully!");
497 } else {
498 println!("โจ No commits found to process!");
499 }
500
501 Ok(())
502 }
503
504 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
506 use crate::data::{
507 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
508 WorkingDirectoryInfo,
509 };
510 use crate::git::{GitRepository, RemoteInfo};
511 use crate::utils::ai_scratch;
512
513 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
514
515 let repo = GitRepository::open()
517 .context("Failed to open git repository. Make sure you're in a git repository.")?;
518
519 let current_branch = repo
521 .get_current_branch()
522 .unwrap_or_else(|_| "HEAD".to_string());
523
524 let wd_status = repo.get_working_directory_status()?;
526 let working_directory = WorkingDirectoryInfo {
527 clean: wd_status.clean,
528 untracked_changes: wd_status
529 .untracked_changes
530 .into_iter()
531 .map(|fs| FileStatusInfo {
532 status: fs.status,
533 file: fs.file,
534 })
535 .collect(),
536 };
537
538 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
540
541 let commits = repo.get_commits_in_range(commit_range)?;
543
544 let versions = Some(VersionInfo {
546 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
547 });
548
549 let ai_scratch_path =
551 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
552 let ai_info = AiInfo {
553 scratch: ai_scratch_path.to_string_lossy().to_string(),
554 };
555
556 let mut repo_view = RepositoryView {
558 versions,
559 explanation: FieldExplanation::default(),
560 working_directory,
561 remotes,
562 ai: ai_info,
563 branch_info: Some(BranchInfo {
564 branch: current_branch,
565 }),
566 pr_template: None,
567 pr_template_location: None,
568 branch_prs: None,
569 commits,
570 };
571
572 repo_view.update_field_presence();
574
575 Ok(repo_view)
576 }
577
578 fn handle_amendments_file(
580 &self,
581 amendments_file: &std::path::Path,
582 amendments: &crate::data::amendments::AmendmentFile,
583 ) -> Result<bool> {
584 use std::io::{self, Write};
585
586 println!(
587 "\n๐ Found {} commits that could be improved.",
588 amendments.amendments.len()
589 );
590 println!("๐พ Amendments saved to: {}", amendments_file.display());
591 println!();
592
593 loop {
594 print!("โ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
595 io::stdout().flush()?;
596
597 let mut input = String::new();
598 io::stdin().read_line(&mut input)?;
599
600 match input.trim().to_lowercase().as_str() {
601 "a" | "apply" | "" => return Ok(true),
602 "s" | "show" => {
603 self.show_amendments_file(amendments_file)?;
604 println!();
605 }
606 "e" | "edit" => {
607 self.edit_amendments_file(amendments_file)?;
608 println!();
609 }
610 "q" | "quit" => return Ok(false),
611 _ => {
612 println!(
613 "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
614 );
615 }
616 }
617 }
618 }
619
620 fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
622 use std::fs;
623
624 println!("\n๐ Amendments file contents:");
625 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
626
627 let contents =
628 fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
629
630 println!("{}", contents);
631 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
632
633 Ok(())
634 }
635
636 fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
638 use std::env;
639 use std::io::{self, Write};
640 use std::process::Command;
641
642 let editor = env::var("OMNI_DEV_EDITOR")
644 .or_else(|_| env::var("EDITOR"))
645 .unwrap_or_else(|_| {
646 println!(
648 "๐ง Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
649 );
650 print!("Please enter the command to use as your editor: ");
651 io::stdout().flush().expect("Failed to flush stdout");
652
653 let mut input = String::new();
654 io::stdin()
655 .read_line(&mut input)
656 .expect("Failed to read user input");
657 input.trim().to_string()
658 });
659
660 if editor.is_empty() {
661 println!("โ No editor specified. Returning to menu.");
662 return Ok(());
663 }
664
665 println!("๐ Opening amendments file in editor: {}", editor);
666
667 let mut cmd_parts = editor.split_whitespace();
669 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
670 let args: Vec<&str> = cmd_parts.collect();
671
672 let mut command = Command::new(editor_cmd);
673 command.args(args);
674 command.arg(amendments_file.to_string_lossy().as_ref());
675
676 match command.status() {
677 Ok(status) => {
678 if status.success() {
679 println!("โ
Editor session completed.");
680 } else {
681 println!(
682 "โ ๏ธ Editor exited with non-zero status: {:?}",
683 status.code()
684 );
685 }
686 }
687 Err(e) => {
688 println!("โ Failed to execute editor '{}': {}", editor, e);
689 println!(" Please check that the editor command is correct and available in your PATH.");
690 }
691 }
692
693 Ok(())
694 }
695
696 async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
698 use crate::git::AmendmentHandler;
699
700 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
702 handler
703 .apply_amendments(&amendments_file.to_string_lossy())
704 .context("Failed to apply amendments")?;
705
706 Ok(())
707 }
708
709 async fn collect_context(
711 &self,
712 repo_view: &crate::data::RepositoryView,
713 ) -> Result<crate::data::context::CommitContext> {
714 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
715 use crate::data::context::CommitContext;
716
717 let mut context = CommitContext::new();
718
719 let context_dir = self
721 .context_dir
722 .as_ref()
723 .cloned()
724 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
725
726 let repo_root = std::path::PathBuf::from(".");
728 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
729 debug!(context_dir = ?context_dir, "Using context directory");
730 match discovery.discover() {
731 Ok(project_context) => {
732 debug!("Discovery successful");
733
734 self.show_guidance_files_status(&project_context, &context_dir)?;
736
737 context.project = project_context;
738 }
739 Err(e) => {
740 debug!(error = %e, "Discovery failed");
741 context.project = Default::default();
742 }
743 }
744
745 if let Some(branch_info) = &repo_view.branch_info {
747 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
748 } else {
749 use crate::git::GitRepository;
751 let repo = GitRepository::open()?;
752 let current_branch = repo
753 .get_current_branch()
754 .unwrap_or_else(|_| "HEAD".to_string());
755 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
756 }
757
758 if !repo_view.commits.is_empty() {
760 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
761 }
762
763 if let Some(ref work_ctx) = self.work_context {
765 context.user_provided = Some(work_ctx.clone());
766 }
767
768 if let Some(ref branch_ctx) = self.branch_context {
769 context.branch.description = branch_ctx.clone();
770 }
771
772 Ok(context)
773 }
774
775 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
777 use crate::data::context::{VerbosityLevel, WorkPattern};
778
779 println!("๐ Context Analysis:");
780
781 if !context.project.valid_scopes.is_empty() {
783 let scope_names: Vec<&str> = context
784 .project
785 .valid_scopes
786 .iter()
787 .map(|s| s.name.as_str())
788 .collect();
789 println!(" ๐ Valid scopes: {}", scope_names.join(", "));
790 }
791
792 if context.branch.is_feature_branch {
794 println!(
795 " ๐ฟ Branch: {} ({})",
796 context.branch.description, context.branch.work_type
797 );
798 if let Some(ref ticket) = context.branch.ticket_id {
799 println!(" ๐ซ Ticket: {}", ticket);
800 }
801 }
802
803 match context.range.work_pattern {
805 WorkPattern::Sequential => println!(" ๐ Pattern: Sequential development"),
806 WorkPattern::Refactoring => println!(" ๐งน Pattern: Refactoring work"),
807 WorkPattern::BugHunt => println!(" ๐ Pattern: Bug investigation"),
808 WorkPattern::Documentation => println!(" ๐ Pattern: Documentation updates"),
809 WorkPattern::Configuration => println!(" โ๏ธ Pattern: Configuration changes"),
810 WorkPattern::Unknown => {}
811 }
812
813 match context.suggested_verbosity() {
815 VerbosityLevel::Comprehensive => {
816 println!(" ๐ Detail level: Comprehensive (significant changes detected)")
817 }
818 VerbosityLevel::Detailed => println!(" ๐ Detail level: Detailed"),
819 VerbosityLevel::Concise => println!(" ๐ Detail level: Concise"),
820 }
821
822 if let Some(ref user_ctx) = context.user_provided {
824 println!(" ๐ค User context: {}", user_ctx);
825 }
826
827 println!();
828 Ok(())
829 }
830
831 fn show_model_info_from_client(
833 &self,
834 client: &crate::claude::client::ClaudeClient,
835 ) -> Result<()> {
836 use crate::claude::model_config::get_model_registry;
837
838 println!("๐ค AI Model Configuration:");
839
840 let metadata = client.get_ai_client_metadata();
842 let registry = get_model_registry();
843
844 if let Some(spec) = registry.get_model_spec(&metadata.model) {
845 if metadata.model != spec.api_identifier {
847 println!(
848 " ๐ก Model: {} โ \x1b[33m{}\x1b[0m",
849 metadata.model, spec.api_identifier
850 );
851 } else {
852 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", metadata.model);
853 }
854
855 println!(" ๐ท๏ธ Provider: {}", spec.provider);
856 println!(" ๐ Generation: {}", spec.generation);
857 println!(" โญ Tier: {} ({})", spec.tier, {
858 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
859 &tier_info.description
860 } else {
861 "No description available"
862 }
863 });
864 println!(" ๐ค Max output tokens: {}", spec.max_output_tokens);
865 println!(" ๐ฅ Input context: {}", spec.input_context);
866
867 if spec.legacy {
868 println!(" โ ๏ธ Legacy model (consider upgrading to newer version)");
869 }
870 } else {
871 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", metadata.model);
873 println!(" ๐ท๏ธ Provider: {}", metadata.provider);
874 println!(" โ ๏ธ Model not found in registry, using client metadata:");
875 println!(" ๐ค Max output tokens: {}", metadata.max_response_length);
876 println!(" ๐ฅ Input context: {}", metadata.max_context_length);
877 }
878
879 println!();
880 Ok(())
881 }
882
883 fn show_guidance_files_status(
885 &self,
886 project_context: &crate::data::context::ProjectContext,
887 context_dir: &std::path::Path,
888 ) -> Result<()> {
889 println!("๐ Project guidance files status:");
890
891 let guidelines_found = project_context.commit_guidelines.is_some();
893 let guidelines_source = if guidelines_found {
894 let local_path = context_dir.join("local").join("commit-guidelines.md");
895 let project_path = context_dir.join("commit-guidelines.md");
896 let home_path = dirs::home_dir()
897 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
898 .unwrap_or_default();
899
900 if local_path.exists() {
901 format!("โ
Local override: {}", local_path.display())
902 } else if project_path.exists() {
903 format!("โ
Project: {}", project_path.display())
904 } else if home_path.exists() {
905 format!("โ
Global: {}", home_path.display())
906 } else {
907 "โ
(source unknown)".to_string()
908 }
909 } else {
910 "โ None found".to_string()
911 };
912 println!(" ๐ Commit guidelines: {}", guidelines_source);
913
914 let scopes_count = project_context.valid_scopes.len();
916 let scopes_source = if scopes_count > 0 {
917 let local_path = context_dir.join("local").join("scopes.yaml");
918 let project_path = context_dir.join("scopes.yaml");
919 let home_path = dirs::home_dir()
920 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
921 .unwrap_or_default();
922
923 let source = if local_path.exists() {
924 format!("Local override: {}", local_path.display())
925 } else if project_path.exists() {
926 format!("Project: {}", project_path.display())
927 } else if home_path.exists() {
928 format!("Global: {}", home_path.display())
929 } else {
930 "(source unknown + ecosystem defaults)".to_string()
931 };
932 format!("โ
{} ({} scopes)", source, scopes_count)
933 } else {
934 "โ None found".to_string()
935 };
936 println!(" ๐ฏ Valid scopes: {}", scopes_source);
937
938 println!();
939 Ok(())
940 }
941}
942
943impl BranchCommand {
944 pub fn execute(self) -> Result<()> {
946 match self.command {
947 BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
948 BranchSubcommands::Create(create_cmd) => {
949 let rt = tokio::runtime::Runtime::new()
951 .context("Failed to create tokio runtime for PR creation")?;
952 rt.block_on(create_cmd.execute())
953 }
954 }
955 }
956}
957
958impl InfoCommand {
959 pub fn execute(self) -> Result<()> {
961 use crate::data::{
962 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
963 WorkingDirectoryInfo,
964 };
965 use crate::git::{GitRepository, RemoteInfo};
966 use crate::utils::ai_scratch;
967
968 let repo = GitRepository::open()
970 .context("Failed to open git repository. Make sure you're in a git repository.")?;
971
972 let current_branch = repo.get_current_branch().context(
974 "Failed to get current branch. Make sure you're not in detached HEAD state.",
975 )?;
976
977 let base_branch = match self.base_branch {
979 Some(branch) => {
980 if !repo.branch_exists(&branch)? {
982 anyhow::bail!("Base branch '{}' does not exist", branch);
983 }
984 branch
985 }
986 None => {
987 if repo.branch_exists("main")? {
989 "main".to_string()
990 } else if repo.branch_exists("master")? {
991 "master".to_string()
992 } else {
993 anyhow::bail!("No default base branch found (main or master)");
994 }
995 }
996 };
997
998 let commit_range = format!("{}..HEAD", base_branch);
1000
1001 let wd_status = repo.get_working_directory_status()?;
1003 let working_directory = WorkingDirectoryInfo {
1004 clean: wd_status.clean,
1005 untracked_changes: wd_status
1006 .untracked_changes
1007 .into_iter()
1008 .map(|fs| FileStatusInfo {
1009 status: fs.status,
1010 file: fs.file,
1011 })
1012 .collect(),
1013 };
1014
1015 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1017
1018 let commits = repo.get_commits_in_range(&commit_range)?;
1020
1021 let pr_template_result = Self::read_pr_template().ok();
1023 let (pr_template, pr_template_location) = match pr_template_result {
1024 Some((content, location)) => (Some(content), Some(location)),
1025 None => (None, None),
1026 };
1027
1028 let branch_prs = Self::get_branch_prs(¤t_branch)
1030 .ok()
1031 .filter(|prs| !prs.is_empty());
1032
1033 let versions = Some(VersionInfo {
1035 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1036 });
1037
1038 let ai_scratch_path =
1040 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1041 let ai_info = AiInfo {
1042 scratch: ai_scratch_path.to_string_lossy().to_string(),
1043 };
1044
1045 let mut repo_view = RepositoryView {
1047 versions,
1048 explanation: FieldExplanation::default(),
1049 working_directory,
1050 remotes,
1051 ai: ai_info,
1052 branch_info: Some(BranchInfo {
1053 branch: current_branch,
1054 }),
1055 pr_template,
1056 pr_template_location,
1057 branch_prs,
1058 commits,
1059 };
1060
1061 repo_view.update_field_presence();
1063
1064 let yaml_output = crate::data::to_yaml(&repo_view)?;
1066 println!("{}", yaml_output);
1067
1068 Ok(())
1069 }
1070
1071 fn read_pr_template() -> Result<(String, String)> {
1073 use std::fs;
1074 use std::path::Path;
1075
1076 let template_path = Path::new(".github/pull_request_template.md");
1077 if template_path.exists() {
1078 let content = fs::read_to_string(template_path)
1079 .context("Failed to read .github/pull_request_template.md")?;
1080 Ok((content, template_path.to_string_lossy().to_string()))
1081 } else {
1082 anyhow::bail!("PR template file does not exist")
1083 }
1084 }
1085
1086 fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
1088 use serde_json::Value;
1089 use std::process::Command;
1090
1091 let output = Command::new("gh")
1093 .args([
1094 "pr",
1095 "list",
1096 "--head",
1097 branch_name,
1098 "--json",
1099 "number,title,state,url,body",
1100 "--limit",
1101 "50",
1102 ])
1103 .output()
1104 .context("Failed to execute gh command")?;
1105
1106 if !output.status.success() {
1107 anyhow::bail!(
1108 "gh command failed: {}",
1109 String::from_utf8_lossy(&output.stderr)
1110 );
1111 }
1112
1113 let json_str = String::from_utf8_lossy(&output.stdout);
1114 let prs_json: Value =
1115 serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
1116
1117 let mut prs = Vec::new();
1118 if let Some(prs_array) = prs_json.as_array() {
1119 for pr_json in prs_array {
1120 if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
1121 pr_json.get("number").and_then(|n| n.as_u64()),
1122 pr_json.get("title").and_then(|t| t.as_str()),
1123 pr_json.get("state").and_then(|s| s.as_str()),
1124 pr_json.get("url").and_then(|u| u.as_str()),
1125 pr_json.get("body").and_then(|b| b.as_str()),
1126 ) {
1127 prs.push(crate::data::PullRequest {
1128 number,
1129 title: title.to_string(),
1130 state: state.to_string(),
1131 url: url.to_string(),
1132 body: body.to_string(),
1133 });
1134 }
1135 }
1136 }
1137
1138 Ok(prs)
1139 }
1140}
1141
1142#[derive(Debug, PartialEq)]
1144enum PrAction {
1145 CreateNew,
1146 UpdateExisting,
1147 Cancel,
1148}
1149
1150#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
1152pub struct PrContent {
1153 pub title: String,
1155 pub description: String,
1157}
1158
1159impl CreateCommand {
1160 pub async fn execute(self) -> Result<()> {
1162 match self.command {
1163 CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute().await,
1164 }
1165 }
1166}
1167
1168impl CreatePrCommand {
1169 pub async fn execute(self) -> Result<()> {
1171 println!("๐ Starting pull request creation process...");
1172
1173 let repo_view = self.generate_repository_view()?;
1175
1176 self.validate_branch_state(&repo_view)?;
1178
1179 use crate::claude::context::ProjectDiscovery;
1181 let repo_root = std::path::PathBuf::from(".");
1182 let context_dir = std::path::PathBuf::from(".omni-dev");
1183 let discovery = ProjectDiscovery::new(repo_root, context_dir);
1184 let project_context = discovery.discover().unwrap_or_default();
1185 self.show_guidance_files_status(&project_context)?;
1186
1187 let claude_client = crate::claude::create_default_claude_client(None)?;
1189 self.show_model_info_from_client(&claude_client)?;
1190
1191 self.show_commit_range_info(&repo_view)?;
1193
1194 let context = {
1196 use crate::claude::context::{BranchAnalyzer, WorkPatternAnalyzer};
1197 use crate::data::context::CommitContext;
1198 let mut context = CommitContext::new();
1199 context.project = project_context;
1200
1201 if let Some(branch_info) = &repo_view.branch_info {
1203 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
1204 }
1205
1206 if !repo_view.commits.is_empty() {
1207 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
1208 }
1209 context
1210 };
1211 self.show_context_summary(&context)?;
1212
1213 debug!("About to generate PR content from AI");
1215 let (pr_content, _claude_client) = self
1216 .generate_pr_content_with_client_internal(&repo_view, claude_client)
1217 .await?;
1218
1219 self.show_context_information(&repo_view).await?;
1221 debug!(
1222 generated_title = %pr_content.title,
1223 generated_description_length = pr_content.description.len(),
1224 generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1225 "Generated PR content from AI"
1226 );
1227
1228 if let Some(save_path) = self.save_only {
1230 let pr_yaml = crate::data::to_yaml(&pr_content)
1231 .context("Failed to serialize PR content to YAML")?;
1232 std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
1233 println!("๐พ PR details saved to: {}", save_path);
1234 return Ok(());
1235 }
1236
1237 debug!("About to serialize PR content to YAML");
1239 let temp_dir = tempfile::tempdir()?;
1240 let pr_file = temp_dir.path().join("pr-details.yaml");
1241
1242 debug!(
1243 pre_serialize_title = %pr_content.title,
1244 pre_serialize_description_length = pr_content.description.len(),
1245 pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1246 "About to serialize PR content with to_yaml"
1247 );
1248
1249 let pr_yaml =
1250 crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
1251
1252 debug!(
1253 file_path = %pr_file.display(),
1254 yaml_content_length = pr_yaml.len(),
1255 yaml_content = %pr_yaml,
1256 original_title = %pr_content.title,
1257 original_description_length = pr_content.description.len(),
1258 "Writing PR details to temporary YAML file"
1259 );
1260
1261 std::fs::write(&pr_file, &pr_yaml)?;
1262
1263 let pr_action = if self.auto_apply {
1265 if repo_view
1267 .branch_prs
1268 .as_ref()
1269 .is_some_and(|prs| !prs.is_empty())
1270 {
1271 PrAction::UpdateExisting
1272 } else {
1273 PrAction::CreateNew
1274 }
1275 } else {
1276 self.handle_pr_file(&pr_file, &repo_view)?
1277 };
1278
1279 if pr_action == PrAction::Cancel {
1280 println!("โ PR operation cancelled by user");
1281 return Ok(());
1282 }
1283
1284 self.validate_environment()?;
1286
1287 let final_pr_yaml =
1289 std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
1290
1291 debug!(
1292 yaml_length = final_pr_yaml.len(),
1293 yaml_content = %final_pr_yaml,
1294 "Read PR details YAML from file"
1295 );
1296
1297 let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
1298 .context("Failed to parse PR details YAML. Please check the file format.")?;
1299
1300 debug!(
1301 title = %final_pr_content.title,
1302 description_length = final_pr_content.description.len(),
1303 description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1304 "Parsed PR content from YAML"
1305 );
1306
1307 match pr_action {
1308 PrAction::CreateNew => {
1309 self.create_github_pr(
1310 &repo_view,
1311 &final_pr_content.title,
1312 &final_pr_content.description,
1313 )?;
1314 println!("โ
Pull request created successfully!");
1315 }
1316 PrAction::UpdateExisting => {
1317 self.update_github_pr(
1318 &repo_view,
1319 &final_pr_content.title,
1320 &final_pr_content.description,
1321 )?;
1322 println!("โ
Pull request updated successfully!");
1323 }
1324 PrAction::Cancel => unreachable!(), }
1326
1327 Ok(())
1328 }
1329
1330 fn validate_environment(&self) -> Result<()> {
1332 let gh_check = std::process::Command::new("gh")
1334 .args(["--version"])
1335 .output();
1336
1337 match gh_check {
1338 Ok(output) if output.status.success() => {
1339 let repo_check = std::process::Command::new("gh")
1341 .args(["repo", "view", "--json", "name"])
1342 .output();
1343
1344 match repo_check {
1345 Ok(repo_output) if repo_output.status.success() => Ok(()),
1346 Ok(repo_output) => {
1347 let error_details = String::from_utf8_lossy(&repo_output.stderr);
1349 if error_details.contains("authentication") || error_details.contains("login") {
1350 anyhow::bail!("GitHub CLI (gh) authentication failed. Please run 'gh auth login' or check your GITHUB_TOKEN environment variable.")
1351 } else {
1352 anyhow::bail!("GitHub CLI (gh) cannot access this repository. Error: {}", error_details.trim())
1353 }
1354 }
1355 Err(e) => anyhow::bail!("Failed to test GitHub CLI access: {}", e),
1356 }
1357 }
1358 _ => anyhow::bail!("GitHub CLI (gh) is not installed or not available in PATH. Please install it from https://cli.github.com/"),
1359 }
1360 }
1361
1362 fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
1364 use crate::data::{
1365 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1366 WorkingDirectoryInfo,
1367 };
1368 use crate::git::{GitRepository, RemoteInfo};
1369 use crate::utils::ai_scratch;
1370
1371 let repo = GitRepository::open()
1373 .context("Failed to open git repository. Make sure you're in a git repository.")?;
1374
1375 let current_branch = repo.get_current_branch().context(
1377 "Failed to get current branch. Make sure you're not in detached HEAD state.",
1378 )?;
1379
1380 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1382
1383 let primary_remote = remotes
1385 .iter()
1386 .find(|r| r.name == "origin")
1387 .or_else(|| remotes.first())
1388 .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
1389
1390 let base_branch = match self.base_branch.as_ref() {
1392 Some(branch) => {
1393 let remote_branch = if branch.contains('/') {
1395 branch.clone()
1397 } else {
1398 format!("{}/{}", primary_remote.name, branch)
1400 };
1401
1402 let remote_ref = format!("refs/remotes/{}", remote_branch);
1404 if repo.repository().find_reference(&remote_ref).is_err() {
1405 anyhow::bail!("Remote branch '{}' does not exist", remote_branch);
1406 }
1407 remote_branch
1408 }
1409 None => {
1410 let main_branch = &primary_remote.main_branch;
1412 if main_branch == "unknown" {
1413 anyhow::bail!(
1414 "Could not determine main branch for remote '{}'",
1415 primary_remote.name
1416 );
1417 }
1418
1419 let remote_main = format!("{}/{}", primary_remote.name, main_branch);
1420
1421 let remote_ref = format!("refs/remotes/{}", remote_main);
1423 if repo.repository().find_reference(&remote_ref).is_err() {
1424 anyhow::bail!(
1425 "Remote main branch '{}' does not exist. Try running 'git fetch' first.",
1426 remote_main
1427 );
1428 }
1429
1430 remote_main
1431 }
1432 };
1433
1434 let commit_range = format!("{}..HEAD", base_branch);
1436
1437 let wd_status = repo.get_working_directory_status()?;
1439 let working_directory = WorkingDirectoryInfo {
1440 clean: wd_status.clean,
1441 untracked_changes: wd_status
1442 .untracked_changes
1443 .into_iter()
1444 .map(|fs| FileStatusInfo {
1445 status: fs.status,
1446 file: fs.file,
1447 })
1448 .collect(),
1449 };
1450
1451 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1453
1454 let commits = repo.get_commits_in_range(&commit_range)?;
1456
1457 let pr_template_result = InfoCommand::read_pr_template().ok();
1459 let (pr_template, pr_template_location) = match pr_template_result {
1460 Some((content, location)) => (Some(content), Some(location)),
1461 None => (None, None),
1462 };
1463
1464 let branch_prs = InfoCommand::get_branch_prs(¤t_branch)
1466 .ok()
1467 .filter(|prs| !prs.is_empty());
1468
1469 let versions = Some(VersionInfo {
1471 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1472 });
1473
1474 let ai_scratch_path =
1476 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1477 let ai_info = AiInfo {
1478 scratch: ai_scratch_path.to_string_lossy().to_string(),
1479 };
1480
1481 let mut repo_view = RepositoryView {
1483 versions,
1484 explanation: FieldExplanation::default(),
1485 working_directory,
1486 remotes,
1487 ai: ai_info,
1488 branch_info: Some(BranchInfo {
1489 branch: current_branch,
1490 }),
1491 pr_template,
1492 pr_template_location,
1493 branch_prs,
1494 commits,
1495 };
1496
1497 repo_view.update_field_presence();
1499
1500 Ok(repo_view)
1501 }
1502
1503 fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
1505 if !repo_view.working_directory.clean {
1507 anyhow::bail!(
1508 "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
1509 );
1510 }
1511
1512 if !repo_view.working_directory.untracked_changes.is_empty() {
1514 let file_list: Vec<&str> = repo_view
1515 .working_directory
1516 .untracked_changes
1517 .iter()
1518 .map(|f| f.file.as_str())
1519 .collect();
1520 anyhow::bail!(
1521 "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
1522 file_list.join(", ")
1523 );
1524 }
1525
1526 if repo_view.commits.is_empty() {
1528 anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
1529 }
1530
1531 if let Some(existing_prs) = &repo_view.branch_prs {
1533 if !existing_prs.is_empty() {
1534 let pr_info: Vec<String> = existing_prs
1535 .iter()
1536 .map(|pr| format!("#{} ({})", pr.number, pr.state))
1537 .collect();
1538
1539 println!(
1540 "๐ Existing PR(s) found for this branch: {}",
1541 pr_info.join(", ")
1542 );
1543 }
1545 }
1546
1547 Ok(())
1548 }
1549
1550 async fn show_context_information(
1552 &self,
1553 _repo_view: &crate::data::RepositoryView,
1554 ) -> Result<()> {
1555 Ok(())
1560 }
1561
1562 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
1564 let base_branch = match self.base_branch.as_ref() {
1566 Some(branch) => {
1567 if branch.contains('/') {
1569 branch.clone()
1571 } else {
1572 let primary_remote_name = repo_view
1574 .remotes
1575 .iter()
1576 .find(|r| r.name == "origin")
1577 .or_else(|| repo_view.remotes.first())
1578 .map(|r| r.name.as_str())
1579 .unwrap_or("origin");
1580 format!("{}/{}", primary_remote_name, branch)
1581 }
1582 }
1583 None => {
1584 repo_view
1586 .remotes
1587 .iter()
1588 .find(|r| r.name == "origin")
1589 .or_else(|| repo_view.remotes.first())
1590 .map(|r| format!("{}/{}", r.name, r.main_branch))
1591 .unwrap_or_else(|| "unknown".to_string())
1592 }
1593 };
1594
1595 let commit_range = format!("{}..HEAD", base_branch);
1596 let commit_count = repo_view.commits.len();
1597
1598 let current_branch = repo_view
1600 .branch_info
1601 .as_ref()
1602 .map(|bi| bi.branch.as_str())
1603 .unwrap_or("unknown");
1604
1605 println!("๐ Branch Analysis:");
1606 println!(" ๐ฟ Current branch: {}", current_branch);
1607 println!(" ๐ Commit range: {}", commit_range);
1608 println!(" ๐ Commits found: {} commits", commit_count);
1609 println!();
1610
1611 Ok(())
1612 }
1613
1614 async fn collect_context(
1616 &self,
1617 repo_view: &crate::data::RepositoryView,
1618 ) -> Result<crate::data::context::CommitContext> {
1619 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
1620 use crate::data::context::CommitContext;
1621 use crate::git::GitRepository;
1622
1623 let mut context = CommitContext::new();
1624
1625 let context_dir = std::path::PathBuf::from(".omni-dev");
1627
1628 let repo_root = std::path::PathBuf::from(".");
1630 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
1631 match discovery.discover() {
1632 Ok(project_context) => {
1633 context.project = project_context;
1634 }
1635 Err(_e) => {
1636 context.project = Default::default();
1637 }
1638 }
1639
1640 let repo = GitRepository::open()?;
1642 let current_branch = repo
1643 .get_current_branch()
1644 .unwrap_or_else(|_| "HEAD".to_string());
1645 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
1646
1647 if !repo_view.commits.is_empty() {
1649 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
1650 }
1651
1652 Ok(context)
1653 }
1654
1655 fn show_guidance_files_status(
1657 &self,
1658 project_context: &crate::data::context::ProjectContext,
1659 ) -> Result<()> {
1660 let context_dir = std::path::PathBuf::from(".omni-dev");
1661
1662 println!("๐ Project guidance files status:");
1663
1664 let pr_guidelines_found = project_context.pr_guidelines.is_some();
1666 let pr_guidelines_source = if pr_guidelines_found {
1667 let local_path = context_dir.join("local").join("pr-guidelines.md");
1668 let project_path = context_dir.join("pr-guidelines.md");
1669 let home_path = dirs::home_dir()
1670 .map(|h| h.join(".omni-dev").join("pr-guidelines.md"))
1671 .unwrap_or_default();
1672
1673 if local_path.exists() {
1674 format!("โ
Local override: {}", local_path.display())
1675 } else if project_path.exists() {
1676 format!("โ
Project: {}", project_path.display())
1677 } else if home_path.exists() {
1678 format!("โ
Global: {}", home_path.display())
1679 } else {
1680 "โ
(source unknown)".to_string()
1681 }
1682 } else {
1683 "โ None found".to_string()
1684 };
1685 println!(" ๐ PR guidelines: {}", pr_guidelines_source);
1686
1687 let scopes_count = project_context.valid_scopes.len();
1689 let scopes_source = if scopes_count > 0 {
1690 let local_path = context_dir.join("local").join("scopes.yaml");
1691 let project_path = context_dir.join("scopes.yaml");
1692 let home_path = dirs::home_dir()
1693 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1694 .unwrap_or_default();
1695
1696 let source = if local_path.exists() {
1697 format!("Local override: {}", local_path.display())
1698 } else if project_path.exists() {
1699 format!("Project: {}", project_path.display())
1700 } else if home_path.exists() {
1701 format!("Global: {}", home_path.display())
1702 } else {
1703 "(source unknown + ecosystem defaults)".to_string()
1704 };
1705 format!("โ
{} ({} scopes)", source, scopes_count)
1706 } else {
1707 "โ None found".to_string()
1708 };
1709 println!(" ๐ฏ Valid scopes: {}", scopes_source);
1710
1711 let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
1713 let pr_template_status = if pr_template_path.exists() {
1714 format!("โ
Project: {}", pr_template_path.display())
1715 } else {
1716 "โ None found".to_string()
1717 };
1718 println!(" ๐ PR template: {}", pr_template_status);
1719
1720 println!();
1721 Ok(())
1722 }
1723
1724 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
1726 use crate::data::context::{VerbosityLevel, WorkPattern};
1727
1728 println!("๐ Context Analysis:");
1729
1730 if !context.project.valid_scopes.is_empty() {
1732 let scope_names: Vec<&str> = context
1733 .project
1734 .valid_scopes
1735 .iter()
1736 .map(|s| s.name.as_str())
1737 .collect();
1738 println!(" ๐ Valid scopes: {}", scope_names.join(", "));
1739 }
1740
1741 if context.branch.is_feature_branch {
1743 println!(
1744 " ๐ฟ Branch: {} ({})",
1745 context.branch.description, context.branch.work_type
1746 );
1747 if let Some(ref ticket) = context.branch.ticket_id {
1748 println!(" ๐ซ Ticket: {}", ticket);
1749 }
1750 }
1751
1752 match context.range.work_pattern {
1754 WorkPattern::Sequential => println!(" ๐ Pattern: Sequential development"),
1755 WorkPattern::Refactoring => println!(" ๐งน Pattern: Refactoring work"),
1756 WorkPattern::BugHunt => println!(" ๐ Pattern: Bug investigation"),
1757 WorkPattern::Documentation => println!(" ๐ Pattern: Documentation updates"),
1758 WorkPattern::Configuration => println!(" โ๏ธ Pattern: Configuration changes"),
1759 WorkPattern::Unknown => {}
1760 }
1761
1762 match context.suggested_verbosity() {
1764 VerbosityLevel::Comprehensive => {
1765 println!(" ๐ Detail level: Comprehensive (significant changes detected)")
1766 }
1767 VerbosityLevel::Detailed => println!(" ๐ Detail level: Detailed"),
1768 VerbosityLevel::Concise => println!(" ๐ Detail level: Concise"),
1769 }
1770
1771 println!();
1772 Ok(())
1773 }
1774
1775 async fn generate_pr_content_with_client_internal(
1777 &self,
1778 repo_view: &crate::data::RepositoryView,
1779 claude_client: crate::claude::client::ClaudeClient,
1780 ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
1781 use tracing::debug;
1782
1783 let pr_template = match &repo_view.pr_template {
1785 Some(template) => template.clone(),
1786 None => self.get_default_pr_template(),
1787 };
1788
1789 debug!(
1790 pr_template_length = pr_template.len(),
1791 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
1792 "Using PR template for generation"
1793 );
1794
1795 println!("๐ค Generating AI-powered PR description...");
1796
1797 debug!("Collecting context for PR generation");
1799 let context = self.collect_context(repo_view).await?;
1800 debug!("Context collection completed");
1801
1802 debug!("About to call Claude AI for PR content generation");
1804 match claude_client
1805 .generate_pr_content_with_context(repo_view, &pr_template, &context)
1806 .await
1807 {
1808 Ok(pr_content) => {
1809 debug!(
1810 ai_generated_title = %pr_content.title,
1811 ai_generated_description_length = pr_content.description.len(),
1812 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1813 "AI successfully generated PR content"
1814 );
1815 Ok((pr_content, claude_client))
1816 }
1817 Err(e) => {
1818 debug!(error = %e, "AI PR generation failed, falling back to basic description");
1819 let mut description = pr_template;
1821 self.enhance_description_with_commits(&mut description, repo_view)?;
1822
1823 let title = self.generate_title_from_commits(repo_view);
1825
1826 debug!(
1827 fallback_title = %title,
1828 fallback_description_length = description.len(),
1829 "Created fallback PR content"
1830 );
1831
1832 Ok((PrContent { title, description }, claude_client))
1833 }
1834 }
1835 }
1836
1837 fn get_default_pr_template(&self) -> String {
1839 r#"# Pull Request
1840
1841## Description
1842<!-- Provide a brief description of what this PR does -->
1843
1844## Type of Change
1845<!-- Mark the relevant option with an "x" -->
1846- [ ] Bug fix (non-breaking change which fixes an issue)
1847- [ ] New feature (non-breaking change which adds functionality)
1848- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
1849- [ ] Documentation update
1850- [ ] Refactoring (no functional changes)
1851- [ ] Performance improvement
1852- [ ] Test coverage improvement
1853
1854## Changes Made
1855<!-- List the specific changes made in this PR -->
1856-
1857-
1858-
1859
1860## Testing
1861- [ ] All existing tests pass
1862- [ ] New tests added for new functionality
1863- [ ] Manual testing performed
1864
1865## Additional Notes
1866<!-- Add any additional notes for reviewers -->
1867"#.to_string()
1868 }
1869
1870 fn enhance_description_with_commits(
1872 &self,
1873 description: &mut String,
1874 repo_view: &crate::data::RepositoryView,
1875 ) -> Result<()> {
1876 if repo_view.commits.is_empty() {
1877 return Ok(());
1878 }
1879
1880 description.push_str("\n---\n");
1882 description.push_str("## ๐ Commit Summary\n");
1883 description
1884 .push_str("*This section was automatically generated based on commit analysis*\n\n");
1885
1886 let mut types_found = std::collections::HashSet::new();
1888 let mut scopes_found = std::collections::HashSet::new();
1889 let mut has_breaking_changes = false;
1890
1891 for commit in &repo_view.commits {
1892 let detected_type = &commit.analysis.detected_type;
1893 types_found.insert(detected_type.clone());
1894 if detected_type.contains("BREAKING")
1895 || commit.original_message.contains("BREAKING CHANGE")
1896 {
1897 has_breaking_changes = true;
1898 }
1899
1900 let detected_scope = &commit.analysis.detected_scope;
1901 if !detected_scope.is_empty() {
1902 scopes_found.insert(detected_scope.clone());
1903 }
1904 }
1905
1906 if let Some(feat_pos) = description.find("- [ ] New feature") {
1908 if types_found.contains("feat") {
1909 description.replace_range(feat_pos..feat_pos + 5, "- [x]");
1910 }
1911 }
1912 if let Some(fix_pos) = description.find("- [ ] Bug fix") {
1913 if types_found.contains("fix") {
1914 description.replace_range(fix_pos..fix_pos + 5, "- [x]");
1915 }
1916 }
1917 if let Some(docs_pos) = description.find("- [ ] Documentation update") {
1918 if types_found.contains("docs") {
1919 description.replace_range(docs_pos..docs_pos + 5, "- [x]");
1920 }
1921 }
1922 if let Some(refactor_pos) = description.find("- [ ] Refactoring") {
1923 if types_found.contains("refactor") {
1924 description.replace_range(refactor_pos..refactor_pos + 5, "- [x]");
1925 }
1926 }
1927 if let Some(breaking_pos) = description.find("- [ ] Breaking change") {
1928 if has_breaking_changes {
1929 description.replace_range(breaking_pos..breaking_pos + 5, "- [x]");
1930 }
1931 }
1932
1933 if !scopes_found.is_empty() {
1935 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
1936 description.push_str(&format!(
1937 "**Affected areas:** {}\n\n",
1938 scopes_list.join(", ")
1939 ));
1940 }
1941
1942 description.push_str("### Commits in this PR:\n");
1944 for commit in &repo_view.commits {
1945 let short_hash = &commit.hash[..8];
1946 let first_line = commit.original_message.lines().next().unwrap_or("").trim();
1947 description.push_str(&format!("- `{}` {}\n", short_hash, first_line));
1948 }
1949
1950 let total_files: usize = repo_view
1952 .commits
1953 .iter()
1954 .map(|c| c.analysis.file_changes.total_files)
1955 .sum();
1956
1957 if total_files > 0 {
1958 description.push_str(&format!("\n**Files changed:** {} files\n", total_files));
1959 }
1960
1961 Ok(())
1962 }
1963
1964 fn handle_pr_file(
1966 &self,
1967 pr_file: &std::path::Path,
1968 repo_view: &crate::data::RepositoryView,
1969 ) -> Result<PrAction> {
1970 use std::io::{self, Write};
1971
1972 println!("\n๐ PR details generated.");
1973 println!("๐พ Details saved to: {}", pr_file.display());
1974 println!();
1975
1976 let has_existing_prs = repo_view
1978 .branch_prs
1979 .as_ref()
1980 .is_some_and(|prs| !prs.is_empty());
1981
1982 loop {
1983 if has_existing_prs {
1984 print!("โ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
1985 } else {
1986 print!(
1987 "โ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
1988 );
1989 }
1990 io::stdout().flush()?;
1991
1992 let mut input = String::new();
1993 io::stdin().read_line(&mut input)?;
1994
1995 match input.trim().to_lowercase().as_str() {
1996 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
1997 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
1998 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
1999 "s" | "show" => {
2000 self.show_pr_file(pr_file)?;
2001 println!();
2002 }
2003 "e" | "edit" => {
2004 self.edit_pr_file(pr_file)?;
2005 println!();
2006 }
2007 "q" | "quit" => return Ok(PrAction::Cancel),
2008 _ => {
2009 if has_existing_prs {
2010 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
2011 } else {
2012 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
2013 }
2014 }
2015 }
2016 }
2017 }
2018
2019 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2021 use std::fs;
2022
2023 println!("\n๐ PR details file contents:");
2024 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
2025
2026 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
2027 println!("{}", contents);
2028 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
2029
2030 Ok(())
2031 }
2032
2033 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2035 use std::env;
2036 use std::io::{self, Write};
2037 use std::process::Command;
2038
2039 let editor = env::var("OMNI_DEV_EDITOR")
2041 .or_else(|_| env::var("EDITOR"))
2042 .unwrap_or_else(|_| {
2043 println!(
2045 "๐ง Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
2046 );
2047 print!("Please enter the command to use as your editor: ");
2048 io::stdout().flush().expect("Failed to flush stdout");
2049
2050 let mut input = String::new();
2051 io::stdin()
2052 .read_line(&mut input)
2053 .expect("Failed to read user input");
2054 input.trim().to_string()
2055 });
2056
2057 if editor.is_empty() {
2058 println!("โ No editor specified. Returning to menu.");
2059 return Ok(());
2060 }
2061
2062 println!("๐ Opening PR details file in editor: {}", editor);
2063
2064 let mut cmd_parts = editor.split_whitespace();
2066 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
2067 let args: Vec<&str> = cmd_parts.collect();
2068
2069 let mut command = Command::new(editor_cmd);
2070 command.args(args);
2071 command.arg(pr_file.to_string_lossy().as_ref());
2072
2073 match command.status() {
2074 Ok(status) => {
2075 if status.success() {
2076 println!("โ
Editor session completed.");
2077 } else {
2078 println!(
2079 "โ ๏ธ Editor exited with non-zero status: {:?}",
2080 status.code()
2081 );
2082 }
2083 }
2084 Err(e) => {
2085 println!("โ Failed to execute editor '{}': {}", editor, e);
2086 println!(" Please check that the editor command is correct and available in your PATH.");
2087 }
2088 }
2089
2090 Ok(())
2091 }
2092
2093 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
2095 if repo_view.commits.is_empty() {
2096 return "Pull Request".to_string();
2097 }
2098
2099 if repo_view.commits.len() == 1 {
2101 return repo_view.commits[0]
2102 .original_message
2103 .lines()
2104 .next()
2105 .unwrap_or("Pull Request")
2106 .trim()
2107 .to_string();
2108 }
2109
2110 let branch_name = repo_view
2112 .branch_info
2113 .as_ref()
2114 .map(|bi| bi.branch.as_str())
2115 .unwrap_or("feature");
2116
2117 let cleaned_branch = branch_name.replace(['/', '-', '_'], " ");
2118
2119 format!("feat: {}", cleaned_branch)
2120 }
2121
2122 fn create_github_pr(
2124 &self,
2125 repo_view: &crate::data::RepositoryView,
2126 title: &str,
2127 description: &str,
2128 ) -> Result<()> {
2129 use std::process::Command;
2130
2131 let branch_name = repo_view
2133 .branch_info
2134 .as_ref()
2135 .map(|bi| &bi.branch)
2136 .context("Branch info not available")?;
2137
2138 println!("๐ Creating pull request...");
2139 println!(" ๐ Title: {}", title);
2140 println!(" ๐ฟ Branch: {}", branch_name);
2141
2142 debug!("Opening git repository to check branch status");
2144 let git_repo =
2145 crate::git::GitRepository::open().context("Failed to open git repository")?;
2146
2147 debug!(
2148 "Checking if branch '{}' exists on remote 'origin'",
2149 branch_name
2150 );
2151 if !git_repo.branch_exists_on_remote(branch_name, "origin")? {
2152 println!("๐ค Pushing branch to remote...");
2153 debug!(
2154 "Branch '{}' not found on remote, attempting to push",
2155 branch_name
2156 );
2157 git_repo
2158 .push_branch(branch_name, "origin")
2159 .context("Failed to push branch to remote")?;
2160 } else {
2161 debug!("Branch '{}' already exists on remote 'origin'", branch_name);
2162 }
2163
2164 debug!("Creating PR with gh CLI - title: '{}'", title);
2166 debug!("PR description length: {} characters", description.len());
2167
2168 let pr_result = Command::new("gh")
2169 .args(["pr", "create", "--title", title, "--body", description])
2170 .output()
2171 .context("Failed to create pull request")?;
2172
2173 if pr_result.status.success() {
2174 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
2175 let pr_url = pr_url.trim();
2176 debug!("PR created successfully with URL: {}", pr_url);
2177 println!("๐ Pull request created: {}", pr_url);
2178 } else {
2179 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2180 error!("gh CLI failed to create PR: {}", error_msg);
2181 anyhow::bail!("Failed to create pull request: {}", error_msg);
2182 }
2183
2184 Ok(())
2185 }
2186
2187 fn update_github_pr(
2189 &self,
2190 repo_view: &crate::data::RepositoryView,
2191 title: &str,
2192 description: &str,
2193 ) -> Result<()> {
2194 use std::process::Command;
2195
2196 let pr_number = repo_view
2198 .branch_prs
2199 .as_ref()
2200 .and_then(|prs| prs.first())
2201 .map(|pr| pr.number)
2202 .context("No existing PR found to update")?;
2203
2204 println!("๐ Updating pull request #{}...", pr_number);
2205 println!(" ๐ Title: {}", title);
2206
2207 debug!(
2208 pr_number = pr_number,
2209 title = %title,
2210 description_length = description.len(),
2211 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
2212 "Updating GitHub PR with title and description"
2213 );
2214
2215 let gh_args = [
2217 "pr",
2218 "edit",
2219 &pr_number.to_string(),
2220 "--title",
2221 title,
2222 "--body",
2223 description,
2224 ];
2225
2226 debug!(
2227 args = ?gh_args,
2228 "Executing gh command to update PR"
2229 );
2230
2231 let pr_result = Command::new("gh")
2232 .args(gh_args)
2233 .output()
2234 .context("Failed to update pull request")?;
2235
2236 if pr_result.status.success() {
2237 if let Some(existing_pr) = repo_view.branch_prs.as_ref().and_then(|prs| prs.first()) {
2239 println!("๐ Pull request updated: {}", existing_pr.url);
2240 } else {
2241 println!("๐ Pull request #{} updated successfully!", pr_number);
2242 }
2243 } else {
2244 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2245 anyhow::bail!("Failed to update pull request: {}", error_msg);
2246 }
2247
2248 Ok(())
2249 }
2250
2251 fn show_model_info_from_client(
2253 &self,
2254 client: &crate::claude::client::ClaudeClient,
2255 ) -> Result<()> {
2256 use crate::claude::model_config::get_model_registry;
2257
2258 println!("๐ค AI Model Configuration:");
2259
2260 let metadata = client.get_ai_client_metadata();
2262 let registry = get_model_registry();
2263
2264 if let Some(spec) = registry.get_model_spec(&metadata.model) {
2265 if metadata.model != spec.api_identifier {
2267 println!(
2268 " ๐ก Model: {} โ \x1b[33m{}\x1b[0m",
2269 metadata.model, spec.api_identifier
2270 );
2271 } else {
2272 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", metadata.model);
2273 }
2274
2275 println!(" ๐ท๏ธ Provider: {}", spec.provider);
2276 println!(" ๐ Generation: {}", spec.generation);
2277 println!(" โญ Tier: {} ({})", spec.tier, {
2278 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
2279 &tier_info.description
2280 } else {
2281 "No description available"
2282 }
2283 });
2284 println!(" ๐ค Max output tokens: {}", spec.max_output_tokens);
2285 println!(" ๐ฅ Input context: {}", spec.input_context);
2286
2287 if spec.legacy {
2288 println!(" โ ๏ธ Legacy model (consider upgrading to newer version)");
2289 }
2290 } else {
2291 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", metadata.model);
2293 println!(" ๐ท๏ธ Provider: {}", metadata.provider);
2294 println!(" โ ๏ธ Model not found in registry, using client metadata:");
2295 println!(" ๐ค Max output tokens: {}", metadata.max_response_length);
2296 println!(" ๐ฅ Input context: {}", metadata.max_context_length);
2297 }
2298
2299 println!();
2300 Ok(())
2301 }
2302}