codeberg_cli/actions/pull_request/
create.rs

1use crate::actions::GeneralArgs;
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    RepoListBranchesQuery,
16};
17use strum::{Display, VariantArray};
18
19/// Create a pull request
20#[derive(Parser, Debug)]
21pub struct CreatePullRequestArgs {
22    /// Comma-delimited list of assignee names
23    #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
24    pub assignees: Option<Vec<String>>,
25
26    /// Target branch for the pull request
27    #[arg(short, long)]
28    pub target_branch: Option<String>,
29
30    /// Main description of the pull request
31    #[arg(id = "description", short, long)]
32    pub body: Option<String>,
33
34    /// Source branch of the pull request
35    #[arg(short, long)]
36    pub source_branch: Option<String>,
37
38    /// Comma-delimited list of labels
39    #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
40    pub labels: Option<Vec<String>>,
41
42    /// Title or summary
43    #[arg(long)]
44    pub title: Option<String>,
45
46    /// Name of the milestone the pull request is related to
47    #[arg(short, long)]
48    pub milestone: Option<String>,
49
50    /// Interactive mode which guides the user through optional argument options
51    #[arg(short, long)]
52    pub interactive: bool,
53}
54
55#[derive(Display, PartialEq, Eq, VariantArray)]
56enum CreatableFields {
57    Assignees,
58    Labels,
59    Milestone,
60}
61
62impl CreatePullRequestArgs {
63    pub async fn run(self, general_args: GeneralArgs) -> anyhow::Result<()> {
64        let ctx = BergContext::new(self, general_args).await?;
65
66        let OwnerRepo { repo, owner } = ctx.owner_repo()?;
67        let options = create_options(&ctx).await?;
68        let pull_request = ctx
69            .client
70            .repo_create_pull_request(owner.as_str(), repo.as_str(), options)
71            .await?;
72
73        match general_args.output_mode {
74            OutputMode::Pretty => {
75                tracing::debug!("{pull_request:?}");
76            }
77            OutputMode::Json => pull_request.print_json()?,
78        }
79
80        Ok(())
81    }
82}
83
84async fn create_options(
85    ctx: &BergContext<CreatePullRequestArgs>,
86) -> anyhow::Result<CreatePullRequestOption> {
87    let title = match ctx.args.title.as_ref() {
88        Some(title) => title.clone(),
89        None => inquire::Text::new(input_prompt_for("Pull Request Title").as_str()).prompt()?,
90    };
91
92    let target_branch = match ctx.args.target_branch.as_ref() {
93        Some(branch) => branch.clone(),
94        None => {
95            select_branch(
96                ctx,
97                "target branch into which changes are merged",
98                vec!["main", "master"],
99                None,
100            )
101            .await?
102        }
103    };
104
105    let source_branch = match ctx.args.source_branch.as_ref() {
106        Some(branch) => branch.clone(),
107        None => {
108            let current_checkout = get_current_checkout()?;
109            select_branch(
110                ctx,
111                "source branch containing the changes",
112                vec![current_checkout.as_str()],
113                Some(target_branch.as_str()),
114            )
115            .await?
116        }
117    };
118
119    let mut options = CreatePullRequestOption {
120        title: Some(title),
121        base: Some(target_branch),
122        head: Some(source_branch),
123        assignee: None,
124        assignees: None,
125        body: None,
126        due_date: None,
127        labels: None,
128        milestone: None,
129    };
130
131    let optional_data = {
132        use CreatableFields::*;
133        [
134            (Labels, ctx.args.labels.is_none()),
135            (Assignees, ctx.args.assignees.is_none()),
136            (Milestone, ctx.args.milestone.is_none()),
137        ]
138        .into_iter()
139        .filter_map(|(name, missing)| missing.then_some(name))
140        .collect::<Vec<_>>()
141    };
142
143    let chosen_optionals = multi_fuzzy_select_with_key(
144        &optional_data,
145        "Choose optional properties",
146        |_| false,
147        |o| o.to_string(),
148    )?;
149
150    options.body.replace(pr_body(ctx)?);
151    {
152        use CreatableFields::*;
153        options.labels = pr_labels(ctx, chosen_optionals.contains(&&Labels)).await?;
154        options.assignees = pr_assignees(ctx, chosen_optionals.contains(&&Assignees)).await?;
155        options.milestone = pr_milestone(ctx, chosen_optionals.contains(&&Milestone)).await?;
156    }
157
158    Ok(options)
159}
160
161fn pr_body(ctx: &BergContext<CreatePullRequestArgs>) -> anyhow::Result<String> {
162    let body = match ctx.args.body.as_ref() {
163        Some(body) => body.clone(),
164        None => ctx.editor_for("a description", "Enter a pull request description")?,
165    };
166    Ok(body)
167}
168
169async fn pr_labels(
170    ctx: &BergContext<CreatePullRequestArgs>,
171    interactive: bool,
172) -> anyhow::Result<Option<Vec<i64>>> {
173    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
174
175    let (_, all_labels) = ctx
176        .client
177        .issue_list_labels(
178            owner.as_str(),
179            repo.as_str(),
180            IssueListLabelsQuery::default(),
181        )
182        .await?;
183
184    let labels = match &ctx.args.labels {
185        Some(label_names) => {
186            let label_ids = all_labels
187                .iter()
188                .filter(|l| {
189                    l.name
190                        .as_ref()
191                        .is_some_and(|name| label_names.contains(name))
192                })
193                .filter_map(|l| l.id)
194                .collect::<Vec<_>>();
195            Some(label_ids)
196        }
197        None => {
198            if !interactive {
199                return Ok(None);
200            }
201
202            let selected_labels = multi_fuzzy_select_with_key(
203                &all_labels,
204                select_prompt_for("labels"),
205                |_| false,
206                |l| {
207                    l.name
208                        .as_ref()
209                        .cloned()
210                        .unwrap_or_else(|| String::from("???"))
211                },
212            )?;
213
214            let label_ids = selected_labels
215                .iter()
216                .filter_map(|l| l.id)
217                .collect::<Vec<_>>();
218
219            Some(label_ids)
220        }
221    };
222    Ok(labels)
223}
224
225async fn pr_assignees(
226    ctx: &BergContext<CreatePullRequestArgs>,
227    interactive: bool,
228) -> anyhow::Result<Option<Vec<String>>> {
229    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
230
231    let (_, all_assignees) = ctx
232        .client
233        .repo_get_assignees(owner.as_str(), repo.as_str())
234        .await?;
235    let assignees = match &ctx.args.assignees {
236        Some(assignees_names) => Some(assignees_names.clone()),
237        None => {
238            if !interactive {
239                return Ok(None);
240            }
241
242            let selected_assignees = multi_fuzzy_select_with_key(
243                &all_assignees,
244                select_prompt_for("assignees"),
245                |_| false,
246                |u| {
247                    u.login
248                        .as_ref()
249                        .cloned()
250                        .unwrap_or_else(|| String::from("???"))
251                },
252            )?;
253
254            Some(
255                selected_assignees
256                    .into_iter()
257                    .filter_map(|u| u.login.as_ref().cloned())
258                    .collect::<Vec<_>>(),
259            )
260        }
261    };
262
263    Ok(assignees)
264}
265
266async fn pr_milestone(
267    ctx: &BergContext<CreatePullRequestArgs>,
268    interactive: bool,
269) -> anyhow::Result<Option<i64>> {
270    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
271
272    let (_, all_milestones) = ctx
273        .client
274        .issue_get_milestones_list(
275            owner.as_str(),
276            repo.as_str(),
277            IssueGetMilestonesListQuery::default(),
278        )
279        .await?;
280
281    let milestone = match &ctx.args.milestone {
282        Some(milestone_name) => Some(
283            all_milestones
284                .iter()
285                .find(|m| m.title.as_ref().is_some_and(|name| name == milestone_name))
286                .and_then(|milestone| milestone.id)
287                .context(format!(
288                    "Milestone with name {milestone_name} wasn't found. Check the spelling"
289                ))?,
290        ),
291        None => {
292            if !interactive {
293                return Ok(None);
294            }
295            let selected_milestone =
296                fuzzy_select_with_key(&all_milestones, select_prompt_for("milestones"), |m| {
297                    m.title
298                        .as_ref()
299                        .cloned()
300                        .unwrap_or_else(|| String::from("???"))
301                })?;
302
303            Some(
304                selected_milestone
305                    .id
306                    .context("Selected milestone's id is missing")?,
307            )
308        }
309    };
310
311    Ok(milestone)
312}
313
314async fn select_branch(
315    ctx: &BergContext<CreatePullRequestArgs>,
316    prompt_text: &str,
317    default_branch_names: Vec<&str>,
318    filter_branch: Option<&str>,
319) -> anyhow::Result<String> {
320    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
321    let (_, branches_list) = ctx
322        .client
323        .repo_list_branches(
324            owner.as_str(),
325            repo.as_str(),
326            RepoListBranchesQuery::default(),
327        )
328        .await?;
329    let branches = branches_list
330        .into_iter()
331        .filter(|branch| {
332            branch
333                .name
334                .as_ref()
335                .is_some_and(|name| filter_branch.is_none_or(|filter| name != filter))
336        })
337        .collect::<Vec<_>>();
338
339    if branches.is_empty() {
340        if filter_branch.is_some() {
341            anyhow::bail!(
342                "No remote branches except {filter_branch:?} found. Maybe the branch you want to merge doesn't exist on remote yet?"
343            );
344        } else {
345            anyhow::bail!("No remote branches found.");
346        }
347    }
348
349    let default_index = default_branch_names.iter().find_map(|&default_name| {
350        branches.iter().position(|branch| {
351            branch
352                .name
353                .as_ref()
354                .is_some_and(|name| name.as_str() == default_name)
355        })
356    });
357
358    fuzzy_select_with_key_with_default(
359        &branches,
360        select_prompt_for(prompt_text),
361        |b| option_display(&b.name),
362        default_index,
363    )
364    .map(|branch| option_display(&branch.name))
365}
366
367fn get_current_checkout() -> anyhow::Result<String> {
368    let output = std::process::Command::new("git")
369        .arg("branch")
370        .arg("--show-current")
371        .output()?;
372    String::from_utf8(output.stdout)
373        .map(|base| base.trim().to_owned())
374        .map_err(anyhow::Error::from)
375}