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
118pub async fn request_changes(ctx: &Ctx, id: u64) -> Result<()> {
119    ctx.client
120        .post_empty(&ctx.path(&format!("/pullrequests/{id}/request-changes")))
121        .await?;
122    report(
123        ctx,
124        &format!("changes requested on #{id}"),
125        serde_json::json!({ "requested_changes": id }),
126    )
127}
128
129pub async fn unrequest_changes(ctx: &Ctx, id: u64) -> Result<()> {
130    ctx.client
131        .delete(&ctx.path(&format!("/pullrequests/{id}/request-changes")))
132        .await?;
133    report(
134        ctx,
135        &format!("change request removed from #{id}"),
136        serde_json::json!({ "unrequested_changes": id }),
137    )
138}
139
140pub fn report(ctx: &Ctx, human: &str, json: serde_json::Value) -> Result<()> {
141    match ctx.format {
142        Format::Json => output::print_json(&json),
143        Format::Human => {
144            output::success(human);
145            Ok(())
146        }
147    }
148}
149
150#[derive(Debug, Default)]
151pub struct CreateArgs {
152    pub target: String,
153    pub source: Option<String>,
154    pub title: Option<String>,
155    pub description: Option<String>,
156    pub no_default_reviewers: bool,
157    pub interactive: bool,
158    pub web: bool,
159    pub close_source_branch: bool,
160}
161
162async fn default_reviewers(ctx: &Ctx) -> Result<Vec<ReviewerRef>> {
163    let me: User = ctx.client.get_json("/user").await?;
164    let my_uuid = me.uuid.unwrap_or_default();
165    let reviewers: Vec<User> = ctx.client.paginate(&ctx.path("/default-reviewers")).await?;
166    Ok(reviewers
167        .into_iter()
168        .filter_map(|r| r.uuid)
169        .filter(|uuid| *uuid != my_uuid)
170        .map(|uuid| ReviewerRef { uuid })
171        .collect())
172}
173
174pub async fn create(ctx: &Ctx, args: CreateArgs) -> Result<()> {
175    let source = match args.source {
176        Some(branch) => branch,
177        None => git::current_branch()?,
178    };
179
180    let mut seen = std::collections::HashSet::new();
181    let targets: Vec<String> = args
182        .target
183        .split(',')
184        .map(|s| s.trim().to_string())
185        .filter(|s| !s.is_empty())
186        .filter(|s| seen.insert(s.clone()))
187        .collect();
188    if targets.is_empty() {
189        return Err(BbError::Config("no target branch given".into()));
190    }
191    if targets.contains(&source) {
192        return Err(BbError::Config(format!(
193            "source and target are both `{source}`"
194        )));
195    }
196
197    let mut title = args.title;
198    let mut description = args.description;
199    if args.interactive {
200        if title.is_none() {
201            let entered = inquire::Text::new("title:")
202                .with_help_message("leave empty for the default")
203                .prompt()
204                .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
205            title = Some(entered).filter(|t| !t.trim().is_empty());
206        }
207        if description.is_none() {
208            let entered = inquire::Editor::new("description:")
209                .prompt()
210                .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
211            description = Some(entered).filter(|t| !t.trim().is_empty());
212        }
213    }
214
215    let reviewers = if args.no_default_reviewers {
216        Vec::new()
217    } else {
218        default_reviewers(ctx).await?
219    };
220
221    #[derive(Serialize)]
222    struct Created {
223        id: u64,
224        target: String,
225        url: String,
226    }
227
228    let mut created = Vec::new();
229    for target in targets {
230        // NOTE: `title` and `description` are `Option<String>` owned across loop
231        // iterations, and `serde_json::json!` moves any value given by value. We
232        // borrow `&source`/`&target` and use `.as_deref()` on the options so the
233        // macro only ever sees references, leaving the originals intact for the
234        // next iteration and for the default-title fallback, success line, and
235        // `Created { target, .. }` below (where an owned `target` is genuinely
236        // needed, so it is consumed there instead of inside `json!`).
237        let default_title = format!("Merge {source} into {target}");
238        let body_title = title.as_deref().unwrap_or(&default_title);
239        let mut body = serde_json::json!({
240            "title": body_title,
241            "source": { "branch": { "name": &source } },
242            "destination": { "branch": { "name": &target } },
243            "reviewers": reviewers,
244            "close_source_branch": args.close_source_branch,
245        });
246        if let Some(text) = description.as_deref() {
247            body["description"] = serde_json::Value::String(text.to_string());
248        }
249
250        let spinner = output::spinner(&format!("opening {source} \u{2192} {target}"));
251        let pr: PullRequest = ctx
252            .client
253            .post_json(&ctx.path("/pullrequests"), &body)
254            .await?;
255        spinner.finish_and_clear();
256
257        let url = if pr.html_url() == "-" {
258            format!("{}/pull-requests/{}", ctx.slug.browse_url(), pr.id)
259        } else {
260            pr.html_url().to_string()
261        };
262
263        if !ctx.format.is_json() {
264            output::success(&format!("#{} {source} \u{2192} {target}", pr.id));
265            output::info(&url);
266        }
267        if args.web {
268            let _ = open::that_detached(&url);
269        }
270        created.push(Created {
271            id: pr.id,
272            target,
273            url,
274        });
275    }
276
277    if ctx.format.is_json() {
278        output::print_json(&created)?;
279    }
280    Ok(())
281}