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
119pub 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
143pub 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
166async 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
195fn 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
203fn 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 Err(BbError::Config(format!("#{id} left unchanged")))
217 }
218}
219
220fn 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
264async 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 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 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 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 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}