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 anyhow::Context;
12use clap::Parser;
13use forgejo_api::structs::{
14    CreatePullRequestOption, IssueGetMilestonesListQuery, IssueListLabelsQuery,
15};
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) -> 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}