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