codeberg_cli/actions/issue/
create.rs

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