rusty_commit/commands/
pr.rs1use 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#[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 let commits = git::get_commits_between(base, ¤t_branch)?;
73 let diff = git::get_diff_between(base, ¤t_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 out.header("Generated PR Description");
91 out.divider();
92 println!("{}", description);
93 out.divider();
94
95 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 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 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 let remote_url = git::get_remote_url(None)?;
138 let pr_url = convert_to_pr_url(&remote_url, ¤t_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 let url = if remote_url.contains("@") && remote_url.contains(":") {
154 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 remote_url.replace(".git", "") + &format!("/compare/{}...{}?expand=1", base, branch)
172 } else {
173 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 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}