codeberg_cli/actions/pull_request/
create.rs

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