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
33#[derive(Debug, Serialize)]
34struct PrRow {
35 id: u64,
36 title: String,
37 author: String,
38 source: String,
39 destination: String,
40 reviewers: Vec<String>,
41 approvals: Vec<String>,
42 url: String,
43}
44
45fn to_row(pr: &PullRequest) -> PrRow {
46 PrRow {
47 id: pr.id,
48 title: pr.title.clone().unwrap_or_default(),
49 author: pr.author_name().to_string(),
50 source: pr.source_branch().to_string(),
51 destination: pr.destination_branch().to_string(),
52 reviewers: pr
53 .reviewers
54 .iter()
55 .filter_map(|r| r.display_name.clone())
56 .collect(),
57 approvals: pr
58 .participants
59 .iter()
60 .filter(|p| p.state.as_deref() == Some("approved"))
61 .filter_map(|p| p.user.as_ref().and_then(|u| u.display_name.clone()))
62 .collect(),
63 url: pr.html_url().to_string(),
64 }
65}
66
67pub async fn list(ctx: &Ctx, destination: Option<String>, state: String) -> Result<()> {
68 let spinner = output::spinner("fetching pull requests");
69 let prs: Vec<PullRequest> = ctx
70 .client
71 .paginate(&ctx.path(&format!(
72 "/pullrequests?state={}&pagelen=50",
73 urlencoding::encode(&state.to_uppercase())
74 )))
75 .await?;
76 spinner.finish_and_clear();
77
78 let rows: Vec<PrRow> = prs
79 .iter()
80 .filter(|pr| match destination.as_deref() {
81 Some(branch) => pr.destination_branch() == branch,
82 None => true,
83 })
84 .map(to_row)
85 .collect();
86
87 match ctx.format {
88 Format::Json => output::print_json(&rows)?,
89 Format::Human => output::print_table(
90 &[
91 "ID",
92 "TITLE",
93 "SOURCE",
94 "→",
95 "TARGET",
96 "AUTHOR",
97 "REVIEWERS",
98 "APPROVED",
99 ],
100 rows.iter()
101 .map(|r| {
102 vec![
103 r.id.to_string(),
104 r.title.clone(),
105 r.source.clone(),
106 "→".into(),
107 r.destination.clone(),
108 r.author.clone(),
109 r.reviewers.join(", "),
110 r.approvals.join(", "),
111 ]
112 })
113 .collect(),
114 ),
115 }
116
117 Ok(())
118}
119
120pub async fn diff(ctx: &Ctx, id: u64) -> Result<()> {
121 let text = ctx
122 .client
123 .get_text(&ctx.path(&format!("/pullrequests/{id}/diff")))
124 .await?;
125 if ctx.format.is_json() {
126 output::print_json(&serde_json::json!({ "id": id, "diff": text }))?;
127 } else {
128 print!("{text}");
129 }
130 Ok(())
131}
132
133pub async fn files(ctx: &Ctx, id: u64) -> Result<()> {
134 let entries: Vec<DiffStatEntry> = ctx
135 .client
136 .paginate(&ctx.path(&format!("/pullrequests/{id}/diffstat?pagelen=100")))
137 .await?;
138
139 #[derive(Serialize)]
140 struct FileRow {
141 status: String,
142 path: String,
143 }
144
145 let rows: Vec<FileRow> = entries
146 .iter()
147 .map(|e| FileRow {
148 status: e.status.clone().unwrap_or_else(|| "-".into()),
149 path: e.path().to_string(),
150 })
151 .collect();
152
153 match ctx.format {
154 Format::Json => output::print_json(&rows)?,
155 Format::Human => output::print_table(
156 &["STATUS", "PATH"],
157 rows.iter()
158 .map(|r| vec![r.status.clone(), r.path.clone()])
159 .collect(),
160 ),
161 }
162 Ok(())
163}
164
165pub async fn commits(ctx: &Ctx, id: u64) -> Result<()> {
166 let commits: Vec<Commit> = ctx
167 .client
168 .paginate(&ctx.path(&format!("/pullrequests/{id}/commits?pagelen=100")))
169 .await?;
170
171 #[derive(Serialize)]
172 struct CommitRow {
173 hash: String,
174 summary: String,
175 }
176
177 let rows: Vec<CommitRow> = commits
178 .iter()
179 .map(|c| CommitRow {
180 hash: c.hash.clone().unwrap_or_default().chars().take(7).collect(),
181 summary: c
182 .summary
183 .as_ref()
184 .and_then(|s| s.raw.clone())
185 .unwrap_or_default()
186 .lines()
187 .next()
188 .unwrap_or("")
189 .to_string(),
190 })
191 .collect();
192
193 match ctx.format {
194 Format::Json => output::print_json(&rows)?,
195 Format::Human => output::print_table(
196 &["HASH", "SUMMARY"],
197 rows.iter()
198 .map(|r| vec![r.hash.clone(), r.summary.clone()])
199 .collect(),
200 ),
201 }
202 Ok(())
203}
204
205pub async fn request_changes(ctx: &Ctx, id: u64) -> Result<()> {
206 ctx.client
207 .post_empty(&ctx.path(&format!("/pullrequests/{id}/request-changes")))
208 .await?;
209 report(
210 ctx,
211 &format!("changes requested on #{id}"),
212 serde_json::json!({ "requested_changes": id }),
213 )
214}
215
216pub async fn unrequest_changes(ctx: &Ctx, id: u64) -> Result<()> {
217 ctx.client
218 .delete(&ctx.path(&format!("/pullrequests/{id}/request-changes")))
219 .await?;
220 report(
221 ctx,
222 &format!("change request removed from #{id}"),
223 serde_json::json!({ "unrequested_changes": id }),
224 )
225}
226
227fn report(ctx: &Ctx, human: &str, json: serde_json::Value) -> Result<()> {
228 match ctx.format {
229 Format::Json => output::print_json(&json),
230 Format::Human => {
231 output::success(human);
232 Ok(())
233 }
234 }
235}
236
237#[derive(Debug, Default)]
238pub struct CreateArgs {
239 pub target: String,
240 pub source: Option<String>,
241 pub title: Option<String>,
242 pub description: Option<String>,
243 pub no_default_reviewers: bool,
244 pub interactive: bool,
245 pub web: bool,
246 pub close_source_branch: bool,
247}
248
249async fn default_reviewers(ctx: &Ctx) -> Result<Vec<ReviewerRef>> {
250 let me: User = ctx.client.get_json("/user").await?;
251 let my_uuid = me.uuid.unwrap_or_default();
252 let reviewers: Vec<User> = ctx.client.paginate(&ctx.path("/default-reviewers")).await?;
253 Ok(reviewers
254 .into_iter()
255 .filter_map(|r| r.uuid)
256 .filter(|uuid| *uuid != my_uuid)
257 .map(|uuid| ReviewerRef { uuid })
258 .collect())
259}
260
261pub async fn create(ctx: &Ctx, args: CreateArgs) -> Result<()> {
262 let source = match args.source {
263 Some(branch) => branch,
264 None => git::current_branch()?,
265 };
266
267 let mut seen = std::collections::HashSet::new();
268 let targets: Vec<String> = args
269 .target
270 .split(',')
271 .map(|s| s.trim().to_string())
272 .filter(|s| !s.is_empty())
273 .filter(|s| seen.insert(s.clone()))
274 .collect();
275 if targets.is_empty() {
276 return Err(BbError::Config("no target branch given".into()));
277 }
278 if targets.contains(&source) {
279 return Err(BbError::Config(format!(
280 "source and target are both `{source}`"
281 )));
282 }
283
284 let mut title = args.title;
285 let mut description = args.description;
286 if args.interactive {
287 if title.is_none() {
288 let entered = inquire::Text::new("title:")
289 .with_help_message("leave empty for the default")
290 .prompt()
291 .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
292 title = Some(entered).filter(|t| !t.trim().is_empty());
293 }
294 if description.is_none() {
295 let entered = inquire::Editor::new("description:")
296 .prompt()
297 .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
298 description = Some(entered).filter(|t| !t.trim().is_empty());
299 }
300 }
301
302 let reviewers = if args.no_default_reviewers {
303 Vec::new()
304 } else {
305 default_reviewers(ctx).await?
306 };
307
308 #[derive(Serialize)]
309 struct Created {
310 id: u64,
311 target: String,
312 url: String,
313 }
314
315 let mut created = Vec::new();
316 for target in targets {
317 let default_title = format!("Merge {source} into {target}");
325 let body_title = title.as_deref().unwrap_or(&default_title);
326 let mut body = serde_json::json!({
327 "title": body_title,
328 "source": { "branch": { "name": &source } },
329 "destination": { "branch": { "name": &target } },
330 "reviewers": reviewers,
331 "close_source_branch": args.close_source_branch,
332 });
333 if let Some(text) = description.as_deref() {
334 body["description"] = serde_json::Value::String(text.to_string());
335 }
336
337 let spinner = output::spinner(&format!("opening {source} \u{2192} {target}"));
338 let pr: PullRequest = ctx
339 .client
340 .post_json(&ctx.path("/pullrequests"), &body)
341 .await?;
342 spinner.finish_and_clear();
343
344 let url = if pr.html_url() == "-" {
345 format!("{}/pull-requests/{}", ctx.slug.browse_url(), pr.id)
346 } else {
347 pr.html_url().to_string()
348 };
349
350 if !ctx.format.is_json() {
351 output::success(&format!("#{} {source} \u{2192} {target}", pr.id));
352 output::info(&url);
353 }
354 if args.web {
355 let _ = open::that_detached(&url);
356 }
357 created.push(Created {
358 id: pr.id,
359 target,
360 url,
361 });
362 }
363
364 if ctx.format.is_json() {
365 output::print_json(&created)?;
366 }
367 Ok(())
368}