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