use serde_json::{Map, Value};
use crate::api::models::{
Attachment, Change, ChecklistItem, Comment, DictEntry, Entity, FieldChange, Issue, Link,
LinkKind, Person, RemoteLink, User, Worklog,
};
const KNOWN: &[&str] = &[
"key",
"commentWithExternalMessageCount",
"votes",
"votedBy",
"unique",
"boards",
"access",
"followers",
"checklistDone",
"checklistTotal",
"checklistItems",
"emailCreatedBy",
"emailTo",
"emailFrom",
"summary",
"status",
"type",
"priority",
"queue",
"assignee",
"createdBy",
"createdAt",
"updatedAt",
"description",
"commentWithoutExternalMessageCount",
"id",
"self",
"version",
"aliases",
"lastCommentUpdatedAt",
"statusStartTime",
"updatedBy",
"previousStatus",
"previousStatusLastAssignee",
"favorite",
"pendingReplyFrom",
];
fn label(value: Option<&Value>) -> Option<String> {
let value = value?;
if let Some(text) = value.as_str() {
return Some(text.to_owned());
}
for member in ["display", "key", "id"] {
if let Some(text) = value.get(member).and_then(Value::as_str) {
return Some(text.to_owned());
}
}
None
}
fn key_of(value: Option<&Value>) -> Option<String> {
value?
.get("key")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn key_label(value: Option<&Value>) -> Option<String> {
let value = value?;
if let Some(key) = value.get("key").and_then(Value::as_str) {
return Some(key.to_owned());
}
label(Some(value))
}
fn user(value: Option<&Value>) -> Option<User> {
let value = value?;
Some(User {
id: value
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
login: value
.get("login")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
display: value
.get("display")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
fn timestamp(value: Option<&Value>) -> Option<jiff::Timestamp> {
let text = value?.as_str()?;
if let Ok(parsed) = text.parse::<jiff::Timestamp>() {
return Some(parsed);
}
let widened = widen_offset(text);
match widened.parse::<jiff::Timestamp>() {
Ok(parsed) => Some(parsed),
Err(error) => {
tracing::debug!(%text, %error, "unparseable timestamp, omitted");
None
}
}
}
fn widen_offset(text: &str) -> String {
let bytes = text.as_bytes();
let Some(sign_at) = bytes
.iter()
.rposition(|byte| *byte == b'+' || *byte == b'-')
else {
return text.to_owned();
};
let offset = &text[sign_at + 1..];
if offset.len() != 4 || !offset.bytes().all(|byte| byte.is_ascii_digit()) {
return text.to_owned();
}
format!("{}{}:{}", &text[..=sign_at], &offset[..2], &offset[2..])
}
fn link_kind(type_id: &str, inward: bool) -> LinkKind {
match (type_id, inward) {
("subtask", true) => LinkKind::Parent,
("subtask", false) => LinkKind::Subtask,
("depends", true) => LinkKind::IsDependentBy,
("depends", false) => LinkKind::Depends,
("duplicates" | "duplicate", true) => LinkKind::Duplicates,
("duplicates" | "duplicate", false) => LinkKind::IsDuplicatedBy,
("epic", true) => LinkKind::HasEpic,
("epic", false) => LinkKind::Epic,
("relates", _) => LinkKind::Relates,
_ => LinkKind::Other,
}
}
#[must_use]
pub fn link(value: &Value) -> Option<Link> {
let object = value.get("object")?;
let kind_object = value.get("type");
let inward = value.get("direction").and_then(Value::as_str) == Some("inward");
let type_id = kind_object
.and_then(|kind| kind.get("id"))
.and_then(Value::as_str)
.unwrap_or_default();
let relation = kind_object
.and_then(|kind| kind.get(if inward { "inward" } else { "outward" }))
.and_then(Value::as_str)
.map(str::to_lowercase);
Some(Link {
id: match value.get("id") {
Some(Value::String(id)) => id.clone(),
Some(other) => other.to_string(),
None => String::new(),
},
kind: link_kind(type_id, inward),
relation,
key: object.get("key").and_then(Value::as_str)?.to_owned(),
summary: label(object.get("display")),
status: label(object.get("status")),
})
}
#[must_use]
pub fn remote_link(value: &Value) -> Option<RemoteLink> {
let object = value.get("object");
let inward = value.get("direction").and_then(Value::as_str) == Some("inward");
Some(RemoteLink {
id: match value.get("id")? {
Value::String(id) => id.clone(),
other => other.to_string(),
},
relation: value
.get("type")
.and_then(|kind| {
kind.get(if inward { "inward" } else { "outward" })
.or_else(|| kind.get("id"))
})
.and_then(Value::as_str)
.map(str::to_lowercase),
application: object
.and_then(|object| object.get("application"))
.and_then(|app| app.get("name").or_else(|| app.get("id")))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
key: object
.and_then(|object| object.get("key"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
title: label(object.and_then(|object| object.get("display"))),
})
}
#[must_use]
pub fn entity(value: &Value) -> Option<Entity> {
let fields = value.get("fields");
let field = |name: &str| fields.and_then(|fields| fields.get(name));
Some(Entity {
id: value.get("id").and_then(Value::as_str)?.to_owned(),
short_id: value.get("shortId").and_then(Value::as_i64),
entity_type: value
.get("entityType")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
summary: field("summary")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
status: label(field("entityStatus")),
lead: user(field("lead")),
start: field("start")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
end: field("end").and_then(Value::as_str).map(ToOwned::to_owned),
description: field("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parent: parent_entity(field("parentEntity")),
version: value.get("version").and_then(Value::as_u64),
})
}
fn parent_entity(value: Option<&Value>) -> Option<String> {
fn id_of(value: &Value) -> Option<String> {
match value {
Value::String(id) => Some(id.clone()),
Value::Number(id) => Some(id.to_string()),
Value::Object(map) => map.get("id").and_then(id_of),
_ => None,
}
}
match value? {
Value::Object(map) => map.get("primary").and_then(id_of),
other => id_of(other),
}
}
#[must_use]
pub fn attachment(value: &Value) -> Option<Attachment> {
Some(Attachment {
id: match value.get("id")? {
Value::String(text) => text.clone(),
other => other.to_string(),
},
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
size: value.get("size").and_then(Value::as_u64),
mimetype: value
.get("mimetype")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
author: user(value.get("createdBy")),
created_at: timestamp(value.get("createdAt")),
content: value
.get("content")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
#[must_use]
pub fn comment(value: &Value) -> Option<Comment> {
Some(Comment {
id: value
.get("id")
.map(|id| match id {
Value::String(text) => text.clone(),
other => other.to_string(),
})
.unwrap_or_default(),
text: value
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
author: user(value.get("createdBy")),
created_at: timestamp(value.get("createdAt")),
})
}
#[must_use]
pub fn worklog(value: &Value) -> Option<Worklog> {
Some(Worklog {
id: identifier(value.get("id"))?,
duration: value
.get("duration")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
author: user(value.get("createdBy")),
start: timestamp(value.get("start")),
comment: value
.get("comment")
.and_then(Value::as_str)
.map(str::to_owned),
issue: key_label(value.get("issue")),
})
}
#[must_use]
pub fn checklist_item(value: &Value) -> Option<ChecklistItem> {
Some(ChecklistItem {
id: identifier(value.get("id"))?,
text: value
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
checked: value
.get("checked")
.and_then(Value::as_bool)
.unwrap_or(false),
assignee: user(value.get("assignee")),
deadline: value
.get("deadline")
.and_then(|deadline| deadline.get("date").or(Some(deadline)))
.and_then(Value::as_str)
.map(str::to_owned),
})
}
fn identifier(value: Option<&Value>) -> Option<String> {
match value? {
Value::String(text) => Some(text.clone()),
Value::Number(number) => Some(number.to_string()),
_ => None,
}
}
fn custom_field_key(key: &str) -> String {
key.rsplit("--").next().unwrap_or(key).to_owned()
}
#[must_use]
pub fn issue(value: &Value) -> Option<Issue> {
let object = value.as_object()?;
let mut extra = Map::new();
for (key, member) in object {
if KNOWN.contains(&key.as_str()) {
continue;
}
if member.is_null() {
continue;
}
let key = custom_field_key(key);
if let Some(text) = label(Some(member)) {
extra.insert(key, Value::String(text));
} else {
extra.insert(key, member.clone());
}
}
Some(Issue {
key: object.get("key")?.as_str()?.to_owned(),
summary: object
.get("summary")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
status: label(object.get("status")),
status_key: key_of(object.get("status")),
issue_type: label(object.get("type")),
priority: label(object.get("priority")),
priority_key: key_of(object.get("priority")),
queue: key_label(object.get("queue")),
assignee: user(object.get("assignee")),
author: user(object.get("createdBy")),
created_at: timestamp(object.get("createdAt")),
updated_at: timestamp(object.get("updatedAt")),
description: object
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
links: Vec::new(),
comment_count: object
.get("commentWithoutExternalMessageCount")
.and_then(Value::as_u64)
.and_then(|count| u32::try_from(count).ok()),
extra,
})
}
#[must_use]
pub fn change(value: &Value) -> Option<Change> {
let fields = value
.get("fields")
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(field_change).collect())
.unwrap_or_default();
Some(Change {
id: identifier(value.get("id"))?,
at: timestamp(value.get("updatedAt")),
by: user(value.get("updatedBy")),
kind: value
.get("type")
.and_then(Value::as_str)
.unwrap_or("change")
.to_owned(),
fields,
})
}
fn field_change(value: &Value) -> Option<FieldChange> {
let field = value
.get("field")
.and_then(|field| field.get("id"))
.and_then(Value::as_str)
.map(custom_field_key)
.or_else(|| label(value.get("field")))?;
Some(FieldChange {
field,
from: changed_value(value.get("from")),
to: changed_value(value.get("to")),
})
}
fn changed_value(value: Option<&Value>) -> Option<String> {
match value? {
Value::Null => None,
Value::Bool(flag) => Some(flag.to_string()),
Value::Number(number) => Some(number.to_string()),
Value::Array(entries) => {
let joined: Vec<String> = entries
.iter()
.filter_map(|entry| changed_value(Some(entry)))
.collect();
(!joined.is_empty()).then(|| joined.join(", "))
}
other => label(Some(other)).or_else(|| identifier(other.get("id"))),
}
}
#[must_use]
pub fn dict_entry(value: &Value) -> Option<DictEntry> {
Some(DictEntry {
key: value.get("key").and_then(Value::as_str)?.to_owned(),
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
description: value
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned),
order: value.get("order").and_then(Value::as_i64),
category: value
.get("type")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
}
#[must_use]
pub fn person(value: &Value) -> Option<Person> {
let login = value.get("login").and_then(Value::as_str)?.to_owned();
let display = value
.get("display")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.unwrap_or(&login)
.to_owned();
Some(Person {
uid: identifier(value.get("uid")).unwrap_or_default(),
display,
email: value
.get("email")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned),
dismissed: value
.get("dismissed")
.and_then(Value::as_bool)
.unwrap_or(false),
external: value
.get("external")
.and_then(Value::as_bool)
.unwrap_or(false),
login,
})
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn reference_objects_collapse_to_their_display_name() {
let value = serde_json::json!({"display": "In Progress", "key": "inProgress"});
assert_eq!(label(Some(&value)).as_deref(), Some("In Progress"));
}
#[test]
fn a_reference_without_a_display_falls_back_to_its_key() {
let value = serde_json::json!({"key": "PROJ", "id": "7"});
assert_eq!(label(Some(&value)).as_deref(), Some("PROJ"));
}
#[test]
fn compact_offsets_are_widened_before_parsing() {
let value = serde_json::json!("2026-08-27T10:00:00.000+0300");
let parsed = timestamp(Some(&value)).expect("parsed");
assert_eq!(parsed.to_string(), "2026-08-27T07:00:00Z");
}
#[test]
fn utc_timestamps_parse_unchanged() {
let value = serde_json::json!("2026-08-27T10:00:00Z");
assert!(timestamp(Some(&value)).is_some());
}
#[test]
fn an_unparseable_date_is_dropped_not_fatal() {
let value = serde_json::json!("yesterday");
assert!(timestamp(Some(&value)).is_none());
}
#[test]
fn unknown_members_become_custom_fields_and_nulls_are_skipped() {
let value = serde_json::json!({
"key": "PROJ-1",
"summary": "s",
"storyPoints": 3,
"sprint": {"display": "S-12", "id": "9"},
"emptyField": null,
});
let parsed = issue(&value).expect("parsed");
assert_eq!(parsed.extra.len(), 2);
assert_eq!(parsed.extra.get("sprint"), Some(&serde_json::json!("S-12")));
assert!(!parsed.extra.contains_key("emptyField"));
}
#[test]
fn a_payload_without_a_key_is_not_an_issue() {
assert!(issue(&serde_json::json!({"summary": "s"})).is_none());
}
fn subtask_link(direction: &str, key: &str) -> Value {
serde_json::json!({
"type": {"id": "subtask", "inward": "Is subtask for", "outward": "Is parent task for"},
"direction": direction,
"object": {"key": key, "display": "some issue"},
})
}
#[test]
fn a_russian_organisation_still_gets_real_link_types() {
let value = serde_json::json!({
"type": {"id": "relates", "inward": "Связана", "outward": "Связана"},
"direction": "outward",
"object": {"key": "LMS-1", "display": "какая-то задача"},
});
assert_eq!(link(&value).expect("link").kind, LinkKind::Relates);
}
#[test]
fn an_unknown_link_type_keeps_what_tracker_called_it() {
let value = serde_json::json!({
"type": {"id": "somethingNew", "inward": "Blocks release of", "outward": "x"},
"direction": "inward",
"object": {"key": "PROJ-5"},
});
let parsed = link(&value).expect("link");
assert_eq!(parsed.kind, LinkKind::Other);
assert_eq!(parsed.relation.as_deref(), Some("blocks release of"));
}
#[test]
fn tracker_bookkeeping_is_not_mistaken_for_custom_fields() {
let value = serde_json::json!({
"key": "PROJ-1",
"summary": "s",
"commentWithExternalMessageCount": 0,
"votes": 3,
"followers": [],
"storyPoints": 5,
});
let parsed = issue(&value).expect("parsed");
assert_eq!(parsed.extra.keys().collect::<Vec<_>>(), ["storyPoints"]);
}
#[test]
fn link_direction_decides_parent_from_subtask() {
assert_eq!(
link(&subtask_link("inward", "PROJ-9")).expect("link").kind,
LinkKind::Parent
);
assert_eq!(
link(&subtask_link("outward", "PROJ-12"))
.expect("link")
.kind,
LinkKind::Subtask
);
}
#[test]
fn the_end_that_depends_is_the_outward_one() {
let kind = |direction: &str| {
let value = serde_json::json!({
"id": 10,
"type": {
"id": "depends",
"inward": "блокирующая задача",
"outward": "зависит от",
},
"direction": direction,
"object": {"key": "PROJ-3", "display": "blocker"},
});
link(&value).expect("link").kind
};
assert_eq!(kind("outward"), LinkKind::Depends);
assert_eq!(kind("inward"), LinkKind::IsDependentBy);
}
#[test]
fn a_link_without_an_id_is_still_shown() {
let value = serde_json::json!({
"type": {"id": "relates", "inward": "Связана", "outward": "Связана"},
"direction": "outward",
"object": {"key": "PROJ-2"},
});
let parsed = link(&value).expect("link");
assert!(parsed.id.is_empty());
assert_eq!(parsed.key, "PROJ-2");
}
#[test]
fn a_queue_renders_as_its_key_not_its_display_name() {
let value = serde_json::json!({
"key": "PROJ-1",
"summary": "s",
"queue": {"key": "PROJ", "display": "Product"},
});
assert_eq!(
issue(&value).expect("parsed").queue.as_deref(),
Some("PROJ")
);
}
#[test]
fn a_custom_field_is_keyed_by_the_name_a_caller_can_type() {
let value = serde_json::json!({
"key": "PROJ-1",
"summary": "x",
"603bd9b6cdc7ba0d2f4b1a55--component": "backend",
"sprint": "S-12",
});
let parsed = issue(&value).expect("parses");
assert_eq!(
parsed.extra.get("component"),
Some(&serde_json::json!("backend"))
);
assert!(!parsed.extra.keys().any(|key| key.contains("--")));
assert_eq!(parsed.extra.get("sprint"), Some(&serde_json::json!("S-12")));
}
}