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 ListComments {
139 repo: String,
141
142 id: u64,
144
145 #[arg(short, long, default_value = "25")]
147 limit: u32,
148 },
149
150 ViewComment {
152 repo: String,
154
155 #[arg(value_name = "PR_ID")]
157 id: u64,
158
159 comment_id: u64,
161 },
162}
163
164#[derive(ValueEnum, Clone)]
165pub enum PrState {
166 Open,
167 Merged,
168 Declined,
169 Superseded,
170}
171
172impl From<PrState> for PullRequestState {
173 fn from(state: PrState) -> Self {
174 match state {
175 PrState::Open => PullRequestState::Open,
176 PrState::Merged => PullRequestState::Merged,
177 PrState::Declined => PullRequestState::Declined,
178 PrState::Superseded => PullRequestState::Superseded,
179 }
180 }
181}
182
183#[derive(ValueEnum, Clone)]
184pub enum MergeStrategyArg {
185 MergeCommit,
186 Squash,
187 FastForward,
188}
189
190impl From<MergeStrategyArg> for MergeStrategy {
191 fn from(strategy: MergeStrategyArg) -> Self {
192 match strategy {
193 MergeStrategyArg::MergeCommit => MergeStrategy::MergeCommit,
194 MergeStrategyArg::Squash => MergeStrategy::Squash,
195 MergeStrategyArg::FastForward => MergeStrategy::FastForward,
196 }
197 }
198}
199
200#[derive(Tabled)]
201struct PrRow {
202 #[tabled(rename = "ID")]
203 id: u64,
204 #[tabled(rename = "TITLE")]
205 title: String,
206 #[tabled(rename = "AUTHOR")]
207 author: String,
208 #[tabled(rename = "STATE")]
209 state: String,
210 #[tabled(rename = "UPDATED")]
211 updated: String,
212}
213
214#[derive(Tabled)]
215struct CommentRow {
216 #[tabled(rename = "ID")]
217 id: u64,
218 #[tabled(rename = "AUTHOR")]
219 author: String,
220 #[tabled(rename = "CREATED")]
221 created: String,
222 #[tabled(rename = "TYPE")]
223 comment_type: String,
224 #[tabled(rename = "CONTENT")]
225 content: String,
226}
227
228impl PrCommands {
229 pub async fn run(self) -> Result<()> {
230 match self {
231 PrCommands::List { repo, state, limit } => {
232 let (workspace, repo_slug) = parse_repo(&repo)?;
233 let client = BitbucketClient::from_stored().await?;
234
235 let prs = client
236 .list_pull_requests(
237 &workspace,
238 &repo_slug,
239 state.map(|s| s.into()),
240 None,
241 Some(limit),
242 )
243 .await?;
244
245 if prs.values.is_empty() {
246 println!("No pull requests found");
247 return Ok(());
248 }
249
250 let rows: Vec<PrRow> = prs
251 .values
252 .iter()
253 .map(|pr| PrRow {
254 id: pr.id,
255 title: pr.title.chars().take(50).collect(),
256 author: pr.author.display_name.clone(),
257 state: format_state(&pr.state),
258 updated: pr.updated_on.format("%Y-%m-%d").to_string(),
259 })
260 .collect();
261
262 let table = Table::new(rows).to_string();
263 println!("{}", table);
264
265 Ok(())
266 }
267
268 PrCommands::View { repo, id, web } => {
269 let (workspace, repo_slug) = parse_repo(&repo)?;
270 let client = BitbucketClient::from_stored().await?;
271 let pr = client.get_pull_request(&workspace, &repo_slug, id).await?;
272
273 if web {
274 if let Some(links) = &pr.links {
275 if let Some(html) = &links.html {
276 open::that(&html.href)?;
277 println!("Opened {} in browser", html.href.cyan());
278 return Ok(());
279 }
280 }
281 anyhow::bail!("Could not find PR URL");
282 }
283
284 println!("{} {} #{}", format_state(&pr.state), pr.title.bold(), pr.id);
285 println!("{}", "─".repeat(60));
286
287 println!(
288 "{} {} → {}",
289 "Branches:".dimmed(),
290 pr.source.branch.name.cyan(),
291 pr.destination.branch.name.green()
292 );
293 println!("{} {}", "Author:".dimmed(), pr.author.display_name);
294 println!(
295 "{} {}",
296 "Created:".dimmed(),
297 pr.created_on.format("%Y-%m-%d %H:%M")
298 );
299 println!(
300 "{} {}",
301 "Updated:".dimmed(),
302 pr.updated_on.format("%Y-%m-%d %H:%M")
303 );
304
305 if let Some(count) = pr.comment_count {
306 println!("{} {}", "Comments:".dimmed(), count);
307 }
308
309 if let Some(tasks) = pr.task_count {
310 if tasks > 0 {
311 println!("{} {}", "Tasks:".dimmed(), tasks);
312 }
313 }
314
315 if let Some(participants) = &pr.participants {
317 let approvals: Vec<_> = participants
318 .iter()
319 .filter(|p| p.approved)
320 .map(|p| p.user.display_name.clone())
321 .collect();
322
323 if !approvals.is_empty() {
324 println!(
325 "{} {}",
326 "Approved by:".dimmed(),
327 approvals.join(", ").green()
328 );
329 }
330 }
331
332 if let Some(description) = &pr.description {
333 if !description.is_empty() {
334 println!();
335 println!("{}", description);
336 }
337 }
338
339 if let Some(links) = &pr.links {
340 if let Some(html) = &links.html {
341 println!();
342 println!("{} {}", "URL:".dimmed(), html.href.cyan());
343 }
344 }
345
346 Ok(())
347 }
348
349 PrCommands::Create {
350 repo,
351 title,
352 source,
353 destination,
354 body,
355 close_source_branch,
356 } => {
357 let (workspace, repo_slug) = parse_repo(&repo)?;
358 let client = BitbucketClient::from_stored().await?;
359
360 let request = CreatePullRequestRequest {
361 title,
362 source: PullRequestBranchRef {
363 branch: BranchInfo { name: source },
364 },
365 destination: destination.map(|d| PullRequestBranchRef {
366 branch: BranchInfo { name: d },
367 }),
368 description: body,
369 close_source_branch: Some(close_source_branch),
370 reviewers: None,
371 };
372
373 let pr = client
374 .create_pull_request(&workspace, &repo_slug, &request)
375 .await?;
376
377 println!("{} Created pull request #{}", "✓".green(), pr.id);
378
379 if let Some(links) = &pr.links {
380 if let Some(html) = &links.html {
381 println!("{} {}", "URL:".dimmed(), html.href.cyan());
382 }
383 }
384
385 Ok(())
386 }
387
388 PrCommands::Merge {
389 repo,
390 id,
391 strategy,
392 message,
393 close_source_branch,
394 } => {
395 let (workspace, repo_slug) = parse_repo(&repo)?;
396 let client = BitbucketClient::from_stored().await?;
397
398 let request = MergePullRequestRequest {
399 merge_type: Some("pullrequest".to_string()),
400 message,
401 close_source_branch: Some(close_source_branch),
402 merge_strategy: Some(strategy.into()),
403 };
404
405 let pr = client
406 .merge_pull_request(&workspace, &repo_slug, id, Some(&request))
407 .await?;
408
409 println!("{} Merged pull request #{}", "✓".green(), pr.id);
410
411 Ok(())
412 }
413
414 PrCommands::Approve { repo, id } => {
415 let (workspace, repo_slug) = parse_repo(&repo)?;
416 let client = BitbucketClient::from_stored().await?;
417
418 client
419 .approve_pull_request(&workspace, &repo_slug, id)
420 .await?;
421
422 println!("{} Approved pull request #{}", "✓".green(), id);
423
424 Ok(())
425 }
426
427 PrCommands::Decline { repo, id } => {
428 let (workspace, repo_slug) = parse_repo(&repo)?;
429 let client = BitbucketClient::from_stored().await?;
430
431 client
432 .decline_pull_request(&workspace, &repo_slug, id)
433 .await?;
434
435 println!("{} Declined pull request #{}", "✓".green(), id);
436
437 Ok(())
438 }
439
440 PrCommands::Checkout { repo, id } => {
441 let (workspace, repo_slug) = parse_repo(&repo)?;
442 let client = BitbucketClient::from_stored().await?;
443
444 let pr = client.get_pull_request(&workspace, &repo_slug, id).await?;
445 let branch = &pr.source.branch.name;
446
447 println!("Fetching and checking out branch {}...", branch.cyan());
448
449 let status = std::process::Command::new("git")
451 .args(["fetch", "origin", branch])
452 .status()
453 .context("Failed to fetch branch")?;
454
455 if !status.success() {
456 anyhow::bail!("git fetch failed");
457 }
458
459 let status = std::process::Command::new("git")
461 .args(["checkout", branch])
462 .status()
463 .context("Failed to checkout branch")?;
464
465 if status.success() {
466 println!("{} Checked out branch {}", "✓".green(), branch);
467 } else {
468 let status = std::process::Command::new("git")
470 .args(["checkout", "-b", branch, &format!("origin/{}", branch)])
471 .status()
472 .context("Failed to create tracking branch")?;
473
474 if status.success() {
475 println!("{} Created and checked out branch {}", "✓".green(), branch);
476 } else {
477 anyhow::bail!("git checkout failed");
478 }
479 }
480
481 Ok(())
482 }
483
484 PrCommands::Diff { repo, id } => {
485 let (workspace, repo_slug) = parse_repo(&repo)?;
486 let client = BitbucketClient::from_stored().await?;
487
488 let diff = client.get_pr_diff(&workspace, &repo_slug, id).await?;
489 println!("{}", diff);
490
491 Ok(())
492 }
493
494 PrCommands::Comment { repo, id, body } => {
495 let (workspace, repo_slug) = parse_repo(&repo)?;
496 let client = BitbucketClient::from_stored().await?;
497
498 client
499 .add_pr_comment(&workspace, &repo_slug, id, &body)
500 .await?;
501
502 println!("{} Added comment to pull request #{}", "✓".green(), id);
503
504 Ok(())
505 }
506
507 PrCommands::ListComments { repo, id, limit } => {
508 let (workspace, repo_slug) = parse_repo(&repo)?;
509 let client = BitbucketClient::from_stored().await?;
510
511 let comments = client
512 .list_pr_comments(&workspace, &repo_slug, id)
513 .await?;
514
515 let mut values: Vec<_> = comments.values.into_iter().take(limit as usize).collect();
516
517 if values.is_empty() {
518 println!("No comments found");
519 return Ok(());
520 }
521
522 values.sort_by_key(|c| c.created_on);
523
524 let rows: Vec<CommentRow> = values
525 .iter()
526 .map(|c| CommentRow {
527 id: c.id,
528 author: c.user.display_name.clone(),
529 created: c.created_on.format("%Y-%m-%d %H:%M").to_string(),
530 comment_type: if c.inline.is_some() {
531 "inline".to_string()
532 } else {
533 "general".to_string()
534 },
535 content: c.content.raw.chars().take(50).collect(),
536 })
537 .collect();
538
539 let table = Table::new(rows).to_string();
540 println!("{}", table);
541
542 Ok(())
543 }
544
545 PrCommands::ViewComment {
546 repo,
547 id,
548 comment_id,
549 } => {
550 let (workspace, repo_slug) = parse_repo(&repo)?;
551 let client = BitbucketClient::from_stored().await?;
552
553 let comment = client
554 .get_pr_comment(&workspace, &repo_slug, id, comment_id)
555 .await?;
556
557 println!(
558 "{} #{} on PR #{}",
559 "Comment".bold(),
560 comment.id,
561 id
562 );
563 println!("{}", "─".repeat(60));
564
565 println!("{} {}", "Author:".dimmed(), comment.user.display_name);
566 println!(
567 "{} {}",
568 "Created:".dimmed(),
569 comment.created_on.format("%Y-%m-%d %H:%M")
570 );
571
572 if let Some(updated) = comment.updated_on {
573 println!(
574 "{} {}",
575 "Updated:".dimmed(),
576 updated.format("%Y-%m-%d %H:%M")
577 );
578 }
579
580 if let Some(inline) = &comment.inline {
581 let line = inline.to.or(inline.from);
582 let location = match line {
583 Some(l) => format!("{}:{}", inline.path, l),
584 None => inline.path.clone(),
585 };
586 println!("{} {}", "Type:".dimmed(), "inline");
587 println!("{} {}", "File:".dimmed(), location.cyan());
588 } else {
589 println!("{} {}", "Type:".dimmed(), "general");
590 }
591
592 println!();
593 println!("{}", comment.content.raw);
594
595 if let Some(links) = &comment.links {
596 if let Some(html) = &links.html {
597 println!();
598 println!("{} {}", "URL:".dimmed(), html.href.cyan());
599 }
600 }
601
602 Ok(())
603 }
604 }
605 }
606}
607
608fn parse_repo(repo: &str) -> Result<(String, String)> {
609 let parts: Vec<&str> = repo.split('/').collect();
610 if parts.len() != 2 {
611 anyhow::bail!(
612 "Invalid repository format. Expected 'workspace/repo-slug', got '{}'",
613 repo
614 );
615 }
616 Ok((parts[0].to_string(), parts[1].to_string()))
617}
618
619fn format_state(state: &PullRequestState) -> String {
620 match state {
621 PullRequestState::Open => "OPEN".green().to_string(),
622 PullRequestState::Merged => "MERGED".purple().to_string(),
623 PullRequestState::Declined => "DECLINED".red().to_string(),
624 PullRequestState::Superseded => "SUPERSEDED".yellow().to_string(),
625 }
626}