codeberg_cli/actions/issue/
create.rs1use 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#[derive(Parser, Debug)]
17pub struct CreateIssueArgs {
18 #[arg(short, long)]
20 pub title: Option<String>,
21
22 #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
24 pub labels: Option<Vec<String>>,
25
26 #[arg(id = "description", short, long)]
28 pub body: Option<String>,
29
30 #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
32 pub assignees: Option<Vec<String>>,
33
34 #[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}