Skip to main content

rusty_commit/commands/
pr.rs

1use anyhow::{Context, Result};
2use colored::Colorize;
3use dialoguer::{theme::ColorfulTheme, Select};
4use std::process::Command;
5
6use crate::cli::PrCommand;
7use crate::config::Config;
8use crate::git;
9use crate::output::progress;
10use crate::providers;
11
12/// Unified output helper for PR commands.
13#[allow(dead_code)]
14struct PrOutput;
15
16#[allow(dead_code)]
17impl PrOutput {
18    fn header(&self, text: &str) {
19        println!("\n{}", text.green().bold());
20    }
21
22    fn subheader(&self, text: &str) {
23        println!("{}", text.dimmed());
24    }
25
26    fn success(&self, message: &str) {
27        println!("{}", format!("✓ {}", message).green());
28    }
29
30    fn warning(&self, message: &str) {
31        println!("{}", message.yellow());
32    }
33
34    fn info(&self, message: &str) {
35        println!("{}", message.cyan());
36    }
37
38    fn divider(&self) {
39        println!("{}", "─".repeat(50).dimmed());
40    }
41
42    fn section(&self, title: &str) {
43        self.divider();
44        println!("{}", title.green().bold());
45        self.divider();
46    }
47}
48
49pub async fn execute(cmd: PrCommand) -> Result<()> {
50    let config = Config::load()?;
51    let repo_root = git::get_repo_root()?;
52
53    match cmd.action {
54        crate::cli::PrAction::Generate { base } => {
55            generate_pr_description(&config, base.as_deref()).await
56        }
57        crate::cli::PrAction::Browse { base } => browse_pr_page(&repo_root, base.as_deref()),
58    }
59}
60
61async fn generate_pr_description(config: &Config, base_branch: Option<&str>) -> Result<()> {
62    let out = PrOutput;
63    let current_branch = git::get_current_branch()?;
64    let base = base_branch.unwrap_or("main");
65
66    out.info(&format!(
67        "Generating PR description for branch '{}' against '{}'...",
68        current_branch, base
69    ));
70
71    // Get commits between branches
72    let commits = git::get_commits_between(base, &current_branch)?;
73    let diff = git::get_diff_between(base, &current_branch)?;
74
75    if commits.is_empty() {
76        out.warning("No commits found to generate PR description.");
77        return Ok(());
78    }
79
80    let pb = progress::spinner("Generating PR description...");
81
82    let provider = providers::create_provider(config)?;
83    let description = provider
84        .generate_pr_description(&commits, &diff, config)
85        .await?;
86
87    pb.finish_with_message("PR description generated!");
88
89    // Display the description
90    out.header("Generated PR Description");
91    out.divider();
92    println!("{}", description);
93    out.divider();
94
95    // Copy to clipboard option
96    let choices = vec!["Copy to clipboard", "Show markdown", "Cancel"];
97    let selection = Select::with_theme(&ColorfulTheme::default())
98        .with_prompt("What would you like to do?")
99        .items(&choices)
100        .default(0)
101        .interact()?;
102
103    match selection {
104        0 => {
105            copy_to_clipboard(&description)?;
106            println!("{}", "✅ PR description copied to clipboard!".green());
107        }
108        1 => {
109            // Save to file for preview
110            let preview_file = format!("PR_DESCRIPTION_{}.md", current_branch.replace('/', "_"));
111            std::fs::write(&preview_file, &description)?;
112            println!(
113                "{}",
114                format!("PR description saved to: {}", preview_file).green()
115            );
116
117            // Try to open in editor
118            if let Ok(editor) = std::env::var("EDITOR") {
119                if let Err(e) = Command::new(&editor).arg(&preview_file).status() {
120                    eprintln!("Warning: Failed to open editor '{}': {}", editor, e);
121                }
122            }
123        }
124        _ => {
125            println!("{}", "Cancelled.".yellow());
126        }
127    }
128
129    Ok(())
130}
131
132fn browse_pr_page(_repo_root: &str, base_branch: Option<&str>) -> Result<()> {
133    let current_branch = git::get_current_branch()?;
134    let base = base_branch.unwrap_or("main");
135
136    // Try to get GitHub remote URL
137    let remote_url = git::get_remote_url()?;
138    let pr_url = convert_to_pr_url(&remote_url, &current_branch, base)?;
139
140    println!("{}", format!("Opening PR page: {}", pr_url).green());
141
142    if let Err(e) = webbrowser::open(&pr_url) {
143        eprintln!("Failed to open browser: {}", e);
144        println!("Please open the following URL manually:");
145        println!("{}", pr_url);
146    }
147
148    Ok(())
149}
150
151fn convert_to_pr_url(remote_url: &str, branch: &str, base: &str) -> Result<String> {
152    // Convert SSH URL to HTTPS URL
153    let url = if remote_url.contains("@") && remote_url.contains(":") {
154        // SSH format: git@github.com:owner/repo.git
155        let parts: Vec<&str> = remote_url.splitn(2, ':').collect();
156        if parts.len() == 2 {
157            let host_path = parts[1];
158            let host_parts: Vec<&str> = parts[0].splitn(2, '@').collect();
159            if host_parts.len() == 2 {
160                let host = host_parts[1];
161                let _path = host_path.trim_end_matches(".git");
162                format!("https://{}/compare/{}...{}?expand=1", host, base, branch)
163            } else {
164                remote_url.to_string()
165            }
166        } else {
167            remote_url.to_string()
168        }
169    } else if remote_url.contains("github.com") {
170        // HTTPS format
171        remote_url.replace(".git", "") + &format!("/compare/{}...{}?expand=1", base, branch)
172    } else {
173        // Non-GitHub repo
174        format!("{}/compare/{}...{}", remote_url, base, branch)
175    };
176
177    Ok(url)
178}
179
180fn copy_to_clipboard(text: &str) -> Result<()> {
181    #[cfg(target_os = "macos")]
182    {
183        use std::io::Write;
184        use std::process::{Command, Stdio};
185
186        let mut process = Command::new("pbcopy")
187            .stdin(Stdio::piped())
188            .spawn()
189            .context("Failed to spawn pbcopy process")?;
190
191        {
192            let stdin = process
193                .stdin
194                .as_mut()
195                .context("pbcopy stdin not available")?;
196            stdin.write_all(text.as_bytes())?;
197        }
198
199        let status = process
200            .wait()
201            .context("Failed to wait for pbcopy process")?;
202        if !status.success() {
203            anyhow::bail!("pbcopy exited with error: {:?}", status);
204        }
205    }
206
207    #[cfg(target_os = "linux")]
208    {
209        use std::io::Write;
210        use std::process::{Command, Stdio};
211
212        // Check if xclip is available, otherwise try xsel as fallback
213        let use_xclip = !Command::new("which")
214            .arg("xclip")
215            .output()?
216            .stdout
217            .is_empty();
218
219        let (cmd_name, args) = if use_xclip {
220            ("xclip", vec!["-selection", "clipboard"])
221        } else {
222            ("xsel", vec!["--clipboard", "--input"])
223        };
224
225        let mut process = Command::new(cmd_name)
226            .args(&args)
227            .stdin(Stdio::piped())
228            .spawn()
229            .context(format!("Failed to spawn {} process", cmd_name))?;
230
231        {
232            let stdin = process
233                .stdin
234                .as_mut()
235                .context(format!("{} stdin not available", cmd_name))?;
236            stdin.write_all(text.as_bytes())?;
237        }
238
239        let status = process
240            .wait()
241            .context(format!("Failed to wait for {} process", cmd_name))?;
242        if !status.success() {
243            anyhow::bail!("{} exited with error: {:?}", cmd_name, status);
244        }
245    }
246
247    #[cfg(target_os = "windows")]
248    {
249        let mut ctx = arboard::Clipboard::new()
250            .map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
251        ctx.set_text(text.to_string())
252            .map_err(|e| anyhow::anyhow!("Failed to set clipboard contents: {}", e))?;
253    }
254
255    Ok(())
256}