Skip to main content

yt/fields/
plan.rs

1use chrono::NaiveDate;
2use serde_json::{Map, Value, json};
3
4use crate::error::Result;
5use crate::fields::resolve::{Resolved, ResolvedOp};
6use crate::fields::schema::{Kind, SchemaField};
7use crate::fields::usage;
8
9#[derive(Clone, Debug, PartialEq)]
10pub enum Operation {
11    /// One command applied to every issue in one request.
12    Command {
13        issues: Vec<String>,
14        command: String,
15    },
16    /// One typed REST update of one issue; `summary` says what it changes.
17    Update {
18        issue: String,
19        body: Value,
20        summary: String,
21    },
22}
23
24impl Operation {
25    pub fn describe(&self) -> String {
26        match self {
27            Self::Command { issues, command } => {
28                format!("{}: command \"{command}\"", issues.join(", "))
29            }
30            Self::Update { issue, summary, .. } => format!("{issue}: {summary}"),
31        }
32    }
33}
34
35/// Single-value sets of bundle-like fields go through commands (one request for many issues, workflows apply);
36/// typed values, multi-value sets and clears go through REST, where the value's form is exact.
37fn via_command(field: &SchemaField, op: &ResolvedOp) -> bool {
38    match op {
39        ResolvedOp::Add(_) | ResolvedOp::Remove(_) => true,
40        ResolvedOp::Clear => false,
41        ResolvedOp::Set(_) if field.multi => false,
42        ResolvedOp::Set(_) => matches!(
43            field.kind(),
44            Kind::Bundle | Kind::State | Kind::User | Kind::Other
45        ),
46    }
47}
48
49fn command_for(field: &SchemaField, op: &ResolvedOp) -> Option<String> {
50    match op {
51        ResolvedOp::Set(values) => values
52            .first()
53            .map(|value| format!("{} {value}", field.name)),
54        // A bare `NAME VALUE` replaces every value of a multi-value field.
55        ResolvedOp::Add(value) => Some(format!("add {} {value}", field.name)),
56        ResolvedOp::Remove(value) => Some(format!("remove {} {value}", field.name)),
57        ResolvedOp::Clear => None,
58    }
59}
60
61/// Operations that change existing issues: one REST update per issue, then one command per command change.
62pub fn edit_operations(
63    issues: &[String],
64    resolved: &[Resolved],
65    summary: Option<&str>,
66    description: Option<&str>,
67) -> Result<Vec<Operation>> {
68    let mut body = Map::new();
69    let mut notes = Vec::new();
70    if let Some(summary) = summary {
71        body.insert("summary".into(), json!(summary));
72        notes.push("set the summary".to_string());
73    }
74    if let Some(description) = description {
75        body.insert("description".into(), json!(description));
76        notes.push("set the description".to_string());
77    }
78    let mut fields = Vec::new();
79    for change in resolved
80        .iter()
81        .filter(|change| !via_command(&change.field, &change.op))
82    {
83        match &change.op {
84            ResolvedOp::Clear => {
85                fields.push(custom_field(&change.field, None)?);
86                notes.push(format!("clear {}", change.field.name));
87            }
88            ResolvedOp::Set(values) => {
89                fields.push(custom_field(&change.field, Some(values))?);
90                notes.push(format!(
91                    "set {} to {}",
92                    change.field.name,
93                    describe_values(&change.field, values)
94                ));
95            }
96            ResolvedOp::Add(_) | ResolvedOp::Remove(_) => {}
97        }
98    }
99    if !fields.is_empty() {
100        body.insert("customFields".into(), Value::Array(fields));
101    }
102    let mut operations = Vec::new();
103    if !body.is_empty() {
104        for issue in issues {
105            operations.push(Operation::Update {
106                issue: issue.clone(),
107                body: Value::Object(body.clone()),
108                summary: notes.join(", "),
109            });
110        }
111    }
112    for change in resolved
113        .iter()
114        .filter(|change| via_command(&change.field, &change.op))
115    {
116        if let Some(command) = command_for(&change.field, &change.op) {
117            operations.push(Operation::Command {
118                issues: issues.to_vec(),
119                command,
120            });
121        }
122    }
123    Ok(operations)
124}
125
126/// The REST body of a new issue, and the commands to run once it exists (state fields go through workflows).
127pub fn create_request(
128    project: &str,
129    summary: &str,
130    description: Option<&str>,
131    resolved: &[Resolved],
132) -> Result<(Value, Vec<String>)> {
133    let mut grouped: Vec<(&SchemaField, Vec<String>)> = Vec::new();
134    for change in resolved {
135        let values = match &change.op {
136            ResolvedOp::Set(values) => values.clone(),
137            ResolvedOp::Add(value) => vec![value.clone()],
138            ResolvedOp::Remove(_) | ResolvedOp::Clear => {
139                return Err(usage(format!(
140                    "{}: a new issue can only be given values (NAME=VALUE or NAME+=VALUE)",
141                    change.field.name
142                )));
143            }
144        };
145        match grouped
146            .iter_mut()
147            .find(|(field, _)| field.name == change.field.name)
148        {
149            Some((_, existing)) => existing.extend(values),
150            None => grouped.push((&change.field, values)),
151        }
152    }
153    let mut fields = Vec::new();
154    let mut commands = Vec::new();
155    for (field, values) in grouped {
156        if matches!(field.kind(), Kind::State | Kind::Other) {
157            let verb = if field.multi { "add " } else { "" };
158            commands.extend(
159                values
160                    .iter()
161                    .map(|value| format!("{verb}{} {value}", field.name)),
162            );
163        } else {
164            fields.push(custom_field(field, Some(&values))?);
165        }
166    }
167    let mut body = json!({
168        "project": {"shortName": project},
169        "summary": summary,
170    });
171    if let Some(description) = description {
172        body["description"] = json!(description);
173    }
174    if !fields.is_empty() {
175        body["customFields"] = Value::Array(fields);
176    }
177    Ok((body, commands))
178}
179
180/// One entry of `customFields` in a REST write; `None` clears the field.
181pub fn custom_field(field: &SchemaField, values: Option<&[String]>) -> Result<Value> {
182    let Some(write_type) = field.write_type() else {
183        return Err(usage(format!(
184            "{}: yt cannot write this field directly; use `yt issue command`",
185            field.name
186        )));
187    };
188    let value = match values {
189        None if field.multi => json!([]),
190        None => Value::Null,
191        Some(values) if field.multi => Value::Array(
192            values
193                .iter()
194                .map(|value| one_value(field, value))
195                .collect::<Result<_>>()?,
196        ),
197        Some(values) => one_value(
198            field,
199            values.first().map(String::as_str).unwrap_or_default(),
200        )?,
201    };
202    Ok(json!({"name": field.name, "$type": write_type, "value": value}))
203}
204
205fn one_value(field: &SchemaField, value: &str) -> Result<Value> {
206    let invalid = || {
207        usage(format!(
208            "{}: '{value}' cannot be sent as a {}",
209            field.name, field.type_id
210        ))
211    };
212    Ok(match field.kind() {
213        Kind::Bundle | Kind::State => json!({"name": value}),
214        Kind::User => json!({"login": value}),
215        Kind::Date => {
216            let date = NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| invalid())?;
217            json!(
218                date.and_hms_opt(0, 0, 0)
219                    .expect("midnight exists")
220                    .and_utc()
221                    .timestamp_millis()
222            )
223        }
224        Kind::DateTime => json!(value.parse::<i64>().map_err(|_| invalid())?),
225        Kind::Period => json!({"presentation": value}),
226        Kind::Integer => json!(value.parse::<i64>().map_err(|_| invalid())?),
227        Kind::Float => json!(value.parse::<f64>().map_err(|_| invalid())?),
228        Kind::String => json!(value),
229        Kind::Text => json!({"text": value}),
230        Kind::Other => return Err(invalid()),
231    })
232}
233
234/// Values for messages: a list for multi-value fields, date-times in local time.
235pub fn describe_values(field: &SchemaField, values: &[String]) -> String {
236    let shown: Vec<String> = values
237        .iter()
238        .map(|value| match (field.kind(), value.parse::<i64>()) {
239            (Kind::DateTime, Ok(millis)) => crate::time::local(millis),
240            _ => value.clone(),
241        })
242        .collect();
243    if field.multi {
244        format!("[{}]", shown.join(", "))
245    } else {
246        shown.join(", ")
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use serde_json::json;
253
254    use super::*;
255
256    fn field(name: &str, type_id: &str, multi: bool) -> SchemaField {
257        SchemaField {
258            name: name.into(),
259            aliases: vec![],
260            type_id: type_id.into(),
261            multi,
262            can_be_empty: true,
263            has_default: false,
264            values: vec![],
265        }
266    }
267
268    fn resolved(name: &str, type_id: &str, multi: bool, op: ResolvedOp) -> Resolved {
269        Resolved {
270            field: field(name, type_id, multi),
271            op,
272        }
273    }
274
275    fn issues() -> Vec<String> {
276        vec!["WEB-1".into(), "WEB-2".into()]
277    }
278
279    #[test]
280    fn rest_updates_come_first_then_one_command_per_change() {
281        let changes = [
282            resolved(
283                "Priority",
284                "enum",
285                false,
286                ResolvedOp::Set(vec!["Major".into()]),
287            ),
288            resolved(
289                "Fix versions",
290                "version",
291                true,
292                ResolvedOp::Set(vec!["1.0".into(), "1.1".into()]),
293            ),
294            resolved("Assignee", "user", false, ResolvedOp::Clear),
295            resolved("Labels", "enum", true, ResolvedOp::Add("ui".into())),
296            resolved("Labels", "enum", true, ResolvedOp::Remove("old".into())),
297            resolved(
298                "State",
299                "state",
300                false,
301                ResolvedOp::Set(vec!["In Progress".into()]),
302            ),
303        ];
304        let operations = edit_operations(&issues(), &changes, None, None).unwrap();
305        let body = json!({"customFields": [
306            {"name": "Fix versions", "$type": "MultiVersionIssueCustomField", "value": [{"name": "1.0"}, {"name": "1.1"}]},
307            {"name": "Assignee", "$type": "SingleUserIssueCustomField", "value": null}
308        ]});
309        let command = |text: &str| Operation::Command {
310            issues: issues(),
311            command: text.into(),
312        };
313        assert_eq!(
314            operations,
315            vec![
316                Operation::Update {
317                    issue: "WEB-1".into(),
318                    body: body.clone(),
319                    summary: "set Fix versions to [1.0, 1.1], clear Assignee".into()
320                },
321                Operation::Update {
322                    issue: "WEB-2".into(),
323                    body,
324                    summary: "set Fix versions to [1.0, 1.1], clear Assignee".into()
325                },
326                command("Priority Major"),
327                command("add Labels ui"),
328                command("remove Labels old"),
329                command("State In Progress"),
330            ]
331        );
332    }
333
334    #[test]
335    fn typed_values_go_through_rest() {
336        let changes = [
337            resolved(
338                "Due Date",
339                "date",
340                false,
341                ResolvedOp::Set(vec!["2026-10-10".into()]),
342            ),
343            resolved(
344                "Deadline",
345                "date and time",
346                false,
347                ResolvedOp::Set(vec!["1791633600000".into()]),
348            ),
349            resolved(
350                "Estimation",
351                "period",
352                false,
353                ResolvedOp::Set(vec!["1w 2d".into()]),
354            ),
355            resolved(
356                "Points",
357                "integer",
358                false,
359                ResolvedOp::Set(vec!["5".into()]),
360            ),
361            resolved(
362                "Notes",
363                "text",
364                false,
365                ResolvedOp::Set(vec!["two words".into()]),
366            ),
367        ];
368        let operations =
369            edit_operations(&["WEB-1".into()], &changes, Some("New"), Some("Text")).unwrap();
370        let Operation::Update { body, .. } = &operations[0] else {
371            panic!("expected an update: {operations:?}")
372        };
373        assert_eq!(
374            body,
375            &json!({
376                "summary": "New",
377                "description": "Text",
378                "customFields": [
379                    {"name": "Due Date", "$type": "DateIssueCustomField", "value": 1_791_590_400_000_i64},
380                    {"name": "Deadline", "$type": "DateIssueCustomField", "value": 1_791_633_600_000_i64},
381                    {"name": "Estimation", "$type": "PeriodIssueCustomField", "value": {"presentation": "1w 2d"}},
382                    {"name": "Points", "$type": "SimpleIssueCustomField", "value": 5},
383                    {"name": "Notes", "$type": "TextIssueCustomField", "value": {"text": "two words"}}
384                ]
385            })
386        );
387        assert_eq!(operations.len(), 1);
388    }
389
390    #[test]
391    fn unknown_fields_go_out_as_commands() {
392        let changes = [Resolved {
393            field: SchemaField::unknown("Custom"),
394            op: ResolvedOp::Set(vec!["Value with spaces".into()]),
395        }];
396        let operations = edit_operations(&["WEB-1".into()], &changes, None, None).unwrap();
397        assert_eq!(
398            operations,
399            vec![Operation::Command {
400                issues: vec!["WEB-1".into()],
401                command: "Custom Value with spaces".into()
402            }]
403        );
404        let clear = [Resolved {
405            field: SchemaField::unknown("Custom"),
406            op: ResolvedOp::Clear,
407        }];
408        assert!(edit_operations(&["WEB-1".into()], &clear, None, None).is_err());
409    }
410
411    #[test]
412    fn a_new_issue_gets_typed_fields_and_state_commands() {
413        let changes = [
414            resolved(
415                "Priority",
416                "enum",
417                false,
418                ResolvedOp::Set(vec!["Major".into()]),
419            ),
420            resolved(
421                "Fix versions",
422                "version",
423                true,
424                ResolvedOp::Add("1.0".into()),
425            ),
426            resolved(
427                "Fix versions",
428                "version",
429                true,
430                ResolvedOp::Add("1.1".into()),
431            ),
432            resolved(
433                "State",
434                "state",
435                false,
436                ResolvedOp::Set(vec!["In Progress".into()]),
437            ),
438        ];
439        let (body, commands) =
440            create_request("WEB", "Login fails", Some("Steps"), &changes).unwrap();
441        assert_eq!(
442            body,
443            json!({
444                "project": {"shortName": "WEB"},
445                "summary": "Login fails",
446                "description": "Steps",
447                "customFields": [
448                    {"name": "Priority", "$type": "SingleEnumIssueCustomField", "value": {"name": "Major"}},
449                    {"name": "Fix versions", "$type": "MultiVersionIssueCustomField", "value": [{"name": "1.0"}, {"name": "1.1"}]}
450                ]
451            })
452        );
453        assert_eq!(commands, ["State In Progress"]);
454        let remove = [resolved(
455            "Fix versions",
456            "version",
457            true,
458            ResolvedOp::Remove("1.0".into()),
459        )];
460        assert!(create_request("WEB", "x", None, &remove).is_err());
461    }
462
463    #[test]
464    fn a_new_issue_gets_every_value_of_a_multi_value_command_field() {
465        let changes = [
466            resolved("Custom", "", true, ResolvedOp::Add("a".into())),
467            resolved("Custom", "", true, ResolvedOp::Add("b".into())),
468        ];
469        let (_, commands) = create_request("WEB", "x", None, &changes).unwrap();
470        assert_eq!(commands, ["add Custom a", "add Custom b"]);
471    }
472
473    #[test]
474    fn operations_describe_themselves() {
475        let command = Operation::Command {
476            issues: issues(),
477            command: "Priority Major".into(),
478        };
479        assert_eq!(
480            command.describe(),
481            "WEB-1, WEB-2: command \"Priority Major\""
482        );
483    }
484}