1use super::{ApiError, JiraClient, validate_issue_key};
4use serde::Deserialize;
5use serde_json::{Value, json};
6use std::collections::BTreeMap;
7
8mod type_change;
9mod validation;
10use validation::{normalize_named_arrays, validate_required};
11
12pub(super) type Fields = BTreeMap<String, Value>;
13pub(super) const EPIC_LINK_SCHEMA: &str = "com.pyxis.greenhopper.jira:gh-epic-link";
14
15#[derive(Clone, Debug, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub(super) struct IssueType {
18 #[serde(default)]
19 id: String,
20 name: String,
21 subtask: Option<bool>,
23 hierarchy_level: Option<i64>,
24 fields: Option<Fields>,
25}
26
27impl IssueType {
28 fn is_subtask(&self) -> bool {
31 self.hierarchy_level == Some(-1)
32 || self.subtask.unwrap_or_else(|| {
33 matches!(
34 self.name.to_ascii_lowercase().as_str(),
35 "subtask" | "sub-task"
36 )
37 })
38 }
39 fn is_epic(&self) -> bool {
40 self.hierarchy_level == Some(1) || self.name.eq_ignore_ascii_case("Epic")
41 }
42
43 fn can_belong_to_epic(&self) -> bool {
44 !self.is_subtask() && self.hierarchy_level.is_none_or(|level| level == 0) && !self.is_epic()
45 }
46}
47
48pub(super) struct CreateMetadata {
49 issue_type: IssueType,
50 pub(super) fields: Option<Fields>,
51 subtask_types: Vec<Value>,
52}
53
54impl JiraClient {
55 async fn optional_metadata(&self, path: &str) -> Result<Option<Value>, ApiError> {
58 match self.get(path).await {
59 Ok(value) => Ok(Some(value)),
60 Err(ApiError::NotFound(_))
61 | Err(ApiError::Api {
62 status: 405 | 410 | 501,
63 ..
64 }) => Ok(None),
65 Err(err) => Err(err),
66 }
67 }
68
69 async fn metadata_pages(
71 &self,
72 path: &str,
73 array: &str,
74 ) -> Result<Option<Vec<Value>>, ApiError> {
75 let mut values = Vec::new();
76 let mut start = 0;
77 loop {
78 let Some(page) = self
79 .optional_metadata(&format!("{path}?startAt={start}&maxResults=100"))
80 .await?
81 else {
82 if start == 0 {
83 return Ok(None);
84 }
85 return Err(ApiError::Other(
86 "Jira metadata pagination became unavailable".into(),
87 ));
88 };
89 let items = page
90 .get(array)
91 .or_else(|| page.get("values"))
92 .and_then(Value::as_array)
93 .ok_or_else(|| {
94 ApiError::Other("Invalid Jira metadata page: missing items".into())
95 })?;
96 let next = start + items.len();
97 if page["startAt"]
98 .as_u64()
99 .is_some_and(|n| n as usize != start)
100 {
101 return Err(ApiError::Other(
102 "Jira metadata pagination did not advance".into(),
103 ));
104 }
105 values.extend(items.iter().cloned());
106 let total = page["total"].as_u64().map(|n| n as usize);
107 if page["isLast"] == true || total.is_some_and(|n| next >= n) {
108 return Ok(Some(values));
109 }
110 if items.is_empty() {
111 return Err(ApiError::Other(
112 "Jira metadata pagination did not advance".into(),
113 ));
114 }
115 if total.is_none() && page["isLast"].as_bool().is_none() {
116 return Err(ApiError::Other(
117 "Invalid Jira metadata page: missing pagination".into(),
118 ));
119 }
120 start = next;
121 }
122 }
123
124 async fn legacy_create_metadata(
125 &self,
126 project: &str,
127 include_fields: bool,
128 ) -> Result<Option<Vec<IssueType>>, ApiError> {
129 let parameter = if project.bytes().all(|b| b.is_ascii_digit()) {
130 "projectIds"
131 } else {
132 "projectKeys"
133 };
134 let project_query: String =
135 reqwest::Url::parse_with_params("http://localhost", &[(parameter, project)])
136 .expect("static URL")
137 .query()
138 .unwrap()
139 .into();
140 let expand = if include_fields {
141 "projects.issuetypes.fields"
142 } else {
143 "projects.issuetypes"
144 };
145 let Some(meta) = self
146 .optional_metadata(&format!("issue/createmeta?{project_query}&expand={expand}"))
147 .await?
148 else {
149 return Ok(None);
150 };
151 let projects = meta["projects"].as_array().ok_or_else(|| {
152 ApiError::Other("Invalid Jira create metadata: missing projects".into())
153 })?;
154 let types = projects
155 .iter()
156 .find(|p| {
157 p["key"]
158 .as_str()
159 .is_some_and(|key| key.eq_ignore_ascii_case(project))
160 || p["id"].as_str() == Some(project)
161 })
162 .map(|p| p["issuetypes"].clone())
163 .ok_or_else(|| ApiError::NotFound(format!("Project {project:?} is unavailable or you lack permission to create issues in it")))?;
164 decode(types).map(Some)
165 }
166
167 async fn create_types(
168 &self,
169 project: &str,
170 include_fields: bool,
171 ) -> Result<Option<(Vec<IssueType>, bool)>, ApiError> {
172 if project.trim().is_empty() {
173 return Err(ApiError::InvalidInput("Project must not be empty".into()));
174 }
175 let path = format!("issue/createmeta/{}/issuetypes", encode_segment(project));
176 let (types, legacy): (Vec<IssueType>, bool) =
177 match self.metadata_pages(&path, "issueTypes").await? {
178 Some(types) => (decode(json!(types))?, false),
179 None => match self.legacy_create_metadata(project, include_fields).await? {
180 Some(types) => (types, true),
181 None => return Ok(None),
182 },
183 };
184 if types.is_empty() {
185 return Err(ApiError::NotFound(format!(
186 "No creatable issue types in project {project:?}; check the project and your create permission"
187 )));
188 }
189 Ok(Some((types, legacy)))
190 }
191
192 pub(super) async fn create_metadata(
193 &self,
194 project: &str,
195 input: &str,
196 ) -> Result<Option<CreateMetadata>, ApiError> {
197 let Some((types, legacy)) = self.create_types(project, true).await? else {
198 return Ok(None);
199 };
200 self.select_create_metadata(project, input, &types, legacy)
201 .await
202 .map(Some)
203 }
204
205 async fn select_create_metadata(
206 &self,
207 project: &str,
208 input: &str,
209 types: &[IssueType],
210 legacy: bool,
211 ) -> Result<CreateMetadata, ApiError> {
212 let path = format!("issue/createmeta/{}/issuetypes", encode_segment(project));
213 let options = types
214 .iter()
215 .map(|t| json!({"id": t.id, "name": t.name}))
216 .collect::<Vec<_>>();
217 let selected = match_option("issue type", input, &options, OptionMatch::NameOrId)?;
218 let subtask_types = types
219 .iter()
220 .filter(|t| t.is_subtask())
221 .map(|t| json!({"id": t.id, "name": t.name}))
222 .collect();
223 let issue_type = types[selected].clone();
224 let fields = if let Some(fields) = &issue_type.fields {
225 Some(fields.clone())
226 } else if legacy {
227 None
228 } else {
229 match self
230 .metadata_pages(
231 &format!("{path}/{}", encode_segment(&issue_type.id)),
232 "fields",
233 )
234 .await?
235 {
236 Some(fields) => Some(
237 fields
238 .into_iter()
239 .map(|field| {
240 let key = field["fieldId"]
241 .as_str()
242 .or_else(|| field["key"].as_str())
243 .ok_or_else(|| {
244 ApiError::Other(
245 "Invalid Jira field metadata: missing field ID".into(),
246 )
247 })?;
248 Ok((key.to_owned(), field))
249 })
250 .collect::<Result<_, ApiError>>()?,
251 ),
252 None => {
253 let fallback = match self.legacy_create_metadata(project, true).await {
256 Ok(types) => types,
257 Err(ApiError::NotFound(_)) => None,
258 Err(error) => return Err(error),
259 };
260 fallback
261 .and_then(|types| types.into_iter().find(|t| t.id == issue_type.id))
262 .and_then(|t| t.fields)
263 }
264 }
265 };
266 Ok(CreateMetadata {
267 issue_type,
268 fields,
269 subtask_types,
270 })
271 }
272
273 pub async fn issue_create_metadata(
276 &self,
277 project: &str,
278 input: Option<&str>,
279 ) -> Result<Value, ApiError> {
280 let Some((types, legacy)) = self.create_types(project, input.is_some()).await? else {
281 self.get_project(project).await?;
282 return Err(ApiError::WithDetails {
283 source: Box::new(ApiError::NotFound("Create metadata is unavailable on this Jira instance; use `jira fields list` for global field discovery".into())),
284 details: json!({"reason":"unsupported", "project":project, "hint":"jira fields list"}),
285 });
286 };
287 let selected = match input {
288 Some(input) => Some(
289 self.select_create_metadata(project, input, &types, legacy)
290 .await?,
291 ),
292 None => None,
293 };
294 let mut warnings = Vec::new();
295 let fields = selected
296 .as_ref()
297 .and_then(|m| m.fields.as_ref())
298 .map(|fields| {
299 fields
300 .iter()
301 .map(|(id, definition)| {
302 let mut definition = definition.clone();
303 if let Some(object) = definition.as_object_mut() {
304 object.entry("allowedValues").or_insert(Value::Null);
305 object.entry("defaultValue").or_insert(Value::Null);
306 }
307 (id.clone(), definition)
308 })
309 .collect::<Fields>()
310 });
311 if selected.is_some() && fields.is_none() {
312 warnings.push("Field metadata is unavailable: required fields, defaults, allowed values, and epic support could not be discovered.");
313 }
314 let epic = selected.as_ref().map(|m| {
315 let candidates = m
316 .fields
317 .as_ref()
318 .map(|fields| epic_candidates(fields, self.api_version))
319 .unwrap_or_default();
320 let status = if !m.issue_type.can_belong_to_epic() {
321 "not_applicable"
322 } else {
323 match candidates.len() {
324 1 => "available",
325 0 => "unavailable",
326 _ => "ambiguous",
327 }
328 };
329 let field = if status == "available" {
330 Some(candidates[0].as_str())
331 } else {
332 None
333 };
334 let mechanism = field.map(|f| if f == "parent" { "parent" } else { "epic_link" });
335 json!({"status":status, "field":field, "mechanism":mechanism, "candidates":candidates})
336 });
337 Ok(
338 json!({"project":project, "issueTypes":types.iter().map(type_json).collect::<Vec<_>>(),
339 "issueType":selected.as_ref().map(|m| type_json(&m.issue_type)), "fields":fields, "epic":epic, "warnings":warnings}),
340 )
341 }
342
343 pub(super) async fn issue_type_for_link(&self, key: &str) -> Result<IssueType, ApiError> {
344 validate_issue_key(key)?;
345 let issue: Value = self.get(&format!("issue/{key}?fields=issuetype")).await?;
346 decode(issue["fields"]["issuetype"].clone())
347 }
348
349 async fn epic_field(&self, fields: Option<&Fields>) -> Result<String, ApiError> {
350 if let Some(fields) = fields {
351 if let Some(id) = choose_epic_field(epic_candidates(fields, self.api_version))? {
352 return Ok(id);
353 }
354 } else if self.api_version >= 3 {
355 return Ok("parent".into());
356 } else {
357 let fields = self
358 .list_fields()
359 .await?
360 .into_iter()
361 .map(|field| {
362 (
363 field.id.clone(),
364 serde_json::to_value(field).expect("field serializes"),
365 )
366 })
367 .collect();
368 if let Some(id) = find_epic_field(&fields)? {
369 return Ok(id);
370 }
371 }
372 Err(ApiError::InvalidInput(
373 "Cannot resolve epic linkage: neither Epic Link nor an epic parent is available in this issue's metadata. Enable Epic Link on the create/edit screen or inspect `jira fields list` and use --field <ID>=<EPIC>.".into()
374 ))
375 }
376
377 async fn set_epic(
378 &self,
379 fields: &mut Value,
380 meta: Option<&Fields>,
381 issue_type: &IssueType,
382 epic: &str,
383 ) -> Result<(), ApiError> {
384 if !issue_type.can_belong_to_epic() {
385 return Err(ApiError::InvalidInput(format!(
386 "Issue type {:?} cannot be added to an Epic; use --epic {epic} with a standard issue type such as Story or Task. For a subtask, use --parent <STORY-OR-TASK>.",
387 issue_type.name
388 )));
389 }
390 let field = self.epic_field(meta).await?;
391 validate_epic_override(fields, meta, &field)?;
392 fields[&field] = if field == "parent" {
393 json!({"key": epic})
394 } else {
395 json!(epic)
396 };
397 Ok(())
398 }
399
400 pub(super) async fn prepare_create(
401 &self,
402 fields: &mut Value,
403 draft: &super::IssueDraft<'_>,
404 custom: &[(String, Value)],
405 ) -> Result<Option<CreateMetadata>, ApiError> {
406 if draft.parent.is_some() && draft.epic.is_some() {
407 return Err(ApiError::InvalidInput(
408 "--parent and --epic cannot be used together".into(),
409 ));
410 }
411 if draft.parent.is_some() && fields.get("parent").is_some() {
412 return Err(ApiError::InvalidInput(
413 "--parent conflicts with --field parent; choose one source".into(),
414 ));
415 }
416 let input = fields["issuetype"]["id"]
417 .as_str()
418 .or_else(|| fields["issuetype"]["name"].as_str())
419 .ok_or_else(|| ApiError::InvalidInput("issuetype requires a name or ID".into()))?
420 .to_owned();
421 let project = fields["project"]["key"]
422 .as_str()
423 .or_else(|| fields["project"]["id"].as_str())
424 .ok_or_else(|| ApiError::InvalidInput("project requires a key or ID".into()))?;
425 let meta = self.create_metadata(project, &input).await?;
426 let fallback = IssueType {
427 id: String::new(),
428 name: input,
429 subtask: None,
430 hierarchy_level: None,
431 fields: None,
432 };
433 let issue_type = meta.as_ref().map(|m| &m.issue_type).unwrap_or(&fallback);
434 if meta.is_some() {
435 fields["issuetype"] = named_id(&issue_type.id, &issue_type.name);
436 }
437 let field_meta = meta.as_ref().and_then(|m| m.fields.as_ref());
438 if let Some(priority) = draft
439 .priority
440 .filter(|_| !custom.iter().any(|(key, _)| key == "priority"))
441 {
442 normalize_priority(fields, field_meta, priority)?;
443 }
444 normalize_named_arrays(fields, field_meta, custom)?;
445 if let Some(key) = draft.epic.or(draft.parent) {
446 let target = self.issue_type_for_link(key).await?;
447 if draft.epic.is_some() && !target.is_epic() {
448 return Err(ApiError::InvalidInput(format!(
449 "--epic {key} targets issue type {:?}, not an Epic",
450 target.name
451 )));
452 }
453 if target.is_epic() {
454 self.set_epic(fields, field_meta, issue_type, key).await?;
457 } else if fields.get("parent").is_none() {
458 if let Some(meta) = &meta {
459 validate_parent_type(issue_type, &target, key, &meta.subtask_types)?;
460 }
461 fields["parent"] = json!({"key": key});
462 }
463 }
464 if meta.is_some()
465 && issue_type.is_subtask()
466 && fields.get("parent").is_none_or(Value::is_null)
467 {
468 return Err(ApiError::InvalidInput(format!(
469 "Issue type {:?} requires --parent <STORY-OR-TASK>",
470 issue_type.name
471 )));
472 }
473 validate_required(fields, field_meta, true)?;
474 Ok(meta)
475 }
476
477 pub(super) async fn prepare_update(
478 &self,
479 key: &str,
480 fields: &mut Value,
481 update: &super::IssueUpdate<'_>,
482 custom: &[(String, Value)],
483 ) -> Result<Option<Fields>, ApiError> {
484 let priority = update
485 .priority
486 .filter(|_| !custom.iter().any(|(key, _)| key == "priority"));
487 if update.epic.is_some() && update.clear_epic {
488 return Err(ApiError::InvalidInput(
489 "--epic and --clear-epic cannot be used together".into(),
490 ));
491 }
492 let meta = self
493 .optional_metadata(&format!("issue/{key}/editmeta"))
494 .await?
495 .map(|meta| decode::<Fields>(meta["fields"].clone()))
496 .transpose()?;
497 if let Some(priority) = priority {
498 normalize_priority(fields, meta.as_ref(), priority)?;
499 }
500 normalize_named_arrays(fields, meta.as_ref(), custom)?;
501 let target_type = match update.issue_type {
504 Some(input) => {
505 if custom.iter().any(|(key, _)| key == "issuetype") {
506 return Err(ApiError::InvalidInput(
507 "--type conflicts with --field issuetype; choose one source".into(),
508 ));
509 }
510 let target = self.resolve_type_change(key, input, meta.as_ref()).await?;
511 fields["issuetype"] = json!({"id": target.id});
512 Some(target)
513 }
514 None => None,
515 };
516 if let Some(epic) = update.epic {
517 if key.eq_ignore_ascii_case(epic) {
518 return Err(ApiError::InvalidInput(
519 "An issue cannot be its own epic".into(),
520 ));
521 }
522 let target = self.issue_type_for_link(epic).await?;
523 if !target.is_epic() {
524 return Err(ApiError::InvalidInput(format!(
525 "--epic {epic} targets issue type {:?}, not an Epic",
526 target.name
527 )));
528 }
529 let issue_type = match &target_type {
530 Some(target) => target.clone(),
531 None => self.issue_type_for_link(key).await?,
532 };
533 self.set_epic(fields, meta.as_ref(), &issue_type, epic)
534 .await?;
535 }
536 if update.clear_epic {
537 let issue_type = match &target_type {
538 Some(target) => target.clone(),
539 None => self.issue_type_for_link(key).await?,
540 };
541 if !issue_type.can_belong_to_epic() {
542 return Err(ApiError::InvalidInput(format!(
543 "Cannot use --clear-epic on issue type {:?}; only standard issues can have epic membership cleared",
544 issue_type.name
545 )));
546 }
547 let field = self.epic_field(meta.as_ref()).await?;
548 validate_epic_override(fields, meta.as_ref(), &field)?;
549 fields[&field] = Value::Null;
550 }
551 validate_required(fields, meta.as_ref(), false)?;
552 Ok(meta)
553 }
554}
555
556fn validate_epic_override(
557 fields: &Value,
558 meta: Option<&Fields>,
559 field: &str,
560) -> Result<(), ApiError> {
561 let conflict = meta
562 .and_then(|meta| {
563 meta.iter()
564 .find(|(id, definition)| is_epic_field(id, definition) && fields.get(*id).is_some())
565 .map(|(id, _)| id.as_str())
566 })
567 .or_else(|| fields.get("parent").map(|_| "parent"))
568 .or_else(|| fields.get(field).map(|_| field));
569 if let Some(conflict) = conflict {
570 return Err(ApiError::InvalidInput(format!(
571 "Epic linkage conflicts with --field {conflict}; specify the relationship only once"
572 )));
573 }
574 Ok(())
575}
576
577fn validate_parent_type(
578 child: &IssueType,
579 parent: &IssueType,
580 key: &str,
581 subtask_types: &[Value],
582) -> Result<(), ApiError> {
583 if parent.is_subtask() {
584 return Err(ApiError::InvalidInput(format!(
585 "--parent {key} is a subtask and cannot have child issues; choose a Story or Task"
586 )));
587 }
588 if let (Some(child_level), Some(parent_level)) = (child.hierarchy_level, parent.hierarchy_level)
589 {
590 if child_level + 1 == parent_level {
591 return Ok(());
592 }
593 if parent_level != 0 {
594 return Err(ApiError::InvalidInput(format!(
595 "Issue type {:?} is not directly below parent type {:?} in the hierarchy",
596 child.name, parent.name
597 )));
598 }
599 } else if child.is_subtask() {
600 return Ok(());
601 }
602 Err(ApiError::InvalidInput(format!(
603 "--parent {key} requires a subtask issue type; selected {:?}. Choose --type from: {}",
604 child.name,
605 option_names(subtask_types)
606 )))
607}
608
609fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, ApiError> {
610 serde_json::from_value(value)
611 .map_err(|err| ApiError::Other(format!("Invalid Jira metadata: {err}")))
612}
613
614pub(super) fn encode_segment(value: &str) -> String {
615 let mut url = reqwest::Url::parse("http://localhost").expect("static URL");
616 url.path_segments_mut()
617 .expect("URL supports paths")
618 .push(value);
619 url.path().trim_start_matches('/').into()
620}
621
622fn named_id(id: &str, name: &str) -> Value {
623 if id.is_empty() {
624 json!({"name": name})
625 } else {
626 json!({"id": id})
627 }
628}
629
630pub(super) fn unresolved_option(input: &str) -> Value {
631 let input = input.trim();
632 if !input.is_empty() && input.bytes().all(|b| b.is_ascii_digit()) {
633 json!({"id": input})
634 } else {
635 json!({"name": input})
636 }
637}
638
639fn epic_link_candidates(fields: &Fields) -> Vec<String> {
640 let schema_matches = fields
641 .iter()
642 .filter(|(_, f)| f["schema"]["custom"] == EPIC_LINK_SCHEMA)
643 .map(|(id, _)| id.clone())
644 .collect::<Vec<_>>();
645 if schema_matches.is_empty() {
646 fields
647 .iter()
648 .filter(|(id, f)| is_epic_field(id, f))
649 .map(|(id, _)| id.clone())
650 .collect()
651 } else {
652 schema_matches
653 }
654}
655
656fn epic_candidates(fields: &Fields, api_version: u8) -> Vec<String> {
657 if api_version >= 3 && fields.contains_key("parent") {
658 return vec!["parent".into()];
659 }
660 let candidates = epic_link_candidates(fields);
661 if candidates.is_empty() && fields.contains_key("parent") {
662 vec!["parent".into()]
663 } else {
664 candidates
665 }
666}
667
668fn choose_epic_field(candidates: Vec<String>) -> Result<Option<String>, ApiError> {
669 match candidates.as_slice() {
670 [] => Ok(None),
671 [id] => Ok(Some(id.clone())),
672 _ => Err(ApiError::InvalidInput(format!(
673 "Ambiguous Epic Link fields: {}; use --field <ID>=<EPIC> to choose one",
674 candidates.join(", ")
675 ))),
676 }
677}
678
679fn find_epic_field(fields: &Fields) -> Result<Option<String>, ApiError> {
680 choose_epic_field(epic_link_candidates(fields))
681}
682
683fn is_epic_field(id: &str, field: &Value) -> bool {
684 field["schema"]["custom"] == EPIC_LINK_SCHEMA
685 || (id.starts_with("customfield_")
686 && field["name"]
687 .as_str()
688 .is_some_and(|name| name.eq_ignore_ascii_case("Epic Link")))
689}
690
691fn normalize_priority(
692 fields: &mut Value,
693 meta: Option<&Fields>,
694 input: &str,
695) -> Result<(), ApiError> {
696 let Some(meta) = meta else {
697 return Ok(());
698 };
699 let priority = meta.get("priority").ok_or_else(|| ApiError::InvalidInput(
700 "Priority is not available on this issue's create/edit screen; omit --priority to keep the project default or ask an administrator to enable it".into()
701 ))?;
702 if let Some(options) = priority["allowedValues"].as_array() {
703 let selected = &options[match_option("priority", input, options, OptionMatch::Priority)?];
704 fields["priority"] = named_id(
705 selected["id"].as_str().unwrap_or(""),
706 selected["name"].as_str().unwrap_or(input),
707 );
708 }
709 Ok(())
710}
711
712#[derive(Clone, Copy, PartialEq)]
715enum OptionMatch {
716 NameOrId,
717 Prefix,
718 Priority,
719}
720
721fn match_option(
722 field: &str,
723 input: &str,
724 options: &[Value],
725 style: OptionMatch,
726) -> Result<usize, ApiError> {
727 let input = input.trim();
728 let lower = input.to_lowercase();
729 for tier in 0..4 {
730 let candidates = options
731 .iter()
732 .enumerate()
733 .filter(|(_, option)| {
734 let name = option["name"].as_str().unwrap_or("");
735 !input.is_empty()
736 && match tier {
737 0 => name == input || option["id"].as_str() == Some(input),
738 1 => name.to_lowercase() == lower,
739 2 => {
740 style == OptionMatch::Priority
741 && priority_label(name).to_lowercase() == lower
742 }
743 _ => {
744 style != OptionMatch::NameOrId
745 && (name.to_lowercase().starts_with(&lower)
746 || (style == OptionMatch::Priority
747 && priority_label(name).to_lowercase().starts_with(&lower)))
748 }
749 }
750 })
751 .map(|(index, _)| index)
752 .collect::<Vec<_>>();
753 if let [index] = candidates.as_slice() {
754 return Ok(*index);
755 }
756 if candidates.len() > 1 {
757 return Err(option_error(field, input, options, true));
758 }
759 }
760 Err(option_error(field, input, options, false))
761}
762
763fn priority_label(name: &str) -> &str {
764 let name = name.trim();
765 let rest = name.trim_start_matches(|c: char| c.is_ascii_digit());
766 if rest.len() != name.len() {
767 let rest = rest.trim_start();
768 if let Some(label) = rest.strip_prefix(['-', '\u{2013}', '\u{2014}', ':', '.']) {
769 return label.trim();
770 }
771 }
772 name
773}
774
775fn option_error(field: &str, input: &str, options: &[Value], ambiguous: bool) -> ApiError {
776 ApiError::InvalidInput(format!(
777 "{} {field} {input:?}; valid: {}",
778 if ambiguous { "Ambiguous" } else { "Invalid" },
779 option_names(options)
780 ))
781}
782
783fn option_names(options: &[Value]) -> String {
784 if options.is_empty() {
785 return "(none available)".into();
786 }
787 options
788 .iter()
789 .map(|option| {
790 let name = option["name"].as_str().unwrap_or("(unnamed)");
791 match option["id"].as_str() {
792 Some(id) => format!("{name:?} (ID {id})"),
793 None => format!("{name:?}"),
794 }
795 })
796 .collect::<Vec<_>>()
797 .join(", ")
798}
799
800pub(super) fn create_error(
801 err: ApiError,
802 meta: Option<&CreateMetadata>,
803 draft: &super::IssueDraft<'_>,
804) -> ApiError {
805 write_error(
806 err,
807 meta.and_then(|m| m.fields.as_ref()),
808 draft.priority,
809 draft.epic.or(draft.parent),
810 )
811}
812
813pub(super) fn write_error(
814 err: ApiError,
815 meta: Option<&Fields>,
816 priority: Option<&str>,
817 link: Option<&str>,
818) -> ApiError {
819 if let ApiError::Api {
820 status: 400,
821 mut message,
822 } = err
823 {
824 if message.contains("priority")
825 && let Some(input) = priority
826 {
827 message.push_str(&format!("; priority {input:?}"));
828 if let Some(options) = meta
829 .and_then(|m| m.get("priority"))
830 .and_then(|p| p["allowedValues"].as_array())
831 {
832 message.push_str(&format!("; valid: {}", option_names(options)));
833 } else {
834 message.push_str("; use the exact project priority name or ID, or omit --priority to keep the default");
835 }
836 }
837 for field in ["components", "fixVersions"] {
838 if message.contains(field) {
839 if let Some(options) = meta
840 .and_then(|m| m.get(field))
841 .and_then(|f| f["allowedValues"].as_array())
842 {
843 message.push_str(&format!("; valid {field}: {}", option_names(options)));
844 } else {
845 message.push_str(&format!(
846 "; use an exact project {field} name or ID, or omit the field"
847 ));
848 }
849 }
850 }
851 if let Some(key) = link
852 && (message.contains("issuetype")
853 || message.contains("parent")
854 || meta.is_some_and(|m| {
855 m.iter()
856 .any(|(id, f)| is_epic_field(id, f) && message.contains(id))
857 }))
858 {
859 message.push_str(&format!("; to add a Story or Task to an Epic use --epic {key}; subtasks require --parent <STORY-OR-TASK>. Check that Epic Link/parent is enabled on the create/edit screen"));
860 }
861 ApiError::Api {
862 status: 400,
863 message,
864 }
865 } else {
866 err
867 }
868}
869
870fn type_json(t: &IssueType) -> Value {
871 json!({"id": if t.id.is_empty() { None } else { Some(&t.id) }, "name": t.name,
872 "subtask": t.is_subtask(), "hierarchyLevel": t.hierarchy_level})
873}