Skip to main content

bitbucket_cli/cli/
pr.rs

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 pull requests
15    List {
16        /// Repository in format workspace/repo-slug
17        repo: String,
18
19        /// Filter by state
20        #[arg(short, long, value_enum)]
21        state: Option<PrState>,
22
23        /// Number of results
24        #[arg(short, long, default_value = "25")]
25        limit: u32,
26    },
27
28    /// View pull request details
29    View {
30        /// Repository in format workspace/repo-slug
31        repo: String,
32
33        /// Pull request ID
34        id: u64,
35
36        /// Open in browser
37        #[arg(short, long)]
38        web: bool,
39    },
40
41    /// Create a new pull request
42    Create {
43        /// Repository in format workspace/repo-slug
44        repo: String,
45
46        /// Title of the pull request
47        #[arg(short, long)]
48        title: String,
49
50        /// Source branch
51        #[arg(short, long)]
52        source: String,
53
54        /// Destination branch (defaults to main branch)
55        #[arg(short, long)]
56        destination: Option<String>,
57
58        /// Description of the pull request
59        #[arg(short = 'b', long)]
60        body: Option<String>,
61
62        /// Close source branch after merge
63        #[arg(long)]
64        close_source_branch: bool,
65    },
66
67    /// Merge a pull request
68    Merge {
69        /// Repository in format workspace/repo-slug
70        repo: String,
71
72        /// Pull request ID
73        id: u64,
74
75        /// Merge strategy
76        #[arg(short, long, value_enum, default_value = "merge-commit")]
77        strategy: MergeStrategyArg,
78
79        /// Commit message
80        #[arg(short, long)]
81        message: Option<String>,
82
83        /// Close source branch
84        #[arg(long)]
85        close_source_branch: bool,
86    },
87
88    /// Approve a pull request
89    Approve {
90        /// Repository in format workspace/repo-slug
91        repo: String,
92
93        /// Pull request ID
94        id: u64,
95    },
96
97    /// Decline a pull request
98    Decline {
99        /// Repository in format workspace/repo-slug
100        repo: String,
101
102        /// Pull request ID
103        id: u64,
104    },
105
106    /// Checkout a pull request branch locally
107    Checkout {
108        /// Repository in format workspace/repo-slug
109        repo: String,
110
111        /// Pull request ID
112        id: u64,
113    },
114
115    /// View pull request diff
116    Diff {
117        /// Repository in format workspace/repo-slug
118        repo: String,
119
120        /// Pull request ID
121        id: u64,
122    },
123
124    /// Add a comment to a pull request
125    Comment {
126        /// Repository in format workspace/repo-slug
127        repo: String,
128
129        /// Pull request ID
130        id: u64,
131
132        /// Comment text
133        #[arg(short, long)]
134        body: String,
135    },
136
137    /// List comments on a pull request
138    ListComments {
139        /// Repository in format workspace/repo-slug
140        repo: String,
141
142        /// Pull request ID
143        id: u64,
144
145        /// Number of results
146        #[arg(short, long, default_value = "25")]
147        limit: u32,
148    },
149
150    /// View a specific comment on a pull request
151    ViewComment {
152        /// Repository in format workspace/repo-slug
153        repo: String,
154
155        /// Pull request ID
156        #[arg(value_name = "PR_ID")]
157        id: u64,
158
159        /// Comment ID
160        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                // Show reviewers/approvals
316                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                // Fetch the branch
450                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                // Checkout the branch
460                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                    // Try creating a tracking branch
469                    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}