codeberg_cli/actions/pull_request/
create.rs1use crate::actions::GlobalArgs;
2use crate::actions::text_manipulation::{input_prompt_for, select_prompt_for};
3use crate::render::json::JsonToStdout;
4use crate::render::option::option_display;
5use crate::render::ui::{
6 fuzzy_select_with_key, fuzzy_select_with_key_with_default, multi_fuzzy_select_with_key,
7};
8use crate::types::context::BergContext;
9use crate::types::git::OwnerRepo;
10use crate::types::output::OutputMode;
11use clap::Parser;
12use forgejo_api::structs::{
13 CreatePullRequestOption, IssueGetMilestonesListQuery, IssueListLabelsQuery,
14};
15use miette::{Context, IntoDiagnostic};
16use strum::{Display, VariantArray};
17
18#[derive(Parser, Debug)]
20pub struct CreatePullRequestArgs {
21 #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
23 pub assignees: Option<Vec<String>>,
24
25 #[arg(short, long)]
27 pub target_branch: Option<String>,
28
29 #[arg(id = "description", short, long)]
31 pub body: Option<String>,
32
33 #[arg(short, long)]
35 pub source_branch: Option<String>,
36
37 #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
39 pub labels: Option<Vec<String>>,
40
41 #[arg(long)]
43 pub title: Option<String>,
44
45 #[arg(short, long)]
47 pub milestone: Option<String>,
48
49 #[arg(short, long)]
51 pub interactive: bool,
52}
53
54#[derive(Display, PartialEq, Eq, VariantArray)]
55enum CreatableFields {
56 Assignees,
57 Labels,
58 Milestone,
59}
60
61impl CreatePullRequestArgs {
62 pub async fn run(self, global_args: GlobalArgs) -> miette::Result<()> {
63 let ctx = BergContext::new(self, global_args).await?;
64
65 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
66 let options = create_options(&ctx).await?;
67 let pull_request = ctx
68 .client
69 .repo_create_pull_request(owner.as_str(), repo.as_str(), options)
70 .await
71 .into_diagnostic()?;
72
73 match ctx.global_args.output_mode {
74 OutputMode::Pretty => {
75 println!(
76 "Successfully created pull request!{opt_url}",
77 opt_url = pull_request
78 .url
79 .map(|url| format!("\n\n\tSee: {url}"))
80 .unwrap_or_default()
81 );
82 }
83 OutputMode::Json => pull_request.print_json()?,
84 }
85
86 Ok(())
87 }
88}
89
90async fn create_options(
91 ctx: &BergContext<CreatePullRequestArgs>,
92) -> miette::Result<CreatePullRequestOption> {
93 let OwnerRepo { owner, repo } = ctx.owner_repo()?;
94 let title = match ctx.args.title.as_ref() {
95 Some(title) => title.clone(),
96 None => {
97 if ctx.global_args.non_interactive {
98 miette::bail!(
99 "You have to specify the pull request title in non-interactive mode!"
100 );
101 }
102 inquire::Text::new(input_prompt_for("Pull Request Title").as_str())
103 .prompt()
104 .into_diagnostic()?
105 }
106 };
107
108 let target_branch = match ctx.args.target_branch.as_ref() {
109 Some(branch) => branch.clone(),
110 None => {
111 if ctx.global_args.non_interactive {
112 miette::bail!(
113 "You have to specify the pull request target branch in non-interactive mode!"
114 );
115 }
116 select_branch(
117 ctx,
118 "target branch into which changes are merged",
119 vec!["main", "master"],
120 None,
121 )
122 .await?
123 }
124 };
125
126 let source_branch = match ctx.args.source_branch.as_ref() {
127 Some(branch) => branch.clone(),
128 None => {
129 let current_checkout = get_current_checkout()?;
130 if ctx.global_args.non_interactive {
131 current_checkout
132 } else {
133 select_branch(
134 ctx,
135 "source branch containing the changes",
136 vec![current_checkout.as_str()],
137 Some(target_branch.as_str()),
138 )
139 .await?
140 }
141 }
142 };
143
144 let labels = if let Some(requested_labels) = ctx.args.labels.as_ref() {
145 let (_, issue_labels) = ctx
146 .client
147 .issue_list_labels(owner.as_str(), repo.as_str(), Default::default())
148 .await
149 .into_diagnostic()?;
150 let labels = requested_labels
151 .iter()
152 .map(|label_name| {
153 issue_labels
154 .iter()
155 .find(|label| label.name.as_ref().is_some_and(|name| name == label_name))
156 .with_context(|| format!("Label with name {label_name} doesn't exist yet"))
157 .context("Please create all labels before using them")?
158 .id
159 .context("Selected label is missing an ID")
160 })
161 .collect::<Result<Vec<_>, _>>()?;
162 Some(labels)
163 } else {
164 None
165 };
166
167 let milestone = if let Some(milestone_title) = ctx.args.milestone.as_ref() {
168 let (_, milestones) = ctx
169 .client
170 .issue_get_milestones_list(owner.as_str(), repo.as_str(), Default::default())
171 .await
172 .into_diagnostic()?;
173 let selected_milestone = milestones
174 .into_iter()
175 .find(|milestone| {
176 milestone
177 .title
178 .as_ref()
179 .is_some_and(|title| title == milestone_title)
180 })
181 .with_context(|| format!("Couldn't find milestone with title: {milestone_title}"))?;
182 Some(
183 selected_milestone
184 .id
185 .context("Milestone is expected to have a milestone ID!")?,
186 )
187 } else {
188 None
189 };
190
191 let mut options = CreatePullRequestOption {
192 title: Some(title),
193 base: Some(target_branch),
194 head: Some(source_branch),
195 assignee: None,
196 assignees: ctx.args.assignees.clone(),
197 body: ctx.args.body.clone(),
198 due_date: None,
199 labels,
200 milestone,
201 };
202
203 if !ctx.global_args.non_interactive {
204 let optional_data = {
205 use CreatableFields::*;
206 [
207 (Labels, ctx.args.labels.is_none()),
208 (Assignees, ctx.args.assignees.is_none()),
209 (Milestone, ctx.args.milestone.is_none()),
210 ]
211 .into_iter()
212 .filter_map(|(name, missing)| missing.then_some(name))
213 .collect::<Vec<_>>()
214 };
215
216 let chosen_optionals = multi_fuzzy_select_with_key(
217 &optional_data,
218 "Choose optional properties",
219 |_| false,
220 |o| o.to_string(),
221 )?;
222
223 options.body.replace(pr_body(ctx)?);
224 {
225 use CreatableFields::*;
226 options.labels = pr_labels(ctx, chosen_optionals.contains(&&Labels)).await?;
227 options.assignees = pr_assignees(ctx, chosen_optionals.contains(&&Assignees)).await?;
228 options.milestone = pr_milestone(ctx, chosen_optionals.contains(&&Milestone)).await?;
229 }
230 }
231
232 Ok(options)
233}
234
235fn pr_body(ctx: &BergContext<CreatePullRequestArgs>) -> miette::Result<String> {
236 let body = match ctx.args.body.as_ref() {
237 Some(body) => body.clone(),
238 None => ctx.editor_for("a description", "Enter a pull request description")?,
239 };
240 Ok(body)
241}
242
243async fn pr_labels(
244 ctx: &BergContext<CreatePullRequestArgs>,
245 interactive: bool,
246) -> miette::Result<Option<Vec<i64>>> {
247 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
248
249 let (_, all_labels) = ctx
250 .client
251 .issue_list_labels(
252 owner.as_str(),
253 repo.as_str(),
254 IssueListLabelsQuery::default(),
255 )
256 .await
257 .into_diagnostic()?;
258
259 let labels = match &ctx.args.labels {
260 Some(label_names) => {
261 let label_ids = all_labels
262 .iter()
263 .filter(|l| {
264 l.name
265 .as_ref()
266 .is_some_and(|name| label_names.contains(name))
267 })
268 .filter_map(|l| l.id)
269 .collect::<Vec<_>>();
270 Some(label_ids)
271 }
272 None => {
273 if !interactive {
274 return Ok(None);
275 }
276
277 let selected_labels = multi_fuzzy_select_with_key(
278 &all_labels,
279 select_prompt_for("labels"),
280 |_| false,
281 |l| {
282 l.name
283 .as_ref()
284 .cloned()
285 .unwrap_or_else(|| String::from("???"))
286 },
287 )?;
288
289 let label_ids = selected_labels
290 .iter()
291 .filter_map(|l| l.id)
292 .collect::<Vec<_>>();
293
294 Some(label_ids)
295 }
296 };
297 Ok(labels)
298}
299
300async fn pr_assignees(
301 ctx: &BergContext<CreatePullRequestArgs>,
302 interactive: bool,
303) -> miette::Result<Option<Vec<String>>> {
304 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
305
306 let (_, all_assignees) = ctx
307 .client
308 .repo_get_assignees(owner.as_str(), repo.as_str())
309 .await
310 .into_diagnostic()?;
311 let assignees = match &ctx.args.assignees {
312 Some(assignees_names) => Some(assignees_names.clone()),
313 None => {
314 if !interactive {
315 return Ok(None);
316 }
317
318 let selected_assignees = multi_fuzzy_select_with_key(
319 &all_assignees,
320 select_prompt_for("assignees"),
321 |_| false,
322 |u| {
323 u.login
324 .as_ref()
325 .cloned()
326 .unwrap_or_else(|| String::from("???"))
327 },
328 )?;
329
330 Some(
331 selected_assignees
332 .into_iter()
333 .filter_map(|u| u.login.as_ref().cloned())
334 .collect::<Vec<_>>(),
335 )
336 }
337 };
338
339 Ok(assignees)
340}
341
342async fn pr_milestone(
343 ctx: &BergContext<CreatePullRequestArgs>,
344 interactive: bool,
345) -> miette::Result<Option<i64>> {
346 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
347
348 let (_, all_milestones) = ctx
349 .client
350 .issue_get_milestones_list(
351 owner.as_str(),
352 repo.as_str(),
353 IssueGetMilestonesListQuery::default(),
354 )
355 .await
356 .into_diagnostic()?;
357
358 let milestone = match &ctx.args.milestone {
359 Some(milestone_name) => Some(
360 all_milestones
361 .iter()
362 .find(|m| m.title.as_ref().is_some_and(|name| name == milestone_name))
363 .and_then(|milestone| milestone.id)
364 .context(format!(
365 "Milestone with name {milestone_name} wasn't found. Check the spelling"
366 ))?,
367 ),
368 None => {
369 if !interactive {
370 return Ok(None);
371 }
372 let selected_milestone =
373 fuzzy_select_with_key(&all_milestones, select_prompt_for("milestones"), |m| {
374 m.title
375 .as_ref()
376 .cloned()
377 .unwrap_or_else(|| String::from("???"))
378 })?;
379
380 Some(
381 selected_milestone
382 .id
383 .context("Selected milestone's id is missing")?,
384 )
385 }
386 };
387
388 Ok(milestone)
389}
390
391async fn select_branch(
392 ctx: &BergContext<CreatePullRequestArgs>,
393 prompt_text: &str,
394 default_branch_names: Vec<&str>,
395 filter_branch: Option<&str>,
396) -> miette::Result<String> {
397 let OwnerRepo { repo, owner } = ctx.owner_repo()?;
398 let (_, branches_list) = ctx
399 .client
400 .repo_list_branches(owner.as_str(), repo.as_str())
401 .await
402 .into_diagnostic()?;
403 let branches = branches_list
404 .into_iter()
405 .filter(|branch| {
406 branch
407 .name
408 .as_ref()
409 .is_some_and(|name| filter_branch.is_none_or(|filter| name != filter))
410 })
411 .collect::<Vec<_>>();
412
413 if branches.is_empty() {
414 if filter_branch.is_some() {
415 miette::bail!(
416 "No remote branches except {filter_branch:?} found. Maybe the branch you want to merge doesn't exist on remote yet?"
417 );
418 } else {
419 miette::bail!("No remote branches found.");
420 }
421 }
422
423 let default_index = default_branch_names.iter().find_map(|&default_name| {
424 branches.iter().position(|branch| {
425 branch
426 .name
427 .as_ref()
428 .is_some_and(|name| name.as_str() == default_name)
429 })
430 });
431
432 fuzzy_select_with_key_with_default(
433 &branches,
434 select_prompt_for(prompt_text),
435 |b| option_display(&b.name),
436 default_index,
437 )
438 .map(|branch| option_display(&branch.name))
439}
440
441fn get_current_checkout() -> miette::Result<String> {
442 let output = std::process::Command::new("git")
443 .arg("branch")
444 .arg("--show-current")
445 .output()
446 .into_diagnostic()?;
447 String::from_utf8(output.stdout)
448 .map(|base| base.trim().to_owned())
449 .into_diagnostic()
450}