Skip to main content

bb_cli/commands/
pr.rs

1use crate::api::models::{Commit, DiffStatEntry, PullRequest, ReviewerRef, User};
2use crate::api::{repo_path, Client};
3use crate::credentials;
4use crate::error::{BbError, Result};
5use crate::git;
6use crate::output::{self, Format};
7use crate::repo::{self, RepoSlug};
8use serde::Serialize;
9
10pub struct Ctx {
11    pub client: Client,
12    pub slug: RepoSlug,
13    pub format: Format,
14}
15
16impl Ctx {
17    pub fn new(repo: Option<&str>, format: Format) -> Result<Self> {
18        let creds = credentials::load()?;
19        let slug = repo::resolve(repo)?;
20        let client = Client::from_env(creds)?;
21        Ok(Self {
22            client,
23            slug,
24            format,
25        })
26    }
27
28    pub fn path(&self, suffix: &str) -> String {
29        repo_path(&self.slug, suffix)
30    }
31}
32
33#[derive(Debug, Serialize)]
34struct PrRow {
35    id: u64,
36    title: String,
37    author: String,
38    source: String,
39    destination: String,
40    reviewers: Vec<String>,
41    approvals: Vec<String>,
42    url: String,
43}
44
45fn to_row(pr: &PullRequest) -> PrRow {
46    PrRow {
47        id: pr.id,
48        title: pr.title.clone().unwrap_or_default(),
49        author: pr.author_name().to_string(),
50        source: pr.source_branch().to_string(),
51        destination: pr.destination_branch().to_string(),
52        reviewers: pr
53            .reviewers
54            .iter()
55            .filter_map(|r| r.display_name.clone())
56            .collect(),
57        approvals: pr
58            .participants
59            .iter()
60            .filter(|p| p.state.as_deref() == Some("approved"))
61            .filter_map(|p| p.user.as_ref().and_then(|u| u.display_name.clone()))
62            .collect(),
63        url: pr.html_url().to_string(),
64    }
65}
66
67pub async fn list(ctx: &Ctx, destination: Option<String>, state: String) -> Result<()> {
68    let spinner = output::spinner("fetching pull requests");
69    let prs: Vec<PullRequest> = ctx
70        .client
71        .paginate(&ctx.path(&format!(
72            "/pullrequests?state={}&pagelen=50",
73            urlencoding::encode(&state.to_uppercase())
74        )))
75        .await?;
76    spinner.finish_and_clear();
77
78    let rows: Vec<PrRow> = prs
79        .iter()
80        .filter(|pr| match destination.as_deref() {
81            Some(branch) => pr.destination_branch() == branch,
82            None => true,
83        })
84        .map(to_row)
85        .collect();
86
87    match ctx.format {
88        Format::Json => output::print_json(&rows)?,
89        Format::Human => output::print_table(
90            &[
91                "ID",
92                "TITLE",
93                "SOURCE",
94                "→",
95                "TARGET",
96                "AUTHOR",
97                "REVIEWERS",
98                "APPROVED",
99            ],
100            rows.iter()
101                .map(|r| {
102                    vec![
103                        r.id.to_string(),
104                        r.title.clone(),
105                        r.source.clone(),
106                        "→".into(),
107                        r.destination.clone(),
108                        r.author.clone(),
109                        r.reviewers.join(", "),
110                        r.approvals.join(", "),
111                    ]
112                })
113                .collect(),
114        ),
115    }
116
117    Ok(())
118}
119
120pub async fn diff(ctx: &Ctx, id: u64) -> Result<()> {
121    let text = ctx
122        .client
123        .get_text(&ctx.path(&format!("/pullrequests/{id}/diff")))
124        .await?;
125    if ctx.format.is_json() {
126        output::print_json(&serde_json::json!({ "id": id, "diff": text }))?;
127    } else {
128        print!("{text}");
129    }
130    Ok(())
131}
132
133pub async fn files(ctx: &Ctx, id: u64) -> Result<()> {
134    let entries: Vec<DiffStatEntry> = ctx
135        .client
136        .paginate(&ctx.path(&format!("/pullrequests/{id}/diffstat?pagelen=100")))
137        .await?;
138
139    #[derive(Serialize)]
140    struct FileRow {
141        status: String,
142        path: String,
143    }
144
145    let rows: Vec<FileRow> = entries
146        .iter()
147        .map(|e| FileRow {
148            status: e.status.clone().unwrap_or_else(|| "-".into()),
149            path: e.path().to_string(),
150        })
151        .collect();
152
153    match ctx.format {
154        Format::Json => output::print_json(&rows)?,
155        Format::Human => output::print_table(
156            &["STATUS", "PATH"],
157            rows.iter()
158                .map(|r| vec![r.status.clone(), r.path.clone()])
159                .collect(),
160        ),
161    }
162    Ok(())
163}
164
165pub async fn commits(ctx: &Ctx, id: u64) -> Result<()> {
166    let commits: Vec<Commit> = ctx
167        .client
168        .paginate(&ctx.path(&format!("/pullrequests/{id}/commits?pagelen=100")))
169        .await?;
170
171    #[derive(Serialize)]
172    struct CommitRow {
173        hash: String,
174        summary: String,
175    }
176
177    let rows: Vec<CommitRow> = commits
178        .iter()
179        .map(|c| CommitRow {
180            hash: c.hash.clone().unwrap_or_default().chars().take(7).collect(),
181            summary: c
182                .summary
183                .as_ref()
184                .and_then(|s| s.raw.clone())
185                .unwrap_or_default()
186                .lines()
187                .next()
188                .unwrap_or("")
189                .to_string(),
190        })
191        .collect();
192
193    match ctx.format {
194        Format::Json => output::print_json(&rows)?,
195        Format::Human => output::print_table(
196            &["HASH", "SUMMARY"],
197            rows.iter()
198                .map(|r| vec![r.hash.clone(), r.summary.clone()])
199                .collect(),
200        ),
201    }
202    Ok(())
203}
204
205pub async fn request_changes(ctx: &Ctx, id: u64) -> Result<()> {
206    ctx.client
207        .post_empty(&ctx.path(&format!("/pullrequests/{id}/request-changes")))
208        .await?;
209    report(
210        ctx,
211        &format!("changes requested on #{id}"),
212        serde_json::json!({ "requested_changes": id }),
213    )
214}
215
216pub async fn unrequest_changes(ctx: &Ctx, id: u64) -> Result<()> {
217    ctx.client
218        .delete(&ctx.path(&format!("/pullrequests/{id}/request-changes")))
219        .await?;
220    report(
221        ctx,
222        &format!("change request removed from #{id}"),
223        serde_json::json!({ "unrequested_changes": id }),
224    )
225}
226
227fn report(ctx: &Ctx, human: &str, json: serde_json::Value) -> Result<()> {
228    match ctx.format {
229        Format::Json => output::print_json(&json),
230        Format::Human => {
231            output::success(human);
232            Ok(())
233        }
234    }
235}
236
237#[derive(Debug, Default)]
238pub struct CreateArgs {
239    pub target: String,
240    pub source: Option<String>,
241    pub title: Option<String>,
242    pub description: Option<String>,
243    pub no_default_reviewers: bool,
244    pub interactive: bool,
245    pub web: bool,
246    pub close_source_branch: bool,
247}
248
249async fn default_reviewers(ctx: &Ctx) -> Result<Vec<ReviewerRef>> {
250    let me: User = ctx.client.get_json("/user").await?;
251    let my_uuid = me.uuid.unwrap_or_default();
252    let reviewers: Vec<User> = ctx.client.paginate(&ctx.path("/default-reviewers")).await?;
253    Ok(reviewers
254        .into_iter()
255        .filter_map(|r| r.uuid)
256        .filter(|uuid| *uuid != my_uuid)
257        .map(|uuid| ReviewerRef { uuid })
258        .collect())
259}
260
261pub async fn create(ctx: &Ctx, args: CreateArgs) -> Result<()> {
262    let source = match args.source {
263        Some(branch) => branch,
264        None => git::current_branch()?,
265    };
266
267    let mut seen = std::collections::HashSet::new();
268    let targets: Vec<String> = args
269        .target
270        .split(',')
271        .map(|s| s.trim().to_string())
272        .filter(|s| !s.is_empty())
273        .filter(|s| seen.insert(s.clone()))
274        .collect();
275    if targets.is_empty() {
276        return Err(BbError::Config("no target branch given".into()));
277    }
278    if targets.contains(&source) {
279        return Err(BbError::Config(format!(
280            "source and target are both `{source}`"
281        )));
282    }
283
284    let mut title = args.title;
285    let mut description = args.description;
286    if args.interactive {
287        if title.is_none() {
288            let entered = inquire::Text::new("title:")
289                .with_help_message("leave empty for the default")
290                .prompt()
291                .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
292            title = Some(entered).filter(|t| !t.trim().is_empty());
293        }
294        if description.is_none() {
295            let entered = inquire::Editor::new("description:")
296                .prompt()
297                .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
298            description = Some(entered).filter(|t| !t.trim().is_empty());
299        }
300    }
301
302    let reviewers = if args.no_default_reviewers {
303        Vec::new()
304    } else {
305        default_reviewers(ctx).await?
306    };
307
308    #[derive(Serialize)]
309    struct Created {
310        id: u64,
311        target: String,
312        url: String,
313    }
314
315    let mut created = Vec::new();
316    for target in targets {
317        // NOTE: `title` and `description` are `Option<String>` owned across loop
318        // iterations, and `serde_json::json!` moves any value given by value. We
319        // borrow `&source`/`&target` and use `.as_deref()` on the options so the
320        // macro only ever sees references, leaving the originals intact for the
321        // next iteration and for the default-title fallback, success line, and
322        // `Created { target, .. }` below (where an owned `target` is genuinely
323        // needed, so it is consumed there instead of inside `json!`).
324        let default_title = format!("Merge {source} into {target}");
325        let body_title = title.as_deref().unwrap_or(&default_title);
326        let mut body = serde_json::json!({
327            "title": body_title,
328            "source": { "branch": { "name": &source } },
329            "destination": { "branch": { "name": &target } },
330            "reviewers": reviewers,
331            "close_source_branch": args.close_source_branch,
332        });
333        if let Some(text) = description.as_deref() {
334            body["description"] = serde_json::Value::String(text.to_string());
335        }
336
337        let spinner = output::spinner(&format!("opening {source} \u{2192} {target}"));
338        let pr: PullRequest = ctx
339            .client
340            .post_json(&ctx.path("/pullrequests"), &body)
341            .await?;
342        spinner.finish_and_clear();
343
344        let url = if pr.html_url() == "-" {
345            format!("{}/pull-requests/{}", ctx.slug.browse_url(), pr.id)
346        } else {
347            pr.html_url().to_string()
348        };
349
350        if !ctx.format.is_json() {
351            output::success(&format!("#{} {source} \u{2192} {target}", pr.id));
352            output::info(&url);
353        }
354        if args.web {
355            let _ = open::that_detached(&url);
356        }
357        created.push(Created {
358            id: pr.id,
359            target,
360            url,
361        });
362    }
363
364    if ctx.format.is_json() {
365        output::print_json(&created)?;
366    }
367    Ok(())
368}