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