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 Command {
13 issues: Vec<String>,
14 command: String,
15 },
16 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
35fn 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 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
61pub 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
126pub 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
180pub 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
234pub 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}