Skip to main content

omni_dev/atlassian/
transition_fields.rs

1//! Resolves `execute --set-field`/`--resolution` inputs against a transition's
2//! screen metadata into a JIRA transition `fields` payload, and reports whether
3//! the screen accepts an in-body comment.
4//!
5//! Transition-screen fields come back in the same [`EditMeta`] shape as the
6//! editmeta used by `jira write`, so scalar resolution reuses
7//! [`resolve_custom_fields`](crate::atlassian::custom_fields::resolve_custom_fields)
8//! verbatim.
9
10use std::collections::BTreeMap;
11
12use anyhow::{bail, Result};
13
14use crate::atlassian::custom_fields::resolve_custom_fields;
15use crate::atlassian::jira_types::EditMeta;
16
17/// The outcome of resolving transition-screen inputs.
18#[derive(Debug)]
19pub struct ResolvedTransitionFields {
20    /// API-shaped `fields` payload keyed by stable JIRA field id, ready to be
21    /// sent in the transition body. Empty when no field inputs were supplied.
22    pub fields: BTreeMap<String, serde_json::Value>,
23
24    /// Whether the transition screen exposes a `comment` field. Callers use
25    /// this to decide whether a supplied comment rides in the transition body
26    /// (`update.comment`, atomic) or falls back to a separate comment post.
27    pub comment_on_screen: bool,
28}
29
30/// Resolves `--set-field` scalars and an optional `--resolution` against a
31/// transition's screen [`EditMeta`].
32///
33/// `scalars` are name→YAML-value pairs (as parsed by
34/// [`parse_set_field`](crate::atlassian::custom_fields::parse_set_field)); they
35/// are dispatched by schema exactly as `jira write` does. `resolution`, when
36/// supplied, is added as the standard system shape `{"name": <value>}`, and
37/// collides (hard error) with a `--set-field resolution=…` targeting the same
38/// field id.
39pub fn resolve_transition_fields(
40    scalars: &BTreeMap<String, serde_yaml::Value>,
41    resolution: Option<&str>,
42    editmeta: &EditMeta,
43) -> Result<ResolvedTransitionFields> {
44    let mut fields = resolve_custom_fields(scalars, &[], editmeta)?;
45
46    if let Some(value) = resolution {
47        if fields.contains_key("resolution") {
48            bail!("`--resolution` collides with `--set-field resolution=…`; supply only one");
49        }
50        fields.insert(
51            "resolution".to_string(),
52            serde_json::json!({ "name": value }),
53        );
54    }
55
56    let comment_on_screen = editmeta.fields.contains_key("comment");
57
58    Ok(ResolvedTransitionFields {
59        fields,
60        comment_on_screen,
61    })
62}
63
64#[cfg(test)]
65#[allow(clippy::unwrap_used, clippy::expect_used)]
66mod tests {
67    use super::*;
68    use crate::atlassian::jira_types::{EditMetaField, EditMetaSchema};
69
70    /// Builds an [`EditMeta`] from `(id, name, kind, custom)` tuples.
71    fn meta(entries: &[(&str, &str, &str, Option<&str>)]) -> EditMeta {
72        let mut fields = BTreeMap::new();
73        for (id, name, kind, custom) in entries {
74            fields.insert(
75                (*id).to_string(),
76                EditMetaField {
77                    name: (*name).to_string(),
78                    schema: EditMetaSchema {
79                        kind: (*kind).to_string(),
80                        custom: custom.map(str::to_string),
81                        ..EditMetaSchema::default()
82                    },
83                    allowed_values: Vec::new(),
84                },
85            );
86        }
87        EditMeta { fields }
88    }
89
90    fn scalar(name: &str, value: &str) -> BTreeMap<String, serde_yaml::Value> {
91        let mut m = BTreeMap::new();
92        m.insert(
93            name.to_string(),
94            serde_yaml::Value::String(value.to_string()),
95        );
96        m
97    }
98
99    #[test]
100    fn resolution_becomes_name_shape() {
101        let editmeta = meta(&[("resolution", "Resolution", "resolution", None)]);
102        let resolved =
103            resolve_transition_fields(&BTreeMap::new(), Some("Fixed"), &editmeta).unwrap();
104        assert_eq!(
105            resolved.fields.get("resolution"),
106            Some(&serde_json::json!({ "name": "Fixed" }))
107        );
108    }
109
110    #[test]
111    fn set_field_resolves_against_screen_metadata() {
112        // A free-text custom field on the transition screen passes through.
113        let editmeta = meta(&[("customfield_100", "Reason", "string", None)]);
114        let resolved =
115            resolve_transition_fields(&scalar("Reason", "because"), None, &editmeta).unwrap();
116        assert_eq!(
117            resolved.fields.get("customfield_100"),
118            Some(&serde_json::json!("because"))
119        );
120    }
121
122    #[test]
123    fn resolution_collides_with_set_field_resolution() {
124        // Use an `option`-kind field at id `resolution` so the `--set-field`
125        // path resolves successfully and the collision guard is what fires
126        // (JIRA's real `resolution` type isn't scalar-resolvable, but a
127        // caller could still target the `resolution` id via `--set-field`).
128        let editmeta = meta(&[("resolution", "Resolution", "option", None)]);
129        let err =
130            resolve_transition_fields(&scalar("resolution", "Done"), Some("Fixed"), &editmeta)
131                .unwrap_err();
132        assert!(err.to_string().contains("collides"));
133    }
134
135    #[test]
136    fn comment_on_screen_detected() {
137        let with = meta(&[("comment", "Comment", "comment", None)]);
138        let without = meta(&[("resolution", "Resolution", "resolution", None)]);
139        assert!(
140            resolve_transition_fields(&BTreeMap::new(), None, &with)
141                .unwrap()
142                .comment_on_screen
143        );
144        assert!(
145            !resolve_transition_fields(&BTreeMap::new(), None, &without)
146                .unwrap()
147                .comment_on_screen
148        );
149    }
150
151    #[test]
152    fn empty_inputs_yield_empty_fields() {
153        let editmeta = meta(&[("resolution", "Resolution", "resolution", None)]);
154        let resolved = resolve_transition_fields(&BTreeMap::new(), None, &editmeta).unwrap();
155        assert!(resolved.fields.is_empty());
156    }
157
158    #[test]
159    fn unknown_set_field_errors_with_candidates() {
160        let editmeta = meta(&[("customfield_100", "Reason", "string", None)]);
161        let err =
162            resolve_transition_fields(&scalar("Nonexistent", "x"), None, &editmeta).unwrap_err();
163        let msg = err.to_string();
164        assert!(msg.contains("Nonexistent"));
165        assert!(msg.contains("Reason"));
166    }
167}