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