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, 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
142pub 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
165async 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
194fn 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
202fn 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 Err(BbError::Config(format!("#{id} left unchanged")))
216 }
217}
218
219fn 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 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 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}