codeberg_cli/actions/issue/
create.rs

1use crate::actions::GeneralArgs;
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, general_args: GeneralArgs) -> anyhow::Result<()> {
48        let ctx = BergContext::new(self, general_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 general_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 title = ctx
68        .args
69        .title
70        .as_ref()
71        .cloned()
72        .map(Ok)
73        .unwrap_or_else(|| {
74            if ctx.general_args.non_interactive {
75                anyhow::bail!("You have to set an issue title!");
76            } else {
77                inquire::Text::new(input_prompt_for("Issue Title").as_str())
78                    .prompt()
79                    .map_err(anyhow::Error::from)
80            }
81        })?;
82
83    let mut options = CreateIssueOption {
84        title,
85        assignee: None,
86        assignees: None,
87        body: None,
88        closed: None,
89        due_date: None,
90        labels: None,
91        milestone: None,
92        r#ref: None,
93    };
94
95    options.body.replace(issue_body(ctx)?);
96
97    if !ctx.general_args.non_interactive {
98        let optional_data = {
99            use CreatableFields::*;
100            [
101                (Labels, ctx.args.labels.is_none()),
102                (Assignees, ctx.args.assignees.is_none()),
103                (Milestone, ctx.args.milestone.is_none()),
104            ]
105            .into_iter()
106            .filter_map(|(name, missing)| missing.then_some(name))
107            .collect::<Vec<_>>()
108        };
109
110        let chosen_optionals = multi_fuzzy_select_with_key(
111            &optional_data,
112            "Choose optional properties",
113            |_| false,
114            |o| o.to_string(),
115        )?;
116        {
117            use CreatableFields::*;
118            options.labels = issue_labels(ctx, chosen_optionals.contains(&&Labels)).await?;
119            options.assignees =
120                issue_assignees(ctx, chosen_optionals.contains(&&Assignees)).await?;
121            options.milestone =
122                issue_milestone(ctx, chosen_optionals.contains(&&Milestone)).await?;
123        }
124    }
125
126    Ok(options)
127}
128
129fn issue_body(ctx: &BergContext<CreateIssueArgs>) -> anyhow::Result<String> {
130    let body = match ctx.args.body.as_ref() {
131        Some(body) => body.clone(),
132        None => {
133            if ctx.general_args.non_interactive {
134                anyhow::bail!("You need to provide an issue description!");
135            } else {
136                ctx.editor_for("a description", "Enter a issue description")?
137            }
138        }
139    };
140    Ok(body)
141}
142
143async fn issue_labels(
144    ctx: &BergContext<CreateIssueArgs>,
145    interactive: bool,
146) -> anyhow::Result<Option<Vec<i64>>> {
147    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
148
149    let (_, all_labels) = ctx
150        .client
151        .issue_list_labels(
152            owner.as_str(),
153            repo.as_str(),
154            IssueListLabelsQuery::default(),
155        )
156        .await?;
157
158    let labels = match &ctx.args.labels {
159        Some(label_names) => {
160            let label_ids = all_labels
161                .iter()
162                .filter(|l| {
163                    l.name
164                        .as_ref()
165                        .is_some_and(|name| label_names.contains(name))
166                })
167                .filter_map(|l| l.id)
168                .collect::<Vec<_>>();
169            Some(label_ids)
170        }
171        None => {
172            if !interactive {
173                return Ok(None);
174            }
175
176            let selected_labels = multi_fuzzy_select_with_key(
177                &all_labels,
178                select_prompt_for("labels"),
179                |_| false,
180                |l| {
181                    l.name
182                        .as_ref()
183                        .cloned()
184                        .unwrap_or_else(|| String::from("???"))
185                },
186            )?;
187
188            let label_ids = selected_labels
189                .iter()
190                .filter_map(|l| l.id)
191                .collect::<Vec<_>>();
192
193            Some(label_ids)
194        }
195    };
196    Ok(labels)
197}
198
199async fn issue_assignees(
200    ctx: &BergContext<CreateIssueArgs>,
201    interactive: bool,
202) -> anyhow::Result<Option<Vec<String>>> {
203    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
204
205    let (_, all_assignees) = ctx
206        .client
207        .repo_get_assignees(owner.as_str(), repo.as_str())
208        .await?;
209    let assignees = match &ctx.args.assignees {
210        Some(assignees_names) => Some(assignees_names.clone()),
211        None => {
212            tracing::debug!("Hey");
213            if !interactive {
214                return Ok(None);
215            }
216
217            let selected_assignees = multi_fuzzy_select_with_key(
218                &all_assignees,
219                select_prompt_for("assignees"),
220                |_| false,
221                |u| {
222                    u.login
223                        .as_ref()
224                        .cloned()
225                        .unwrap_or_else(|| String::from("???"))
226                },
227            )?;
228
229            Some(
230                selected_assignees
231                    .into_iter()
232                    .filter_map(|u| u.login.as_ref().cloned())
233                    .collect::<Vec<_>>(),
234            )
235        }
236    };
237
238    Ok(assignees)
239}
240
241async fn issue_milestone(
242    ctx: &BergContext<CreateIssueArgs>,
243    interactive: bool,
244) -> anyhow::Result<Option<i64>> {
245    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
246
247    let (_, all_milestones) = ctx
248        .client
249        .issue_get_milestones_list(
250            owner.as_str(),
251            repo.as_str(),
252            IssueGetMilestonesListQuery::default(),
253        )
254        .await?;
255
256    let milestone = match &ctx.args.milestone {
257        Some(milestone_name) => Some(
258            all_milestones
259                .iter()
260                .find(|m| m.title.as_ref().is_some_and(|name| name == milestone_name))
261                .and_then(|milestone| milestone.id)
262                .context(format!(
263                    "Milestone with name {milestone_name} wasn't found. Check the spelling"
264                ))?,
265        ),
266        None => {
267            if !interactive {
268                return Ok(None);
269            }
270            let selected_milestone =
271                fuzzy_select_with_key(&all_milestones, select_prompt_for("milestones"), |m| {
272                    m.title
273                        .as_ref()
274                        .cloned()
275                        .unwrap_or_else(|| String::from("???"))
276                })?;
277
278            Some(
279                selected_milestone
280                    .id
281                    .context("Selected milestone's id is missing")?,
282            )
283        }
284    };
285
286    Ok(milestone)
287}