1use anyhow::{Context, Result};
2use clap::{Subcommand, ValueEnum};
3use colored::Colorize;
4use tabled::{Table, Tabled};
5
6use crate::api::BitbucketClient;
7use crate::models::{
8 BranchInfo, CreatePullRequestRequest, MergePullRequestRequest, MergeStrategy,
9 PullRequestBranchRef, PullRequestState,
10};
11
12#[derive(Subcommand)]
13pub enum PrCommands {
14 List {
16 repo: String,
18
19 #[arg(short, long, value_enum)]
21 state: Option<PrState>,
22
23 #[arg(short, long, default_value = "25")]
25 limit: u32,
26 },
27
28 View {
30 repo: String,
32
33 id: u64,
35
36 #[arg(short, long)]
38 web: bool,
39 },
40
41 Create {
43 repo: String,
45
46 #[arg(short, long)]
48 title: String,
49
50 #[arg(short, long)]
52 source: String,
53
54 #[arg(short, long)]
56 destination: Option<String>,
57
58 #[arg(short = 'b', long)]
60 body: Option<String>,
61
62 #[arg(long)]
64 close_source_branch: bool,
65 },
66
67 Merge {
69 repo: String,
71
72 id: u64,
74
75 #[arg(short, long, value_enum, default_value = "merge-commit")]
77 strategy: MergeStrategyArg,
78
79 #[arg(short, long)]
81 message: Option<String>,
82
83 #[arg(long)]
85 close_source_branch: bool,
86 },
87
88 Approve {
90 repo: String,
92
93 id: u64,
95 },
96
97 Decline {
99 repo: String,
101
102 id: u64,
104 },
105
106 Checkout {
108 repo: String,
110
111 id: u64,
113 },
114
115 Diff {
117 repo: String,
119
120 id: u64,
122 },
123
124 Comment {
126 repo: String,
128
129 id: u64,
131
132 #[arg(short, long)]
134 body: String,
135 },
136}
137
138#[derive(ValueEnum, Clone)]
139pub enum PrState {
140 Open,
141 Merged,
142 Declined,
143 Superseded,
144}
145
146impl From<PrState> for PullRequestState {
147 fn from(state: PrState) -> Self {
148 match state {
149 PrState::Open => PullRequestState::Open,
150 PrState::Merged => PullRequestState::Merged,
151 PrState::Declined => PullRequestState::Declined,
152 PrState::Superseded => PullRequestState::Superseded,
153 }
154 }
155}
156
157#[derive(ValueEnum, Clone)]
158pub enum MergeStrategyArg {
159 MergeCommit,
160 Squash,
161 FastForward,
162}
163
164impl From<MergeStrategyArg> for MergeStrategy {
165 fn from(strategy: MergeStrategyArg) -> Self {
166 match strategy {
167 MergeStrategyArg::MergeCommit => MergeStrategy::MergeCommit,
168 MergeStrategyArg::Squash => MergeStrategy::Squash,
169 MergeStrategyArg::FastForward => MergeStrategy::FastForward,
170 }
171 }
172}
173
174#[derive(Tabled)]
175struct PrRow {
176 #[tabled(rename = "ID")]
177 id: u64,
178 #[tabled(rename = "TITLE")]
179 title: String,
180 #[tabled(rename = "AUTHOR")]
181 author: String,
182 #[tabled(rename = "STATE")]
183 state: String,
184 #[tabled(rename = "UPDATED")]
185 updated: String,
186}
187
188impl PrCommands {
189 pub async fn run(self) -> Result<()> {
190 match self {
191 PrCommands::List { repo, state, limit } => {
192 let (workspace, repo_slug) = parse_repo(&repo)?;
193 let client = BitbucketClient::from_stored()?;
194
195 let prs = client
196 .list_pull_requests(
197 &workspace,
198 &repo_slug,
199 state.map(|s| s.into()),
200 None,
201 Some(limit),
202 )
203 .await?;
204
205 if prs.values.is_empty() {
206 println!("No pull requests found");
207 return Ok(());
208 }
209
210 let rows: Vec<PrRow> = prs
211 .values
212 .iter()
213 .map(|pr| PrRow {
214 id: pr.id,
215 title: pr.title.chars().take(50).collect(),
216 author: pr.author.display_name.clone(),
217 state: format_state(&pr.state),
218 updated: pr.updated_on.format("%Y-%m-%d").to_string(),
219 })
220 .collect();
221
222 let table = Table::new(rows).to_string();
223 println!("{}", table);
224
225 Ok(())
226 }
227
228 PrCommands::View { repo, id, web } => {
229 let (workspace, repo_slug) = parse_repo(&repo)?;
230 let client = BitbucketClient::from_stored()?;
231 let pr = client.get_pull_request(&workspace, &repo_slug, id).await?;
232
233 if web {
234 if let Some(links) = &pr.links {
235 if let Some(html) = &links.html {
236 open::that(&html.href)?;
237 println!("Opened {} in browser", html.href.cyan());
238 return Ok(());
239 }
240 }
241 anyhow::bail!("Could not find PR URL");
242 }
243
244 println!("{} {} #{}", format_state(&pr.state), pr.title.bold(), pr.id);
245 println!("{}", "─".repeat(60));
246
247 println!(
248 "{} {} → {}",
249 "Branches:".dimmed(),
250 pr.source.branch.name.cyan(),
251 pr.destination.branch.name.green()
252 );
253 println!("{} {}", "Author:".dimmed(), pr.author.display_name);
254 println!(
255 "{} {}",
256 "Created:".dimmed(),
257 pr.created_on.format("%Y-%m-%d %H:%M")
258 );
259 println!(
260 "{} {}",
261 "Updated:".dimmed(),
262 pr.updated_on.format("%Y-%m-%d %H:%M")
263 );
264
265 if let Some(count) = pr.comment_count {
266 println!("{} {}", "Comments:".dimmed(), count);
267 }
268
269 if let Some(tasks) = pr.task_count {
270 if tasks > 0 {
271 println!("{} {}", "Tasks:".dimmed(), tasks);
272 }
273 }
274
275 if let Some(participants) = &pr.participants {
277 let approvals: Vec<_> = participants
278 .iter()
279 .filter(|p| p.approved)
280 .map(|p| p.user.display_name.clone())
281 .collect();
282
283 if !approvals.is_empty() {
284 println!(
285 "{} {}",
286 "Approved by:".dimmed(),
287 approvals.join(", ").green()
288 );
289 }
290 }
291
292 if let Some(description) = &pr.description {
293 if !description.is_empty() {
294 println!();
295 println!("{}", description);
296 }
297 }
298
299 if let Some(links) = &pr.links {
300 if let Some(html) = &links.html {
301 println!();
302 println!("{} {}", "URL:".dimmed(), html.href.cyan());
303 }
304 }
305
306 Ok(())
307 }
308
309 PrCommands::Create {
310 repo,
311 title,
312 source,
313 destination,
314 body,
315 close_source_branch,
316 } => {
317 let (workspace, repo_slug) = parse_repo(&repo)?;
318 let client = BitbucketClient::from_stored()?;
319
320 let request = CreatePullRequestRequest {
321 title,
322 source: PullRequestBranchRef {
323 branch: BranchInfo { name: source },
324 },
325 destination: destination.map(|d| PullRequestBranchRef {
326 branch: BranchInfo { name: d },
327 }),
328 description: body,
329 close_source_branch: Some(close_source_branch),
330 reviewers: None,
331 };
332
333 let pr = client
334 .create_pull_request(&workspace, &repo_slug, &request)
335 .await?;
336
337 println!("{} Created pull request #{}", "✓".green(), pr.id);
338
339 if let Some(links) = &pr.links {
340 if let Some(html) = &links.html {
341 println!("{} {}", "URL:".dimmed(), html.href.cyan());
342 }
343 }
344
345 Ok(())
346 }
347
348 PrCommands::Merge {
349 repo,
350 id,
351 strategy,
352 message,
353 close_source_branch,
354 } => {
355 let (workspace, repo_slug) = parse_repo(&repo)?;
356 let client = BitbucketClient::from_stored()?;
357
358 let request = MergePullRequestRequest {
359 merge_type: Some("pullrequest".to_string()),
360 message,
361 close_source_branch: Some(close_source_branch),
362 merge_strategy: Some(strategy.into()),
363 };
364
365 let pr = client
366 .merge_pull_request(&workspace, &repo_slug, id, Some(&request))
367 .await?;
368
369 println!("{} Merged pull request #{}", "✓".green(), pr.id);
370
371 Ok(())
372 }
373
374 PrCommands::Approve { repo, id } => {
375 let (workspace, repo_slug) = parse_repo(&repo)?;
376 let client = BitbucketClient::from_stored()?;
377
378 client
379 .approve_pull_request(&workspace, &repo_slug, id)
380 .await?;
381
382 println!("{} Approved pull request #{}", "✓".green(), id);
383
384 Ok(())
385 }
386
387 PrCommands::Decline { repo, id } => {
388 let (workspace, repo_slug) = parse_repo(&repo)?;
389 let client = BitbucketClient::from_stored()?;
390
391 client
392 .decline_pull_request(&workspace, &repo_slug, id)
393 .await?;
394
395 println!("{} Declined pull request #{}", "✓".green(), id);
396
397 Ok(())
398 }
399
400 PrCommands::Checkout { repo, id } => {
401 let (workspace, repo_slug) = parse_repo(&repo)?;
402 let client = BitbucketClient::from_stored()?;
403
404 let pr = client.get_pull_request(&workspace, &repo_slug, id).await?;
405 let branch = &pr.source.branch.name;
406
407 println!("Fetching and checking out branch {}...", branch.cyan());
408
409 let status = std::process::Command::new("git")
411 .args(["fetch", "origin", branch])
412 .status()
413 .context("Failed to fetch branch")?;
414
415 if !status.success() {
416 anyhow::bail!("git fetch failed");
417 }
418
419 let status = std::process::Command::new("git")
421 .args(["checkout", branch])
422 .status()
423 .context("Failed to checkout branch")?;
424
425 if status.success() {
426 println!("{} Checked out branch {}", "✓".green(), branch);
427 } else {
428 let status = std::process::Command::new("git")
430 .args(["checkout", "-b", branch, &format!("origin/{}", branch)])
431 .status()
432 .context("Failed to create tracking branch")?;
433
434 if status.success() {
435 println!("{} Created and checked out branch {}", "✓".green(), branch);
436 } else {
437 anyhow::bail!("git checkout failed");
438 }
439 }
440
441 Ok(())
442 }
443
444 PrCommands::Diff { repo, id } => {
445 let (workspace, repo_slug) = parse_repo(&repo)?;
446 let client = BitbucketClient::from_stored()?;
447
448 let diff = client.get_pr_diff(&workspace, &repo_slug, id).await?;
449 println!("{}", diff);
450
451 Ok(())
452 }
453
454 PrCommands::Comment { repo, id, body } => {
455 let (workspace, repo_slug) = parse_repo(&repo)?;
456 let client = BitbucketClient::from_stored()?;
457
458 client
459 .add_pr_comment(&workspace, &repo_slug, id, &body)
460 .await?;
461
462 println!("{} Added comment to pull request #{}", "✓".green(), id);
463
464 Ok(())
465 }
466 }
467 }
468}
469
470fn parse_repo(repo: &str) -> Result<(String, String)> {
471 let parts: Vec<&str> = repo.split('/').collect();
472 if parts.len() != 2 {
473 anyhow::bail!(
474 "Invalid repository format. Expected 'workspace/repo-slug', got '{}'",
475 repo
476 );
477 }
478 Ok((parts[0].to_string(), parts[1].to_string()))
479}
480
481fn format_state(state: &PullRequestState) -> String {
482 match state {
483 PullRequestState::Open => "OPEN".green().to_string(),
484 PullRequestState::Merged => "MERGED".purple().to_string(),
485 PullRequestState::Declined => "DECLINED".red().to_string(),
486 PullRequestState::Superseded => "SUPERSEDED".yellow().to_string(),
487 }
488}