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 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}