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
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                // Show reviewers/approvals
276                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                // Fetch the branch
410                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                // Checkout the branch
420                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                    // Try creating a tracking branch
429                    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}