Skip to main content

omni_dev/atlassian/
custom_fields.rs

1//! Resolves frontmatter custom field values and body sections to a JIRA
2//! `fields` payload for write operations.
3//!
4//! Input:
5//! - Frontmatter scalar map keyed by human name (from `custom_fields:` in JFM).
6//! - Body sections parsed via [`crate::atlassian::document::split_custom_sections`].
7//! - [`EditMeta`] fetched for the target issue (or create target).
8//!
9//! Output: `{ field_id -> api_json }` ready to be merged into a PUT/POST.
10
11use std::collections::BTreeMap;
12
13use anyhow::{anyhow, bail, Context, Result};
14
15use crate::atlassian::client::{EditMeta, EditMetaField};
16use crate::atlassian::convert::markdown_to_adf;
17use crate::atlassian::document::CustomFieldSection;
18
19/// Plugin type URI for the rich-text "textarea" custom field. Used as the
20/// discriminator for dispatch in tests and elsewhere that distinguishes
21/// rich-text custom fields from scalar ones.
22#[cfg(test)]
23const CUSTOM_TEXTAREA: &str = "com.atlassian.jira.plugin.system.customfieldtypes:textarea";
24
25/// Resolves a mixed set of frontmatter scalars and body sections into an
26/// API-ready custom field map keyed by stable field ID.
27///
28/// - **Scalars** are dispatched by schema: option/radiobutton fields become
29///   `{"value": "..."}`, textfield/number/date pass through, rich-text
30///   fields are rejected (must use a body section instead).
31/// - **Sections** must reference rich-text fields; their markdown is
32///   converted to ADF via [`markdown_to_adf`].
33///
34/// Field names are looked up in [`EditMeta`]; entries already formatted as
35/// `customfield_<digits>` bypass the lookup. An unknown or ambiguous name
36/// produces an error naming the available editable fields.
37pub fn resolve_custom_fields(
38    scalars: &BTreeMap<String, serde_yaml::Value>,
39    sections: &[CustomFieldSection],
40    editmeta: &EditMeta,
41) -> Result<BTreeMap<String, serde_json::Value>> {
42    let mut out: BTreeMap<String, serde_json::Value> = BTreeMap::new();
43
44    for (key, value) in scalars {
45        let (id, field) = lookup_field(editmeta, key)?;
46        if field.is_adf_rich_text() {
47            bail!(
48                "Field '{}' ({}) is a rich-text field; set it via a `<!-- field: {} ({}) -->` section in the body, not as a scalar in frontmatter",
49                field.name, id, field.name, id
50            );
51        }
52        let payload = scalar_to_api_value(value, field).with_context(|| {
53            format!(
54                "Failed to convert custom field '{}' ({}) to API value",
55                field.name, id
56            )
57        })?;
58        out.insert(id, payload);
59    }
60
61    for section in sections {
62        let (id, field) = resolve_section_field(editmeta, section)?;
63        if !field.is_adf_rich_text() {
64            bail!(
65                "Field '{}' ({}) is not a rich-text field; put scalar values in `custom_fields:` frontmatter instead of a body section",
66                field.name, id
67            );
68        }
69        let adf = markdown_to_adf(&section.body).with_context(|| {
70            format!(
71                "Failed to convert body for custom field '{}' ({}) to ADF",
72                field.name, id
73            )
74        })?;
75        let value =
76            serde_json::to_value(&adf).context("Failed to serialize custom field ADF document")?;
77        out.insert(id, value);
78    }
79
80    Ok(out)
81}
82
83/// Looks up a field by id-or-name, preferring exact `customfield_<id>`
84/// matches before falling back to a name lookup.
85fn lookup_field<'a>(editmeta: &'a EditMeta, key: &str) -> Result<(String, &'a EditMetaField)> {
86    if looks_like_field_id(key) {
87        if let Some(field) = editmeta.fields.get(key) {
88            return Ok((key.to_string(), field));
89        }
90        // Fall through to name lookup in case the caller named a field
91        // literally "customfield_something".
92    }
93
94    let matches: Vec<_> = editmeta
95        .fields
96        .iter()
97        .filter(|(_, f)| f.name == key)
98        .collect();
99
100    match matches.as_slice() {
101        [] => {
102            let candidates = editmeta
103                .fields
104                .iter()
105                .map(|(id, f)| format!("  {id}  {}", f.name))
106                .collect::<Vec<_>>()
107                .join("\n");
108            Err(anyhow!(
109                "Unknown custom field '{key}'. Available editable fields on this issue:\n{candidates}"
110            ))
111        }
112        [(id, field)] => Ok(((*id).clone(), field)),
113        multi => {
114            let ids: Vec<_> = multi.iter().map(|(id, _)| id.as_str()).collect();
115            Err(anyhow!(
116                "Ambiguous custom field '{key}' matches multiple IDs: {}",
117                ids.join(", ")
118            ))
119        }
120    }
121}
122
123/// Resolves a body section's tag (which carries both name and id) against
124/// editmeta, trusting the id when both are present.
125fn resolve_section_field<'a>(
126    editmeta: &'a EditMeta,
127    section: &CustomFieldSection,
128) -> Result<(String, &'a EditMetaField)> {
129    if let Some(field) = editmeta.fields.get(&section.id) {
130        return Ok((section.id.clone(), field));
131    }
132    lookup_field(editmeta, &section.name)
133}
134
135fn looks_like_field_id(s: &str) -> bool {
136    s.starts_with("customfield_") && s[12..].chars().all(|c| c.is_ascii_digit())
137}
138
139/// Dispatches a scalar YAML value to the API shape expected for a given
140/// field schema.
141fn scalar_to_api_value(
142    value: &serde_yaml::Value,
143    field: &EditMetaField,
144) -> Result<serde_json::Value> {
145    let kind = field.schema.kind.as_str();
146    let custom = field.schema.custom.as_deref();
147    match (kind, custom) {
148        ("option", _) | ("string", Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons")) => {
149            let s = yaml_as_string(value).with_context(|| {
150                format!("expected a string for option field '{}'", field.name)
151            })?;
152            Ok(serde_json::json!({ "value": s }))
153        }
154        ("array", _) => {
155            let seq = value.as_sequence().ok_or_else(|| {
156                anyhow!("expected a sequence for array field '{}'", field.name)
157            })?;
158            let items: Vec<serde_json::Value> = seq
159                .iter()
160                .map(|v| {
161                    let s = yaml_as_string(v).with_context(|| {
162                        format!(
163                            "expected a string array element for field '{}'",
164                            field.name
165                        )
166                    })?;
167                    Ok(serde_json::json!({ "value": s }))
168                })
169                .collect::<Result<_>>()?;
170            Ok(serde_json::Value::Array(items))
171        }
172        ("string" | "number" | "date" | "datetime", _) => yaml_to_json(value),
173        (other, _) => Err(anyhow!(
174            "Unsupported field type '{other}' for '{}'; custom field writes currently support option, textfield, number, date, and array-of-options",
175            field.name
176        )),
177    }
178}
179
180fn yaml_as_string(value: &serde_yaml::Value) -> Result<String> {
181    match value {
182        serde_yaml::Value::String(s) => Ok(s.clone()),
183        serde_yaml::Value::Bool(b) => Ok(b.to_string()),
184        serde_yaml::Value::Number(n) => Ok(n.to_string()),
185        _ => Err(anyhow!("expected a scalar string value")),
186    }
187}
188
189fn yaml_to_json(value: &serde_yaml::Value) -> Result<serde_json::Value> {
190    let s = serde_yaml::to_string(value).context("Failed to convert YAML to JSON")?;
191    serde_json::to_value(serde_yaml::from_str::<serde_json::Value>(&s)?)
192        .context("Failed to convert YAML value to JSON")
193}
194
195/// Parses a `--set-field NAME=VALUE` argument into a `(name, value)` pair.
196///
197/// The value is parsed as YAML when possible so `--set-field "Points=8"`
198/// becomes a number and `--set-field "Enabled=true"` becomes a bool.
199/// Values that fail to parse as YAML fall back to plain strings.
200pub fn parse_set_field(input: &str) -> Result<(String, serde_yaml::Value)> {
201    let (name, value) = input
202        .split_once('=')
203        .ok_or_else(|| anyhow!("expected --set-field \"NAME=VALUE\", got '{input}'"))?;
204    let name = name.trim().to_string();
205    if name.is_empty() {
206        bail!("--set-field requires a non-empty name before '='");
207    }
208    let yaml_value = serde_yaml::from_str::<serde_yaml::Value>(value)
209        .unwrap_or_else(|_| serde_yaml::Value::String(value.to_string()));
210    Ok((name, yaml_value))
211}
212
213/// Merges CLI `--set-field` overrides into a frontmatter scalar map,
214/// with CLI overriding frontmatter on name conflicts.
215pub fn merge_set_field_overrides(
216    frontmatter: BTreeMap<String, serde_yaml::Value>,
217    overrides: Vec<(String, serde_yaml::Value)>,
218) -> BTreeMap<String, serde_yaml::Value> {
219    let mut merged = frontmatter;
220    for (name, value) in overrides {
221        merged.insert(name, value);
222    }
223    merged
224}
225
226#[cfg(test)]
227#[allow(clippy::unwrap_used, clippy::expect_used)]
228mod tests {
229    use super::*;
230    use crate::atlassian::client::{EditMetaField, EditMetaSchema};
231
232    fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
233        let mut fields = BTreeMap::new();
234        for (id, name, kind, custom) in entries {
235            fields.insert(
236                (*id).to_string(),
237                EditMetaField {
238                    name: (*name).to_string(),
239                    schema: EditMetaSchema {
240                        kind: (*kind).to_string(),
241                        custom: custom.map(str::to_string),
242                    },
243                },
244            );
245        }
246        EditMeta { fields }
247    }
248
249    #[test]
250    fn scalar_option_field_wraps_in_value_object() {
251        let editmeta = meta(&[(
252            "customfield_10001",
253            "Planned / Unplanned Work",
254            "option",
255            Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
256        )]);
257        let mut scalars = BTreeMap::new();
258        scalars.insert(
259            "Planned / Unplanned Work".to_string(),
260            serde_yaml::Value::String("Unplanned".to_string()),
261        );
262        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
263        assert_eq!(
264            out.get("customfield_10001").unwrap(),
265            &serde_json::json!({ "value": "Unplanned" })
266        );
267    }
268
269    #[test]
270    fn scalar_radiobutton_wraps_in_value_object() {
271        let editmeta = meta(&[(
272            "customfield_10002",
273            "Risk",
274            "string",
275            Some("com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons"),
276        )]);
277        let mut scalars = BTreeMap::new();
278        scalars.insert(
279            "Risk".to_string(),
280            serde_yaml::Value::String("High".to_string()),
281        );
282        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
283        assert_eq!(
284            out.get("customfield_10002").unwrap(),
285            &serde_json::json!({ "value": "High" })
286        );
287    }
288
289    #[test]
290    fn scalar_number_field_passes_through() {
291        let editmeta = meta(&[(
292            "customfield_10003",
293            "Story points",
294            "number",
295            Some("com.atlassian.jira.plugin.system.customfieldtypes:float"),
296        )]);
297        let mut scalars = BTreeMap::new();
298        scalars.insert(
299            "Story points".to_string(),
300            serde_yaml::Value::Number(8.into()),
301        );
302        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
303        assert_eq!(out.get("customfield_10003").unwrap(), &serde_json::json!(8));
304    }
305
306    #[test]
307    fn scalar_array_option_field_wraps_each_item() {
308        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
309        let mut scalars = BTreeMap::new();
310        scalars.insert(
311            "Components".to_string(),
312            serde_yaml::Value::Sequence(vec![
313                serde_yaml::Value::String("backend".to_string()),
314                serde_yaml::Value::String("auth".to_string()),
315            ]),
316        );
317        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
318        assert_eq!(
319            out.get("customfield_10004").unwrap(),
320            &serde_json::json!([{"value": "backend"}, {"value": "auth"}])
321        );
322    }
323
324    #[test]
325    fn scalar_to_rich_text_field_errors() {
326        let editmeta = meta(&[(
327            "customfield_19300",
328            "Acceptance Criteria",
329            "string",
330            Some(CUSTOM_TEXTAREA),
331        )]);
332        let mut scalars = BTreeMap::new();
333        scalars.insert(
334            "Acceptance Criteria".to_string(),
335            serde_yaml::Value::String("just text".to_string()),
336        );
337        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
338        assert!(err.to_string().contains("rich-text field"));
339    }
340
341    #[test]
342    fn rich_text_section_becomes_adf_payload() {
343        let editmeta = meta(&[(
344            "customfield_19300",
345            "Acceptance Criteria",
346            "string",
347            Some(CUSTOM_TEXTAREA),
348        )]);
349        let sections = [CustomFieldSection {
350            name: "Acceptance Criteria".to_string(),
351            id: "customfield_19300".to_string(),
352            body: "- Item one\n- Item two".to_string(),
353        }];
354        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
355        let value = out.get("customfield_19300").unwrap();
356        assert_eq!(value["type"], "doc");
357        assert_eq!(value["version"], 1);
358        assert!(value["content"].is_array());
359    }
360
361    #[test]
362    fn section_pointing_at_non_rich_text_field_errors() {
363        let editmeta = meta(&[("customfield_10001", "Priority Flag", "option", None)]);
364        let sections = [CustomFieldSection {
365            name: "Priority Flag".to_string(),
366            id: "customfield_10001".to_string(),
367            body: "Some text".to_string(),
368        }];
369        let err = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap_err();
370        assert!(err.to_string().contains("not a rich-text field"));
371    }
372
373    #[test]
374    fn unknown_field_name_errors_with_suggestions() {
375        let editmeta = meta(&[
376            ("customfield_1", "Alpha", "string", None),
377            ("customfield_2", "Beta", "string", None),
378        ]);
379        let mut scalars = BTreeMap::new();
380        scalars.insert("Gamma".to_string(), serde_yaml::Value::from("x"));
381        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
382        let msg = err.to_string();
383        assert!(msg.contains("Unknown custom field 'Gamma'"));
384        assert!(msg.contains("Alpha"));
385        assert!(msg.contains("Beta"));
386    }
387
388    #[test]
389    fn field_id_bypasses_name_lookup() {
390        let editmeta = meta(&[(
391            "customfield_10001",
392            "Planned / Unplanned Work",
393            "option",
394            None,
395        )]);
396        let mut scalars = BTreeMap::new();
397        scalars.insert(
398            "customfield_10001".to_string(),
399            serde_yaml::Value::String("Unplanned".to_string()),
400        );
401        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
402        assert_eq!(
403            out.get("customfield_10001").unwrap(),
404            &serde_json::json!({ "value": "Unplanned" })
405        );
406    }
407
408    #[test]
409    fn ambiguous_field_name_errors_listing_ids() {
410        let editmeta = meta(&[
411            ("customfield_1", "Duplicate", "string", None),
412            ("customfield_2", "Duplicate", "string", None),
413        ]);
414        let mut scalars = BTreeMap::new();
415        scalars.insert("Duplicate".to_string(), serde_yaml::Value::from("x"));
416        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
417        let msg = err.to_string();
418        assert!(msg.contains("Ambiguous"));
419        assert!(msg.contains("customfield_1"));
420        assert!(msg.contains("customfield_2"));
421    }
422
423    #[test]
424    fn array_field_requires_sequence_value() {
425        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
426        let mut scalars = BTreeMap::new();
427        scalars.insert(
428            "Components".to_string(),
429            serde_yaml::Value::String("not-a-sequence".to_string()),
430        );
431        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
432        assert!(format!("{err:#}").contains("expected a sequence"));
433    }
434
435    #[test]
436    fn array_element_must_be_scalar_string() {
437        let editmeta = meta(&[("customfield_10004", "Components", "array", None)]);
438        let mut scalars = BTreeMap::new();
439        scalars.insert(
440            "Components".to_string(),
441            serde_yaml::Value::Sequence(vec![serde_yaml::Value::Sequence(vec![
442                serde_yaml::Value::String("nested".to_string()),
443            ])]),
444        );
445        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
446        assert!(format!("{err:#}").contains("expected a scalar string value"));
447    }
448
449    #[test]
450    fn unsupported_schema_type_errors_with_field_name() {
451        let editmeta = meta(&[("customfield_20000", "Reporter", "user", None)]);
452        let mut scalars = BTreeMap::new();
453        scalars.insert("Reporter".to_string(), serde_yaml::Value::from("alice"));
454        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
455        let msg = format!("{err:#}");
456        assert!(msg.contains("Unsupported field type 'user'"));
457        assert!(msg.contains("Reporter"));
458    }
459
460    #[test]
461    fn option_field_accepts_bool_and_number_scalars() {
462        let editmeta = meta(&[
463            (
464                "customfield_bool",
465                "Toggle",
466                "option",
467                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
468            ),
469            (
470                "customfield_num",
471                "Number choice",
472                "option",
473                Some("com.atlassian.jira.plugin.system.customfieldtypes:select"),
474            ),
475        ]);
476        let mut scalars = BTreeMap::new();
477        scalars.insert("Toggle".to_string(), serde_yaml::Value::Bool(true));
478        scalars.insert(
479            "Number choice".to_string(),
480            serde_yaml::Value::Number(3.into()),
481        );
482        let out = resolve_custom_fields(&scalars, &[], &editmeta).unwrap();
483        assert_eq!(
484            out.get("customfield_bool").unwrap(),
485            &serde_json::json!({"value": "true"})
486        );
487        assert_eq!(
488            out.get("customfield_num").unwrap(),
489            &serde_json::json!({"value": "3"})
490        );
491    }
492
493    #[test]
494    fn option_field_rejects_non_scalar_value() {
495        let editmeta = meta(&[("customfield_opt", "Opt", "option", None)]);
496        let mut mapping = serde_yaml::Mapping::new();
497        mapping.insert(serde_yaml::Value::from("k"), serde_yaml::Value::from("v"));
498        let mut scalars = BTreeMap::new();
499        scalars.insert("Opt".to_string(), serde_yaml::Value::Mapping(mapping));
500        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
501        assert!(format!("{err:#}").contains("expected a scalar string value"));
502    }
503
504    #[test]
505    fn section_with_stale_id_falls_back_to_name_lookup() {
506        // editmeta has the field under a new id; the section tag carries an
507        // older id. Resolver should fall back to name lookup and find it.
508        let editmeta = meta(&[(
509            "customfield_NEW",
510            "Acceptance Criteria",
511            "string",
512            Some(CUSTOM_TEXTAREA),
513        )]);
514        let sections = [CustomFieldSection {
515            name: "Acceptance Criteria".to_string(),
516            id: "customfield_OLD".to_string(),
517            body: "body".to_string(),
518        }];
519        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
520        assert!(out.contains_key("customfield_NEW"));
521        assert!(!out.contains_key("customfield_OLD"));
522    }
523
524    #[test]
525    fn field_id_that_does_not_exist_falls_through_to_name_lookup() {
526        // A `customfield_<digits>` key that isn't in editmeta should still
527        // try a name lookup before erroring.
528        let editmeta = meta(&[("customfield_ACTUAL", "My Field", "string", None)]);
529        let mut scalars = BTreeMap::new();
530        scalars.insert("customfield_999".to_string(), serde_yaml::Value::from("x"));
531        let err = resolve_custom_fields(&scalars, &[], &editmeta).unwrap_err();
532        assert!(err.to_string().contains("Unknown custom field"));
533    }
534
535    // ── parse_set_field / merge_set_field_overrides ─────────────────
536
537    #[test]
538    fn parse_set_field_bare_string_value() {
539        let (name, value) = parse_set_field("Status=Open").unwrap();
540        assert_eq!(name, "Status");
541        assert_eq!(value, serde_yaml::Value::String("Open".to_string()));
542    }
543
544    #[test]
545    fn parse_set_field_numeric_value_becomes_number() {
546        let (_name, value) = parse_set_field("Points=8").unwrap();
547        assert_eq!(value, serde_yaml::Value::Number(8.into()));
548    }
549
550    #[test]
551    fn parse_set_field_bool_value_becomes_bool() {
552        let (_name, value) = parse_set_field("Enabled=true").unwrap();
553        assert_eq!(value, serde_yaml::Value::Bool(true));
554    }
555
556    #[test]
557    fn parse_set_field_preserves_spaces_in_name() {
558        let (name, value) = parse_set_field("Planned / Unplanned Work=Unplanned").unwrap();
559        assert_eq!(name, "Planned / Unplanned Work");
560        assert_eq!(value, serde_yaml::Value::String("Unplanned".to_string()));
561    }
562
563    #[test]
564    fn parse_set_field_equals_in_value_preserved() {
565        // Only the FIRST `=` splits name from value.
566        let (name, value) = parse_set_field("Formula=a=b+c").unwrap();
567        assert_eq!(name, "Formula");
568        assert_eq!(value, serde_yaml::Value::String("a=b+c".to_string()));
569    }
570
571    #[test]
572    fn parse_set_field_requires_equals() {
573        let err = parse_set_field("just-a-name").unwrap_err();
574        assert!(err.to_string().contains("expected --set-field"));
575    }
576
577    #[test]
578    fn parse_set_field_empty_name_errors() {
579        let err = parse_set_field("=value").unwrap_err();
580        assert!(err.to_string().contains("non-empty name"));
581    }
582
583    #[test]
584    fn merge_set_field_overrides_cli_wins() {
585        let mut frontmatter = BTreeMap::new();
586        frontmatter.insert(
587            "Priority".to_string(),
588            serde_yaml::Value::String("Low".to_string()),
589        );
590        frontmatter.insert(
591            "Keep".to_string(),
592            serde_yaml::Value::String("from-fm".to_string()),
593        );
594        let overrides = vec![(
595            "Priority".to_string(),
596            serde_yaml::Value::String("High".to_string()),
597        )];
598        let merged = merge_set_field_overrides(frontmatter, overrides);
599        assert_eq!(
600            merged.get("Priority"),
601            Some(&serde_yaml::Value::String("High".to_string()))
602        );
603        assert_eq!(
604            merged.get("Keep"),
605            Some(&serde_yaml::Value::String("from-fm".to_string()))
606        );
607    }
608
609    #[test]
610    fn merge_set_field_overrides_with_empty_overrides_preserves_frontmatter() {
611        let mut frontmatter = BTreeMap::new();
612        frontmatter.insert("K".to_string(), serde_yaml::Value::from("v"));
613        let merged = merge_set_field_overrides(frontmatter, vec![]);
614        assert_eq!(merged.len(), 1);
615        assert_eq!(
616            merged.get("K"),
617            Some(&serde_yaml::Value::String("v".to_string()))
618        );
619    }
620
621    #[test]
622    fn section_prefers_tag_id_over_name_lookup() {
623        // Name "Acceptance Criteria" matches two different IDs globally, but
624        // the section tag carries a specific ID so no ambiguity error.
625        let editmeta = meta(&[(
626            "customfield_19300",
627            "Acceptance Criteria",
628            "string",
629            Some(CUSTOM_TEXTAREA),
630        )]);
631        let sections = [CustomFieldSection {
632            name: "Acceptance Criteria".to_string(),
633            id: "customfield_19300".to_string(),
634            body: "body".to_string(),
635        }];
636        let out = resolve_custom_fields(&BTreeMap::new(), &sections, &editmeta).unwrap();
637        assert!(out.contains_key("customfield_19300"));
638    }
639}