1use serde_json::{Map, Value};
10
11use crate::api::models::{
12 Attachment, Change, ChecklistItem, Comment, DictEntry, Entity, FieldChange, Issue, Link,
13 LinkKind, Person, RemoteLink, User, Worklog,
14};
15
16const KNOWN: &[&str] = &[
19 "key",
20 "commentWithExternalMessageCount",
24 "votes",
25 "votedBy",
26 "unique",
27 "boards",
28 "access",
29 "followers",
30 "checklistDone",
31 "checklistTotal",
32 "checklistItems",
33 "emailCreatedBy",
34 "emailTo",
35 "emailFrom",
36 "summary",
37 "status",
38 "type",
39 "priority",
40 "queue",
41 "assignee",
42 "createdBy",
43 "createdAt",
44 "updatedAt",
45 "description",
46 "commentWithoutExternalMessageCount",
47 "id",
48 "self",
49 "version",
50 "aliases",
51 "lastCommentUpdatedAt",
52 "statusStartTime",
53 "updatedBy",
54 "previousStatus",
55 "previousStatusLastAssignee",
56 "favorite",
57 "pendingReplyFrom",
58];
59
60fn label(value: Option<&Value>) -> Option<String> {
63 let value = value?;
64 if let Some(text) = value.as_str() {
65 return Some(text.to_owned());
66 }
67 for member in ["display", "key", "id"] {
68 if let Some(text) = value.get(member).and_then(Value::as_str) {
69 return Some(text.to_owned());
70 }
71 }
72 None
73}
74
75fn key_of(value: Option<&Value>) -> Option<String> {
86 value?
87 .get("key")
88 .and_then(Value::as_str)
89 .map(ToOwned::to_owned)
90}
91
92fn key_label(value: Option<&Value>) -> Option<String> {
93 let value = value?;
94 if let Some(key) = value.get("key").and_then(Value::as_str) {
95 return Some(key.to_owned());
96 }
97 label(Some(value))
98}
99
100fn user(value: Option<&Value>) -> Option<User> {
101 let value = value?;
102 Some(User {
103 id: value
104 .get("id")
105 .and_then(Value::as_str)
106 .unwrap_or_default()
107 .to_owned(),
108 login: value
109 .get("login")
110 .and_then(Value::as_str)
111 .map(ToOwned::to_owned),
112 display: value
113 .get("display")
114 .and_then(Value::as_str)
115 .map(ToOwned::to_owned),
116 })
117}
118
119fn timestamp(value: Option<&Value>) -> Option<jiff::Timestamp> {
127 let text = value?.as_str()?;
128
129 if let Ok(parsed) = text.parse::<jiff::Timestamp>() {
130 return Some(parsed);
131 }
132
133 let widened = widen_offset(text);
134 match widened.parse::<jiff::Timestamp>() {
135 Ok(parsed) => Some(parsed),
136 Err(error) => {
137 tracing::debug!(%text, %error, "unparseable timestamp, omitted");
138 None
139 }
140 }
141}
142
143fn widen_offset(text: &str) -> String {
145 let bytes = text.as_bytes();
146 let Some(sign_at) = bytes
147 .iter()
148 .rposition(|byte| *byte == b'+' || *byte == b'-')
149 else {
150 return text.to_owned();
151 };
152
153 let offset = &text[sign_at + 1..];
156 if offset.len() != 4 || !offset.bytes().all(|byte| byte.is_ascii_digit()) {
157 return text.to_owned();
158 }
159
160 format!("{}{}:{}", &text[..=sign_at], &offset[..2], &offset[2..])
161}
162
163fn link_kind(type_id: &str, inward: bool) -> LinkKind {
181 match (type_id, inward) {
182 ("subtask", true) => LinkKind::Parent,
183 ("subtask", false) => LinkKind::Subtask,
184 ("depends", true) => LinkKind::IsDependentBy,
185 ("depends", false) => LinkKind::Depends,
186 ("duplicates" | "duplicate", true) => LinkKind::Duplicates,
187 ("duplicates" | "duplicate", false) => LinkKind::IsDuplicatedBy,
188 ("epic", true) => LinkKind::HasEpic,
189 ("epic", false) => LinkKind::Epic,
190 ("relates", _) => LinkKind::Relates,
191 _ => LinkKind::Other,
192 }
193}
194
195#[must_use]
197pub fn link(value: &Value) -> Option<Link> {
198 let object = value.get("object")?;
199 let kind_object = value.get("type");
200 let inward = value.get("direction").and_then(Value::as_str) == Some("inward");
201
202 let type_id = kind_object
203 .and_then(|kind| kind.get("id"))
204 .and_then(Value::as_str)
205 .unwrap_or_default();
206
207 let relation = kind_object
210 .and_then(|kind| kind.get(if inward { "inward" } else { "outward" }))
211 .and_then(Value::as_str)
212 .map(str::to_lowercase);
213
214 Some(Link {
215 id: match value.get("id") {
220 Some(Value::String(id)) => id.clone(),
221 Some(other) => other.to_string(),
222 None => String::new(),
223 },
224 kind: link_kind(type_id, inward),
225 relation,
226 key: object.get("key").and_then(Value::as_str)?.to_owned(),
227 summary: label(object.get("display")),
228 status: label(object.get("status")),
229 })
230}
231
232#[must_use]
238pub fn remote_link(value: &Value) -> Option<RemoteLink> {
239 let object = value.get("object");
240 let inward = value.get("direction").and_then(Value::as_str) == Some("inward");
241
242 Some(RemoteLink {
243 id: match value.get("id")? {
244 Value::String(id) => id.clone(),
245 other => other.to_string(),
246 },
247 relation: value
248 .get("type")
249 .and_then(|kind| {
250 kind.get(if inward { "inward" } else { "outward" })
251 .or_else(|| kind.get("id"))
252 })
253 .and_then(Value::as_str)
254 .map(str::to_lowercase),
255 application: object
256 .and_then(|object| object.get("application"))
257 .and_then(|app| app.get("name").or_else(|| app.get("id")))
258 .and_then(Value::as_str)
259 .map(ToOwned::to_owned),
260 key: object
261 .and_then(|object| object.get("key"))
262 .and_then(Value::as_str)
263 .map(ToOwned::to_owned),
264 title: label(object.and_then(|object| object.get("display"))),
265 })
266}
267
268#[must_use]
273pub fn entity(value: &Value) -> Option<Entity> {
274 let fields = value.get("fields");
275 let field = |name: &str| fields.and_then(|fields| fields.get(name));
276
277 Some(Entity {
278 id: value.get("id").and_then(Value::as_str)?.to_owned(),
279 short_id: value.get("shortId").and_then(Value::as_i64),
280 entity_type: value
281 .get("entityType")
282 .and_then(Value::as_str)
283 .map(ToOwned::to_owned),
284 summary: field("summary")
285 .and_then(Value::as_str)
286 .unwrap_or_default()
287 .to_owned(),
288 status: label(field("entityStatus")),
289 lead: user(field("lead")),
290 start: field("start")
291 .and_then(Value::as_str)
292 .map(ToOwned::to_owned),
293 end: field("end").and_then(Value::as_str).map(ToOwned::to_owned),
294 description: field("description")
295 .and_then(Value::as_str)
296 .map(ToOwned::to_owned),
297 parent: parent_entity(field("parentEntity")),
298 version: value.get("version").and_then(Value::as_u64),
299 })
300}
301
302fn parent_entity(value: Option<&Value>) -> Option<String> {
309 fn id_of(value: &Value) -> Option<String> {
310 match value {
311 Value::String(id) => Some(id.clone()),
312 Value::Number(id) => Some(id.to_string()),
313 Value::Object(map) => map.get("id").and_then(id_of),
316 _ => None,
317 }
318 }
319
320 match value? {
321 Value::Object(map) => map.get("primary").and_then(id_of),
322 other => id_of(other),
323 }
324}
325
326#[must_use]
328pub fn attachment(value: &Value) -> Option<Attachment> {
329 Some(Attachment {
330 id: match value.get("id")? {
331 Value::String(text) => text.clone(),
332 other => other.to_string(),
333 },
334 name: value
335 .get("name")
336 .and_then(Value::as_str)
337 .unwrap_or_default()
338 .to_owned(),
339 size: value.get("size").and_then(Value::as_u64),
340 mimetype: value
341 .get("mimetype")
342 .and_then(Value::as_str)
343 .map(ToOwned::to_owned),
344 author: user(value.get("createdBy")),
345 created_at: timestamp(value.get("createdAt")),
346 content: value
347 .get("content")
348 .and_then(Value::as_str)
349 .map(ToOwned::to_owned),
350 })
351}
352
353#[must_use]
355pub fn comment(value: &Value) -> Option<Comment> {
356 Some(Comment {
357 id: value
358 .get("id")
359 .map(|id| match id {
360 Value::String(text) => text.clone(),
361 other => other.to_string(),
362 })
363 .unwrap_or_default(),
364 text: value
365 .get("text")
366 .and_then(Value::as_str)
367 .unwrap_or_default()
368 .to_owned(),
369 author: user(value.get("createdBy")),
370 created_at: timestamp(value.get("createdAt")),
371 })
372}
373
374#[must_use]
376pub fn worklog(value: &Value) -> Option<Worklog> {
377 Some(Worklog {
378 id: identifier(value.get("id"))?,
379 duration: value
380 .get("duration")
381 .and_then(Value::as_str)
382 .unwrap_or_default()
383 .to_owned(),
384 author: user(value.get("createdBy")),
385 start: timestamp(value.get("start")),
386 comment: value
387 .get("comment")
388 .and_then(Value::as_str)
389 .map(str::to_owned),
390 issue: key_label(value.get("issue")),
391 })
392}
393
394#[must_use]
396pub fn checklist_item(value: &Value) -> Option<ChecklistItem> {
397 Some(ChecklistItem {
398 id: identifier(value.get("id"))?,
399 text: value
400 .get("text")
401 .and_then(Value::as_str)
402 .unwrap_or_default()
403 .to_owned(),
404 checked: value
405 .get("checked")
406 .and_then(Value::as_bool)
407 .unwrap_or(false),
408 assignee: user(value.get("assignee")),
409 deadline: value
410 .get("deadline")
411 .and_then(|deadline| deadline.get("date").or(Some(deadline)))
412 .and_then(Value::as_str)
413 .map(str::to_owned),
414 })
415}
416
417fn identifier(value: Option<&Value>) -> Option<String> {
422 match value? {
423 Value::String(text) => Some(text.clone()),
424 Value::Number(number) => Some(number.to_string()),
425 _ => None,
426 }
427}
428
429fn custom_field_key(key: &str) -> String {
437 key.rsplit("--").next().unwrap_or(key).to_owned()
438}
439
440#[must_use]
445pub fn issue(value: &Value) -> Option<Issue> {
446 let object = value.as_object()?;
447
448 let mut extra = Map::new();
449 for (key, member) in object {
450 if KNOWN.contains(&key.as_str()) {
451 continue;
452 }
453 if member.is_null() {
456 continue;
457 }
458 let key = custom_field_key(key);
459 if let Some(text) = label(Some(member)) {
460 extra.insert(key, Value::String(text));
461 } else {
462 extra.insert(key, member.clone());
463 }
464 }
465
466 Some(Issue {
467 key: object.get("key")?.as_str()?.to_owned(),
468 summary: object
469 .get("summary")
470 .and_then(Value::as_str)
471 .unwrap_or_default()
472 .to_owned(),
473 status: label(object.get("status")),
474 status_key: key_of(object.get("status")),
475 issue_type: label(object.get("type")),
476 priority: label(object.get("priority")),
477 priority_key: key_of(object.get("priority")),
478 queue: key_label(object.get("queue")),
479 assignee: user(object.get("assignee")),
480 author: user(object.get("createdBy")),
481 created_at: timestamp(object.get("createdAt")),
482 updated_at: timestamp(object.get("updatedAt")),
483 description: object
484 .get("description")
485 .and_then(Value::as_str)
486 .map(ToOwned::to_owned),
487 links: Vec::new(),
488 comment_count: object
489 .get("commentWithoutExternalMessageCount")
490 .and_then(Value::as_u64)
491 .and_then(|count| u32::try_from(count).ok()),
492 extra,
493 })
494}
495
496#[must_use]
498pub fn change(value: &Value) -> Option<Change> {
499 let fields = value
500 .get("fields")
501 .and_then(Value::as_array)
502 .map(|entries| entries.iter().filter_map(field_change).collect())
503 .unwrap_or_default();
504
505 Some(Change {
506 id: identifier(value.get("id"))?,
507 at: timestamp(value.get("updatedAt")),
508 by: user(value.get("updatedBy")),
509 kind: value
510 .get("type")
511 .and_then(Value::as_str)
512 .unwrap_or("change")
513 .to_owned(),
514 fields,
515 })
516}
517
518fn field_change(value: &Value) -> Option<FieldChange> {
519 let field = value
524 .get("field")
525 .and_then(|field| field.get("id"))
526 .and_then(Value::as_str)
527 .map(custom_field_key)
528 .or_else(|| label(value.get("field")))?;
529
530 Some(FieldChange {
531 field,
532 from: changed_value(value.get("from")),
533 to: changed_value(value.get("to")),
534 })
535}
536
537fn changed_value(value: Option<&Value>) -> Option<String> {
545 match value? {
546 Value::Null => None,
547 Value::Bool(flag) => Some(flag.to_string()),
548 Value::Number(number) => Some(number.to_string()),
549 Value::Array(entries) => {
550 let joined: Vec<String> = entries
551 .iter()
552 .filter_map(|entry| changed_value(Some(entry)))
553 .collect();
554 (!joined.is_empty()).then(|| joined.join(", "))
555 }
556 other => label(Some(other)).or_else(|| identifier(other.get("id"))),
560 }
561}
562
563#[must_use]
569pub fn dict_entry(value: &Value) -> Option<DictEntry> {
570 Some(DictEntry {
571 key: value.get("key").and_then(Value::as_str)?.to_owned(),
572 name: value
573 .get("name")
574 .and_then(Value::as_str)
575 .unwrap_or_default()
576 .to_owned(),
577 description: value
578 .get("description")
579 .and_then(Value::as_str)
580 .filter(|text| !text.is_empty())
581 .map(ToOwned::to_owned),
582 order: value.get("order").and_then(Value::as_i64),
583 category: value
584 .get("type")
585 .and_then(Value::as_str)
586 .map(ToOwned::to_owned),
587 })
588}
589
590#[must_use]
597pub fn person(value: &Value) -> Option<Person> {
598 let login = value.get("login").and_then(Value::as_str)?.to_owned();
599 let display = value
600 .get("display")
601 .and_then(Value::as_str)
602 .filter(|text| !text.trim().is_empty())
603 .unwrap_or(&login)
604 .to_owned();
605
606 Some(Person {
607 uid: identifier(value.get("uid")).unwrap_or_default(),
608 display,
609 email: value
610 .get("email")
611 .and_then(Value::as_str)
612 .filter(|text| !text.is_empty())
613 .map(ToOwned::to_owned),
614 dismissed: value
615 .get("dismissed")
616 .and_then(Value::as_bool)
617 .unwrap_or(false),
618 external: value
619 .get("external")
620 .and_then(Value::as_bool)
621 .unwrap_or(false),
622 login,
623 })
624}
625
626#[cfg(test)]
627#[allow(clippy::expect_used)]
628mod tests {
629 use super::*;
630
631 #[test]
632 fn reference_objects_collapse_to_their_display_name() {
633 let value = serde_json::json!({"display": "In Progress", "key": "inProgress"});
634 assert_eq!(label(Some(&value)).as_deref(), Some("In Progress"));
635 }
636
637 #[test]
638 fn a_reference_without_a_display_falls_back_to_its_key() {
639 let value = serde_json::json!({"key": "PROJ", "id": "7"});
640 assert_eq!(label(Some(&value)).as_deref(), Some("PROJ"));
641 }
642
643 #[test]
645 fn compact_offsets_are_widened_before_parsing() {
646 let value = serde_json::json!("2026-08-27T10:00:00.000+0300");
647 let parsed = timestamp(Some(&value)).expect("parsed");
648 assert_eq!(parsed.to_string(), "2026-08-27T07:00:00Z");
649 }
650
651 #[test]
652 fn utc_timestamps_parse_unchanged() {
653 let value = serde_json::json!("2026-08-27T10:00:00Z");
654 assert!(timestamp(Some(&value)).is_some());
655 }
656
657 #[test]
660 fn an_unparseable_date_is_dropped_not_fatal() {
661 let value = serde_json::json!("yesterday");
662 assert!(timestamp(Some(&value)).is_none());
663 }
664
665 #[test]
666 fn unknown_members_become_custom_fields_and_nulls_are_skipped() {
667 let value = serde_json::json!({
668 "key": "PROJ-1",
669 "summary": "s",
670 "storyPoints": 3,
671 "sprint": {"display": "S-12", "id": "9"},
672 "emptyField": null,
673 });
674 let parsed = issue(&value).expect("parsed");
675
676 assert_eq!(parsed.extra.len(), 2);
677 assert_eq!(parsed.extra.get("sprint"), Some(&serde_json::json!("S-12")));
678 assert!(!parsed.extra.contains_key("emptyField"));
679 }
680
681 #[test]
682 fn a_payload_without_a_key_is_not_an_issue() {
683 assert!(issue(&serde_json::json!({"summary": "s"})).is_none());
684 }
685
686 fn subtask_link(direction: &str, key: &str) -> Value {
687 serde_json::json!({
688 "type": {"id": "subtask", "inward": "Is subtask for", "outward": "Is parent task for"},
689 "direction": direction,
690 "object": {"key": key, "display": "some issue"},
691 })
692 }
693
694 #[test]
698 fn a_russian_organisation_still_gets_real_link_types() {
699 let value = serde_json::json!({
700 "type": {"id": "relates", "inward": "Связана", "outward": "Связана"},
701 "direction": "outward",
702 "object": {"key": "LMS-1", "display": "какая-то задача"},
703 });
704
705 assert_eq!(link(&value).expect("link").kind, LinkKind::Relates);
706 }
707
708 #[test]
711 fn an_unknown_link_type_keeps_what_tracker_called_it() {
712 let value = serde_json::json!({
713 "type": {"id": "somethingNew", "inward": "Blocks release of", "outward": "x"},
714 "direction": "inward",
715 "object": {"key": "PROJ-5"},
716 });
717
718 let parsed = link(&value).expect("link");
719 assert_eq!(parsed.kind, LinkKind::Other);
720 assert_eq!(parsed.relation.as_deref(), Some("blocks release of"));
721 }
722
723 #[test]
726 fn tracker_bookkeeping_is_not_mistaken_for_custom_fields() {
727 let value = serde_json::json!({
728 "key": "PROJ-1",
729 "summary": "s",
730 "commentWithExternalMessageCount": 0,
731 "votes": 3,
732 "followers": [],
733 "storyPoints": 5,
734 });
735
736 let parsed = issue(&value).expect("parsed");
737 assert_eq!(parsed.extra.keys().collect::<Vec<_>>(), ["storyPoints"]);
738 }
739
740 #[test]
741 fn link_direction_decides_parent_from_subtask() {
742 assert_eq!(
743 link(&subtask_link("inward", "PROJ-9")).expect("link").kind,
744 LinkKind::Parent
745 );
746 assert_eq!(
747 link(&subtask_link("outward", "PROJ-12"))
748 .expect("link")
749 .kind,
750 LinkKind::Subtask
751 );
752 }
753
754 #[test]
764 fn the_end_that_depends_is_the_outward_one() {
765 let kind = |direction: &str| {
766 let value = serde_json::json!({
767 "id": 10,
768 "type": {
769 "id": "depends",
770 "inward": "блокирующая задача",
771 "outward": "зависит от",
772 },
773 "direction": direction,
774 "object": {"key": "PROJ-3", "display": "blocker"},
775 });
776 link(&value).expect("link").kind
777 };
778
779 assert_eq!(kind("outward"), LinkKind::Depends);
780 assert_eq!(kind("inward"), LinkKind::IsDependentBy);
781 }
782
783 #[test]
786 fn a_link_without_an_id_is_still_shown() {
787 let value = serde_json::json!({
788 "type": {"id": "relates", "inward": "Связана", "outward": "Связана"},
789 "direction": "outward",
790 "object": {"key": "PROJ-2"},
791 });
792
793 let parsed = link(&value).expect("link");
794 assert!(parsed.id.is_empty());
795 assert_eq!(parsed.key, "PROJ-2");
796 }
797
798 #[test]
799 fn a_queue_renders_as_its_key_not_its_display_name() {
800 let value = serde_json::json!({
801 "key": "PROJ-1",
802 "summary": "s",
803 "queue": {"key": "PROJ", "display": "Product"},
804 });
805 assert_eq!(
806 issue(&value).expect("parsed").queue.as_deref(),
807 Some("PROJ")
808 );
809 }
810
811 #[test]
815 fn a_custom_field_is_keyed_by_the_name_a_caller_can_type() {
816 let value = serde_json::json!({
817 "key": "PROJ-1",
818 "summary": "x",
819 "603bd9b6cdc7ba0d2f4b1a55--component": "backend",
820 "sprint": "S-12",
821 });
822
823 let parsed = issue(&value).expect("parses");
824
825 assert_eq!(
826 parsed.extra.get("component"),
827 Some(&serde_json::json!("backend"))
828 );
829 assert!(!parsed.extra.keys().any(|key| key.contains("--")));
830 assert_eq!(parsed.extra.get("sprint"), Some(&serde_json::json!("S-12")));
832 }
833}