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