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 crate::users;
9use serde::Serialize;
10
11pub struct Ctx {
12    pub client: Client,
13    pub slug: RepoSlug,
14    pub format: Format,
15}
16
17impl Ctx {
18    pub fn new(repo: Option<&str>, format: Format) -> Result<Self> {
19        let creds = credentials::load()?;
20        let slug = repo::resolve(repo)?;
21        let client = Client::from_env(creds)?;
22        Ok(Self {
23            client,
24            slug,
25            format,
26        })
27    }
28
29    pub fn path(&self, suffix: &str) -> String {
30        repo_path(&self.slug, suffix)
31    }
32}
33
34pub async fn diff(ctx: &Ctx, id: u64) -> Result<()> {
35    let text = ctx
36        .client
37        .get_text(&ctx.path(&format!("/pullrequests/{id}/diff")))
38        .await?;
39    if ctx.format.is_json() {
40        output::print_json(&serde_json::json!({ "id": id, "diff": text }))?;
41    } else {
42        print!("{text}");
43    }
44    Ok(())
45}
46
47pub async fn files(ctx: &Ctx, id: u64) -> Result<()> {
48    let entries: Vec<DiffStatEntry> = ctx
49        .client
50        .paginate(&ctx.path(&format!("/pullrequests/{id}/diffstat?pagelen=100")))
51        .await?;
52
53    #[derive(Serialize)]
54    struct FileRow {
55        status: String,
56        path: String,
57    }
58
59    let rows: Vec<FileRow> = entries
60        .iter()
61        .map(|e| FileRow {
62            status: e.status.clone().unwrap_or_else(|| "-".into()),
63            path: e.path().to_string(),
64        })
65        .collect();
66
67    match ctx.format {
68        Format::Json => output::print_json(&rows)?,
69        Format::Human => output::print_table(
70            &["STATUS", "PATH"],
71            rows.iter()
72                .map(|r| vec![r.status.clone(), r.path.clone()])
73                .collect(),
74        ),
75    }
76    Ok(())
77}
78
79pub async fn commits(ctx: &Ctx, id: u64) -> Result<()> {
80    let commits: Vec<Commit> = ctx
81        .client
82        .paginate(&ctx.path(&format!("/pullrequests/{id}/commits?pagelen=100")))
83        .await?;
84
85    #[derive(Serialize)]
86    struct CommitRow {
87        hash: String,
88        summary: String,
89    }
90
91    let rows: Vec<CommitRow> = commits
92        .iter()
93        .map(|c| CommitRow {
94            hash: c.hash.clone().unwrap_or_default().chars().take(7).collect(),
95            summary: c
96                .summary
97                .as_ref()
98                .and_then(|s| s.raw.clone())
99                .unwrap_or_default()
100                .lines()
101                .next()
102                .unwrap_or("")
103                .to_string(),
104        })
105        .collect();
106
107    match ctx.format {
108        Format::Json => output::print_json(&rows)?,
109        Format::Human => output::print_table(
110            &["HASH", "SUMMARY"],
111            rows.iter()
112                .map(|r| vec![r.hash.clone(), r.summary.clone()])
113                .collect(),
114        ),
115    }
116    Ok(())
117}
118
119/// Asks the author to change something. Marking a pull request is a claim about
120/// someone else's work that the api cannot tell was warranted, so a human says
121/// yes: the command confirms first, and `--yes` is the only way past it.
122pub async fn request_changes(ctx: &Ctx, id: u64, yes: bool) -> Result<()> {
123    if !yes {
124        gate(
125            ctx,
126            id,
127            "request changes on",
128            "requesting changes",
129            ask_human,
130        )
131        .await?;
132    }
133    ctx.client
134        .post_empty(&ctx.path(&format!("/pullrequests/{id}/request-changes")))
135        .await?;
136    report(
137        ctx,
138        &format!("changes requested on #{id}"),
139        serde_json::json!({ "requested_changes": id }),
140    )
141}
142
143/// Withdraws a change request. Gated for the same reason as its opposite, from
144/// the other side: withdrawing clears a block on a merge.
145pub async fn unrequest_changes(ctx: &Ctx, id: u64, yes: bool) -> Result<()> {
146    if !yes {
147        gate(
148            ctx,
149            id,
150            "withdraw the change request on",
151            "withdrawing a change request",
152            ask_human,
153        )
154        .await?;
155    }
156    ctx.client
157        .delete(&ctx.path(&format!("/pullrequests/{id}/request-changes")))
158        .await?;
159    report(
160        ctx,
161        &format!("change request removed from #{id}"),
162        serde_json::json!({ "unrequested_changes": id }),
163    )
164}
165
166/// Puts the pull request in front of a human and waits for a yes.
167///
168/// With no terminal there is nobody to ask, so this names the flag rather than
169/// blocking on input that will not arrive. That also means an agent or a CI job
170/// cannot mark anything unless whoever wrote the command line said `--yes`.
171///
172/// The pull request is fetched only on this path: `--yes` must cost no extra
173/// request.
174///
175/// `verb` opens the question a human answers; `action` names the same thing as a
176/// noun, for the error a caller with no terminal gets instead. Two forms rather
177/// than one because a verb phrase reads wrong as a sentence's subject, and that
178/// error is the only thing an agent or a CI job ever sees.
179async fn gate<A>(ctx: &Ctx, id: u64, verb: &str, action: &str, ask: A) -> Result<()>
180where
181    A: FnOnce(&str) -> Result<bool>,
182{
183    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
184        return Err(BbError::Config(format!(
185            "{action} on #{id} needs approval — answer the prompt in a terminal, or pass --yes to approve up front"
186        )));
187    }
188    let pr: PullRequest = ctx
189        .client
190        .get_json(&ctx.path(&format!("/pullrequests/{id}")))
191        .await?;
192    decide(id, &prompt_line(verb, &pr), ask)
193}
194
195/// Renders the question. Kept separate so a test can assert what a human is
196/// shown without needing a terminal or a server.
197fn prompt_line(verb: &str, pr: &PullRequest) -> String {
198    let title = pr.title.as_deref().unwrap_or("untitled");
199    let author = pr.author.as_ref().map(|a| a.name()).unwrap_or("someone");
200    format!("{verb} #{} \"{title}\" by {author}?", pr.id)
201}
202
203/// Turns the answer into a verdict. `ask` is a parameter because the real prompt
204/// needs a terminal no test has: this way the part that carries the decision is
205/// exercised, and `ask_human` is left holding nothing but the rendering.
206fn decide<A>(id: u64, question: &str, ask: A) -> Result<()>
207where
208    A: FnOnce(&str) -> Result<bool>,
209{
210    if ask(question)? {
211        Ok(())
212    } else {
213        // Declining is an error, not a quiet success: a script reading exit 0 as
214        // "marked" must never see one. The human just read the question, so the
215        // message states the outcome rather than echoing it back at them.
216        Err(BbError::Config(format!("#{id} left unchanged")))
217    }
218}
219
220/// Left uncovered on purpose: it needs a terminal, and it holds no decision that
221/// a test could get wrong.
222fn ask_human(question: &str) -> Result<bool> {
223    inquire::Confirm::new(question)
224        .with_default(false)
225        .prompt()
226        .map_err(|e| BbError::Config(format!("cancelled: {e}")))
227}
228
229pub fn report(ctx: &Ctx, human: &str, json: serde_json::Value) -> Result<()> {
230    match ctx.format {
231        Format::Json => output::print_json(&json),
232        Format::Human => {
233            output::success(human);
234            Ok(())
235        }
236    }
237}
238
239#[derive(Debug, Default)]
240pub struct CreateArgs {
241    pub target: String,
242    pub source: Option<String>,
243    pub title: Option<String>,
244    pub description: Option<String>,
245    pub no_default_reviewers: bool,
246    pub reviewer: Option<String>,
247    pub interactive: bool,
248    pub web: bool,
249    pub close_source_branch: bool,
250}
251
252async fn default_reviewers(ctx: &Ctx) -> Result<Vec<ReviewerRef>> {
253    let me: User = ctx.client.get_json("/user").await?;
254    let my_uuid = me.uuid.unwrap_or_default();
255    let reviewers: Vec<User> = ctx.client.paginate(&ctx.path("/default-reviewers")).await?;
256    Ok(reviewers
257        .into_iter()
258        .filter_map(|r| r.uuid)
259        .filter(|uuid| *uuid != my_uuid)
260        .map(|uuid| ReviewerRef { uuid })
261        .collect())
262}
263
264/// Resolves an explicit `--reviewer` list to uuids, dropping the author because
265/// bitbucket answers 400 when the author is tagged as a reviewer. Every name is
266/// resolved before the caller opens anything, so one bad name creates no pull
267/// request rather than one with a reviewer set nobody chose.
268async fn named_reviewers(ctx: &Ctx, names: &str) -> Result<Vec<ReviewerRef>> {
269    let requested: Vec<&str> = names
270        .split(',')
271        .map(str::trim)
272        .filter(|s| !s.is_empty())
273        .collect();
274    if requested.is_empty() {
275        return Err(BbError::Config("no reviewer name given".into()));
276    }
277
278    // Names first, so a typo fails before anything else is asked of the api.
279    let mut uuids: Vec<String> = Vec::new();
280    for name in requested {
281        let user = users::resolve_user(&ctx.client, &ctx.slug, name, &[]).await?;
282        let uuid = user
283            .uuid
284            .clone()
285            .ok_or_else(|| BbError::Config(format!("`{}` has no uuid to tag", user.name())))?;
286        if !uuids.contains(&uuid) {
287            uuids.push(uuid);
288        }
289    }
290
291    let me: User = ctx.client.get_json("/user").await?;
292    let my_uuid = me.uuid.unwrap_or_default();
293    uuids.retain(|uuid| *uuid != my_uuid);
294
295    Ok(uuids.into_iter().map(|uuid| ReviewerRef { uuid }).collect())
296}
297
298pub async fn create(ctx: &Ctx, args: CreateArgs) -> Result<()> {
299    let source = match args.source {
300        Some(branch) => branch,
301        None => git::current_branch()?,
302    };
303
304    let mut seen = std::collections::HashSet::new();
305    let targets: Vec<String> = args
306        .target
307        .split(',')
308        .map(|s| s.trim().to_string())
309        .filter(|s| !s.is_empty())
310        .filter(|s| seen.insert(s.clone()))
311        .collect();
312    if targets.is_empty() {
313        return Err(BbError::Config("no target branch given".into()));
314    }
315    if targets.contains(&source) {
316        return Err(BbError::Config(format!(
317            "source and target are both `{source}`"
318        )));
319    }
320
321    let mut title = args.title;
322    let mut description = args.description;
323    if args.interactive {
324        if title.is_none() {
325            let entered = inquire::Text::new("title:")
326                .with_help_message("leave empty for the default")
327                .prompt()
328                .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
329            title = Some(entered).filter(|t| !t.trim().is_empty());
330        }
331        if description.is_none() {
332            let entered = inquire::Editor::new("description:")
333                .prompt()
334                .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
335            description = Some(entered).filter(|t| !t.trim().is_empty());
336        }
337    }
338
339    // An explicit list is the whole list: naming reviewers means the repository's
340    // default set never arrives uninvited, which is the reason to name them.
341    let reviewers = match args.reviewer.as_deref() {
342        Some(names) => named_reviewers(ctx, names).await?,
343        None if args.no_default_reviewers => Vec::new(),
344        None => default_reviewers(ctx).await?,
345    };
346
347    #[derive(Serialize)]
348    struct Created {
349        id: u64,
350        target: String,
351        url: String,
352    }
353
354    let mut created = Vec::new();
355    for target in targets {
356        // NOTE: `title` and `description` are `Option<String>` owned across loop
357        // iterations, and `serde_json::json!` moves any value given by value. We
358        // borrow `&source`/`&target` and use `.as_deref()` on the options so the
359        // macro only ever sees references, leaving the originals intact for the
360        // next iteration and for the default-title fallback, success line, and
361        // `Created { target, .. }` below (where an owned `target` is genuinely
362        // needed, so it is consumed there instead of inside `json!`).
363        let default_title = format!("Merge {source} into {target}");
364        let body_title = title.as_deref().unwrap_or(&default_title);
365        let mut body = serde_json::json!({
366            "title": body_title,
367            "source": { "branch": { "name": &source } },
368            "destination": { "branch": { "name": &target } },
369            "reviewers": reviewers,
370            "close_source_branch": args.close_source_branch,
371        });
372        if let Some(text) = description.as_deref() {
373            body["description"] = serde_json::Value::String(text.to_string());
374        }
375
376        let spinner = output::spinner(&format!("opening {source} \u{2192} {target}"));
377        let pr: PullRequest = ctx
378            .client
379            .post_json(&ctx.path("/pullrequests"), &body)
380            .await?;
381        spinner.finish_and_clear();
382
383        let url = if pr.html_url() == "-" {
384            format!("{}/pull-requests/{}", ctx.slug.browse_url(), pr.id)
385        } else {
386            pr.html_url().to_string()
387        };
388
389        if !ctx.format.is_json() {
390            output::success(&format!("#{} {source} \u{2192} {target}", pr.id));
391            output::info(&url);
392        }
393        if args.web {
394            let _ = open::that_detached(&url);
395        }
396        created.push(Created {
397            id: pr.id,
398            target,
399            url,
400        });
401    }
402
403    if ctx.format.is_json() {
404        output::print_json(&created)?;
405    }
406    Ok(())
407}
408
409#[cfg(test)]
410#[allow(clippy::unwrap_used)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn a_yes_lets_the_write_proceed() {
416        assert!(decide(42, "request changes on #42?", |_| Ok(true)).is_ok());
417    }
418
419    #[test]
420    fn a_no_is_an_error_naming_the_pull_request() {
421        // The question deliberately carries no id, so the assertion below proves
422        // the message is built from the argument rather than echoing the prompt.
423        let err = decide(42, "request changes?", |_| Ok(false)).unwrap_err();
424        assert!(
425            err.to_string().contains("#42"),
426            "the error must name the pull request, got: {err}"
427        );
428    }
429
430    #[test]
431    fn the_prompt_line_carries_title_and_author() {
432        let pr = PullRequest {
433            id: 42,
434            title: Some("fix auth token expiry".into()),
435            state: None,
436            author: Some(User {
437                uuid: None,
438                account_id: None,
439                display_name: Some("Dana".into()),
440                nickname: None,
441            }),
442            source: None,
443            destination: None,
444            links: None,
445            reviewers: Vec::new(),
446            participants: Vec::new(),
447            draft: false,
448            updated_on: None,
449            comment_count: None,
450            description: None,
451            summary: None,
452        };
453        let line = prompt_line("request changes on", &pr);
454        assert!(line.contains("#42"), "got: {line}");
455        assert!(line.contains("fix auth token expiry"), "got: {line}");
456        assert!(line.contains("Dana"), "got: {line}");
457    }
458}