Skip to main content

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            if all_labels.is_empty() {
227                println!(
228                    "No labels available! You might want to create some via `berg label create`!"
229                );
230                return Ok(None);
231            }
232
233            let selected_labels = multi_fuzzy_select_with_key(
234                &all_labels,
235                select_prompt_for("labels"),
236                |_| false,
237                |l| {
238                    l.name
239                        .as_ref()
240                        .cloned()
241                        .unwrap_or_else(|| String::from("???"))
242                },
243            )?;
244
245            let label_ids = selected_labels
246                .iter()
247                .filter_map(|l| l.id)
248                .collect::<Vec<_>>();
249
250            Some(label_ids)
251        }
252    };
253    Ok(labels)
254}
255
256async fn issue_assignees(
257    ctx: &BergContext<CreateIssueArgs>,
258    interactive: bool,
259) -> miette::Result<Option<Vec<String>>> {
260    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
261
262    let (_, all_assignees) = ctx
263        .client
264        .repo_get_assignees(owner.as_str(), repo.as_str())
265        .await
266        .into_diagnostic()?;
267    let assignees = match &ctx.args.assignees {
268        Some(assignees_names) => Some(assignees_names.clone()),
269        None => {
270            tracing::debug!("Hey");
271            if !interactive {
272                return Ok(None);
273            }
274
275            let selected_assignees = multi_fuzzy_select_with_key(
276                &all_assignees,
277                select_prompt_for("assignees"),
278                |_| false,
279                |u| {
280                    u.login
281                        .as_ref()
282                        .cloned()
283                        .unwrap_or_else(|| String::from("???"))
284                },
285            )?;
286
287            Some(
288                selected_assignees
289                    .into_iter()
290                    .filter_map(|u| u.login.as_ref().cloned())
291                    .collect::<Vec<_>>(),
292            )
293        }
294    };
295
296    Ok(assignees)
297}
298
299async fn issue_milestone(
300    ctx: &BergContext<CreateIssueArgs>,
301    interactive: bool,
302) -> miette::Result<Option<i64>> {
303    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
304
305    let (_, all_milestones) = ctx
306        .client
307        .issue_get_milestones_list(
308            owner.as_str(),
309            repo.as_str(),
310            IssueGetMilestonesListQuery::default(),
311        )
312        .await
313        .into_diagnostic()?;
314
315    let milestone = match &ctx.args.milestone {
316        Some(milestone_name) => Some(
317            all_milestones
318                .iter()
319                .find(|m| m.title.as_ref().is_some_and(|name| name == milestone_name))
320                .and_then(|milestone| milestone.id)
321                .context(format!(
322                    "Milestone with name {milestone_name} wasn't found. Check the spelling"
323                ))?,
324        ),
325        None => {
326            if !interactive {
327                return Ok(None);
328            }
329            let selected_milestone =
330                fuzzy_select_with_key(&all_milestones, select_prompt_for("milestones"), |m| {
331                    m.title
332                        .as_ref()
333                        .cloned()
334                        .unwrap_or_else(|| String::from("???"))
335                })?;
336
337            Some(
338                selected_milestone
339                    .id
340                    .context("Selected milestone's id is missing")?,
341            )
342        }
343    };
344
345    Ok(milestone)
346}