codeberg_cli/actions/issue/
create.rs

1use crate::actions::GlobalArgs;
2use crate::render::json::JsonToStdout;
3use crate::render::ui::{fuzzy_select_with_key, multi_fuzzy_select_with_key};
4use crate::types::context::BergContext;
5use crate::types::git::OwnerRepo;
6use crate::types::output::OutputMode;
7use anyhow::Context;
8use forgejo_api::structs::{CreateIssueOption, IssueGetMilestonesListQuery, IssueListLabelsQuery};
9use strum::*;
10
11use crate::actions::text_manipulation::{input_prompt_for, select_prompt_for};
12
13use clap::Parser;
14
15/// Create an issue
16#[derive(Parser, Debug)]
17pub struct CreateIssueArgs {
18    /// Title or summary
19    #[arg(short, long)]
20    pub title: Option<String>,
21
22    /// Comma-delimited list of label ids
23    #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
24    pub labels: Option<Vec<String>>,
25
26    /// Main description of issue
27    #[arg(id = "description", short, long)]
28    pub body: Option<String>,
29
30    /// Comma-delimited list of assignee names
31    #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
32    pub assignees: Option<Vec<String>>,
33
34    /// Name of the milestone the issue is related to
35    #[arg(short, long)]
36    pub milestone: Option<String>,
37}
38
39#[derive(Display, PartialEq, Eq, VariantArray)]
40enum CreatableFields {
41    Labels,
42    Assignees,
43    Milestone,
44}
45
46impl CreateIssueArgs {
47    pub async fn run(self, global_args: GlobalArgs) -> anyhow::Result<()> {
48        let ctx = BergContext::new(self, global_args).await?;
49        let OwnerRepo { repo, owner } = ctx.owner_repo()?;
50        let options = create_options(&ctx).await?;
51        tracing::debug!("{options:?}");
52        let issue = ctx
53            .client
54            .issue_create_issue(owner.as_str(), repo.as_str(), options)
55            .await?;
56        match ctx.global_args.output_mode {
57            OutputMode::Pretty => {
58                tracing::debug!("{issue:?}");
59            }
60            OutputMode::Json => issue.print_json()?,
61        }
62        Ok(())
63    }
64}
65
66async fn create_options(ctx: &BergContext<CreateIssueArgs>) -> anyhow::Result<CreateIssueOption> {
67    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
68    let title = ctx
69        .args
70        .title
71        .as_ref()
72        .cloned()
73        .map(Ok)
74        .unwrap_or_else(|| {
75            if ctx.global_args.non_interactive {
76                anyhow::bail!("You have to set an issue title!");
77            } else {
78                inquire::Text::new(input_prompt_for("Issue Title").as_str())
79                    .prompt()
80                    .map_err(anyhow::Error::from)
81            }
82        })?;
83
84    let milestone = if let Some(milestone_title) = ctx.args.milestone.as_ref() {
85        let (_, milestones) = ctx
86            .client
87            .issue_get_milestones_list(owner.as_str(), repo.as_str(), Default::default())
88            .await?;
89        let selected_milestone = milestones
90            .into_iter()
91            .find(|milestone| {
92                milestone
93                    .title
94                    .as_ref()
95                    .is_some_and(|title| title == milestone_title)
96            })
97            .with_context(|| format!("Couldn't find milestone with title: {milestone_title}"))?;
98        Some(
99            selected_milestone
100                .id
101                .context("Milestone is expected to have a milestone ID!")?,
102        )
103    } else {
104        None
105    };
106
107    let labels = if let Some(requested_labels) = ctx.args.labels.as_ref() {
108        let (_, issue_labels) = ctx
109            .client
110            .issue_list_labels(owner.as_str(), repo.as_str(), Default::default())
111            .await?;
112        let labels = requested_labels
113            .iter()
114            .map(|label_name| {
115                issue_labels
116                    .iter()
117                    .find(|label| label.name.as_ref().is_some_and(|name| name == label_name))
118                    .with_context(|| format!("Label with name {label_name} doesn't exist yet"))
119                    .context("Please create all labels before using them")?
120                    .id
121                    .context("Selected label is missing an ID")
122            })
123            .collect::<Result<Vec<_>, _>>()?;
124        Some(labels)
125    } else {
126        None
127    };
128
129    let mut options = CreateIssueOption {
130        title,
131        assignee: None,
132        assignees: ctx.args.assignees.clone(),
133        body: ctx.args.body.clone(),
134        closed: None,
135        due_date: None,
136        labels,
137        milestone,
138        r#ref: None,
139    };
140
141    options.body.replace(issue_body(ctx)?);
142
143    if !ctx.global_args.non_interactive {
144        let optional_data = {
145            use CreatableFields::*;
146            [
147                (Labels, ctx.args.labels.is_none()),
148                (Assignees, ctx.args.assignees.is_none()),
149                (Milestone, ctx.args.milestone.is_none()),
150            ]
151            .into_iter()
152            .filter_map(|(name, missing)| missing.then_some(name))
153            .collect::<Vec<_>>()
154        };
155
156        let chosen_optionals = multi_fuzzy_select_with_key(
157            &optional_data,
158            "Choose optional properties",
159            |_| false,
160            |o| o.to_string(),
161        )?;
162        {
163            use CreatableFields::*;
164            options.labels = issue_labels(ctx, chosen_optionals.contains(&&Labels)).await?;
165            options.assignees =
166                issue_assignees(ctx, chosen_optionals.contains(&&Assignees)).await?;
167            options.milestone =
168                issue_milestone(ctx, chosen_optionals.contains(&&Milestone)).await?;
169        }
170    }
171
172    Ok(options)
173}
174
175fn issue_body(ctx: &BergContext<CreateIssueArgs>) -> anyhow::Result<String> {
176    let body = match ctx.args.body.as_ref() {
177        Some(body) => body.clone(),
178        None => {
179            if ctx.global_args.non_interactive {
180                anyhow::bail!("You need to provide an issue description!");
181            } else {
182                ctx.editor_for("a description", "Enter an issue description")?
183            }
184        }
185    };
186    Ok(body)
187}
188
189async fn issue_labels(
190    ctx: &BergContext<CreateIssueArgs>,
191    interactive: bool,
192) -> anyhow::Result<Option<Vec<i64>>> {
193    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
194
195    let (_, all_labels) = ctx
196        .client
197        .issue_list_labels(
198            owner.as_str(),
199            repo.as_str(),
200            IssueListLabelsQuery::default(),
201        )
202        .await?;
203
204    let labels = match &ctx.args.labels {
205        Some(label_names) => {
206            let label_ids = all_labels
207                .iter()
208                .filter(|l| {
209                    l.name
210                        .as_ref()
211                        .is_some_and(|name| label_names.contains(name))
212                })
213                .filter_map(|l| l.id)
214                .collect::<Vec<_>>();
215            Some(label_ids)
216        }
217        None => {
218            if !interactive {
219                return Ok(None);
220            }
221
222            let selected_labels = multi_fuzzy_select_with_key(
223                &all_labels,
224                select_prompt_for("labels"),
225                |_| false,
226                |l| {
227                    l.name
228                        .as_ref()
229                        .cloned()
230                        .unwrap_or_else(|| String::from("???"))
231                },
232            )?;
233
234            let label_ids = selected_labels
235                .iter()
236                .filter_map(|l| l.id)
237                .collect::<Vec<_>>();
238
239            Some(label_ids)
240        }
241    };
242    Ok(labels)
243}
244
245async fn issue_assignees(
246    ctx: &BergContext<CreateIssueArgs>,
247    interactive: bool,
248) -> anyhow::Result<Option<Vec<String>>> {
249    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
250
251    let (_, all_assignees) = ctx
252        .client
253        .repo_get_assignees(owner.as_str(), repo.as_str())
254        .await?;
255    let assignees = match &ctx.args.assignees {
256        Some(assignees_names) => Some(assignees_names.clone()),
257        None => {
258            tracing::debug!("Hey");
259            if !interactive {
260                return Ok(None);
261            }
262
263            let selected_assignees = multi_fuzzy_select_with_key(
264                &all_assignees,
265                select_prompt_for("assignees"),
266                |_| false,
267                |u| {
268                    u.login
269                        .as_ref()
270                        .cloned()
271                        .unwrap_or_else(|| String::from("???"))
272                },
273            )?;
274
275            Some(
276                selected_assignees
277                    .into_iter()
278                    .filter_map(|u| u.login.as_ref().cloned())
279                    .collect::<Vec<_>>(),
280            )
281        }
282    };
283
284    Ok(assignees)
285}
286
287async fn issue_milestone(
288    ctx: &BergContext<CreateIssueArgs>,
289    interactive: bool,
290) -> anyhow::Result<Option<i64>> {
291    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
292
293    let (_, all_milestones) = ctx
294        .client
295        .issue_get_milestones_list(
296            owner.as_str(),
297            repo.as_str(),
298            IssueGetMilestonesListQuery::default(),
299        )
300        .await?;
301
302    let milestone = match &ctx.args.milestone {
303        Some(milestone_name) => Some(
304            all_milestones
305                .iter()
306                .find(|m| m.title.as_ref().is_some_and(|name| name == milestone_name))
307                .and_then(|milestone| milestone.id)
308                .context(format!(
309                    "Milestone with name {milestone_name} wasn't found. Check the spelling"
310                ))?,
311        ),
312        None => {
313            if !interactive {
314                return Ok(None);
315            }
316            let selected_milestone =
317                fuzzy_select_with_key(&all_milestones, select_prompt_for("milestones"), |m| {
318                    m.title
319                        .as_ref()
320                        .cloned()
321                        .unwrap_or_else(|| String::from("???"))
322                })?;
323
324            Some(
325                selected_milestone
326                    .id
327                    .context("Selected milestone's id is missing")?,
328            )
329        }
330    };
331
332    Ok(milestone)
333}