Skip to main content

codeberg_cli/actions/pull_request/
create.rs

1use 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/// Create a pull request
19#[derive(Parser, Debug)]
20pub struct CreatePullRequestArgs {
21    /// Comma-delimited list of assignee names
22    #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
23    pub assignees: Option<Vec<String>>,
24
25    /// Target branch for the pull request
26    #[arg(short, long)]
27    pub target_branch: Option<String>,
28
29    /// Main description of the pull request
30    #[arg(id = "description", short, long)]
31    pub body: Option<String>,
32
33    /// Source branch of the pull request
34    #[arg(short, long)]
35    pub source_branch: Option<String>,
36
37    /// Comma-delimited list of labels
38    #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
39    pub labels: Option<Vec<String>>,
40
41    /// Title or summary
42    #[arg(long)]
43    pub title: Option<String>,
44
45    /// Name of the milestone the pull request is related to
46    #[arg(short, long)]
47    pub milestone: Option<String>,
48
49    /// Interactive mode which guides the user through optional argument options
50    #[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}