use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraMetadata {
#[serde(default = "default_flavor")]
pub flavor: JiraFlavor,
pub projects: std::collections::HashMap<String, JiraProjectMetadata>,
#[serde(default)]
pub structures: Vec<JiraStructureRef>,
}
fn default_flavor() -> JiraFlavor {
JiraFlavor::Cloud
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum JiraFlavor {
Cloud,
SelfHosted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraProjectMetadata {
#[serde(default)]
pub issue_types: Vec<JiraIssueType>,
#[serde(default)]
pub components: Vec<JiraComponent>,
#[serde(default)]
pub priorities: Vec<JiraPriority>,
#[serde(default)]
pub link_types: Vec<JiraLinkType>,
#[serde(default)]
pub custom_fields: Vec<JiraCustomField>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraIssueType {
pub id: String,
pub name: String,
#[serde(default)]
pub subtask: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraComponent {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraPriority {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraLinkType {
pub id: String,
pub name: String,
#[serde(default)]
pub outward: Option<String>,
#[serde(default)]
pub inward: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraCustomField {
pub id: String,
pub name: String,
pub field_type: JiraFieldType,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub options: Vec<JiraFieldOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum JiraFieldType {
Option,
Array,
Number,
Date,
DateTime,
String,
Any,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraFieldOption {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct JiraStructureRef {
pub id: u64,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl JiraCustomField {
pub fn transform_value(&self, value: &serde_json::Value) -> serde_json::Value {
match self.field_type {
JiraFieldType::Option => {
if let Some(name) = value.as_str()
&& let Some(opt) = self
.options
.iter()
.find(|o| o.name.eq_ignore_ascii_case(name))
{
return serde_json::json!({ "id": opt.id });
}
value.clone()
}
JiraFieldType::Array => {
if let Some(names) = value.as_array() {
let ids: Vec<serde_json::Value> = names
.iter()
.filter_map(|n| {
let name = n.as_str()?;
self.options
.iter()
.find(|o| o.name.eq_ignore_ascii_case(name))
.map(|o| serde_json::json!({ "id": o.id }))
})
.collect();
return serde_json::json!(ids);
}
value.clone()
}
_ => value.clone(),
}
}
}
impl JiraMetadata {
pub fn is_single_project(&self) -> bool {
self.projects.len() == 1
}
pub fn project_keys(&self) -> Vec<&str> {
self.projects.keys().map(|k| k.as_str()).collect()
}
pub fn all_issue_types(&self) -> Vec<String> {
let mut types: Vec<String> = self
.projects
.values()
.flat_map(|p| {
p.issue_types
.iter()
.filter(|t| !t.subtask)
.map(|t| t.name.clone())
})
.collect();
types.sort();
types.dedup();
types
}
pub fn all_priorities(&self) -> Vec<String> {
let mut prios: Vec<String> = self
.projects
.values()
.flat_map(|p| p.priorities.iter().map(|pr| pr.name.clone()))
.collect();
prios.sort();
prios.dedup();
prios
}
pub fn all_components(&self) -> Vec<String> {
let mut comps: Vec<String> = self
.projects
.values()
.flat_map(|p| p.components.iter().map(|c| c.name.clone()))
.collect();
comps.sort();
comps.dedup();
comps
}
pub fn all_link_types(&self) -> Vec<String> {
let mut types: Vec<String> = self
.projects
.values()
.flat_map(|p| p.link_types.iter().map(|lt| lt.name.clone()))
.collect();
types.sort();
types.dedup();
types
}
pub fn all_custom_fields(&self) -> Vec<JiraCustomField> {
if self.projects.len() > MAX_ENRICHMENT_PROJECTS {
tracing::warn!(
project_count = self.projects.len(),
cap = MAX_ENRICHMENT_PROJECTS,
"Jira metadata carries more projects than the enrichment cap; \
customfield schema will only reflect the first {} (by sorted \
project key) — narrow the metadata loader's project \
selection (top-N by recency, allowlist, etc.) for full \
coverage.",
MAX_ENRICHMENT_PROJECTS
);
}
let mut project_keys: Vec<&String> = self.projects.keys().collect();
project_keys.sort();
let mut by_name: std::collections::HashMap<String, JiraCustomField> =
std::collections::HashMap::new();
for key in project_keys.iter().take(MAX_ENRICHMENT_PROJECTS) {
if let Some(proj) = self.projects.get(*key) {
for cf in &proj.custom_fields {
by_name.entry(cf.name.clone()).or_insert_with(|| cf.clone());
}
}
}
let mut result: Vec<JiraCustomField> = by_name.into_values().collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub fn custom_field_for_project(
&self,
project_key: &str,
field_name: &str,
) -> Option<&JiraCustomField> {
self.projects
.get(project_key)?
.custom_fields
.iter()
.find(|cf| cf.name == field_name)
}
pub fn custom_field_groups(&self) -> Vec<(String, Vec<JiraCustomField>)> {
if self.projects.len() > MAX_ENRICHMENT_PROJECTS {
tracing::warn!(
project_count = self.projects.len(),
cap = MAX_ENRICHMENT_PROJECTS,
"Jira metadata carries more projects than the enrichment cap; \
customfield groups will only reflect the first {} (by sorted \
project key).",
MAX_ENRICHMENT_PROJECTS
);
}
let mut project_keys: Vec<&String> = self.projects.keys().collect();
project_keys.sort();
let mut groups: std::collections::HashMap<String, Vec<JiraCustomField>> =
std::collections::HashMap::new();
for key in project_keys.iter().take(MAX_ENRICHMENT_PROJECTS) {
if let Some(proj) = self.projects.get(*key) {
for cf in &proj.custom_fields {
groups.entry(cf.name.clone()).or_default().push(cf.clone());
}
}
}
let mut result: Vec<(String, Vec<JiraCustomField>)> = groups.into_iter().collect();
result.sort_by(|a, b| a.0.cmp(&b.0));
result
}
}
pub const MAX_ENRICHMENT_PROJECTS: usize = 30;
#[derive(Debug, Clone)]
pub enum MetadataLoadStrategy {
Configured(Vec<String>),
MyProjects,
RecentActivity { days: u32 },
All,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_metadata_load_strategy_variants_construct() {
let _configured = MetadataLoadStrategy::Configured(vec!["PROJ".into()]);
let _my_projects = MetadataLoadStrategy::MyProjects;
let _recent = MetadataLoadStrategy::RecentActivity { days: 90 };
let _all = MetadataLoadStrategy::All;
}
fn sample_option_field() -> JiraCustomField {
JiraCustomField {
id: "customfield_10001".into(),
name: "Sprint".into(),
field_type: JiraFieldType::Option,
required: false,
options: vec![
JiraFieldOption {
id: "1".into(),
name: "Sprint 1".into(),
},
JiraFieldOption {
id: "2".into(),
name: "Sprint 2".into(),
},
],
}
}
#[test]
fn test_jira_option_transform() {
let field = sample_option_field();
assert_eq!(
field.transform_value(&json!("Sprint 1")),
json!({ "id": "1" })
);
}
#[test]
fn test_jira_option_case_insensitive() {
let field = sample_option_field();
assert_eq!(
field.transform_value(&json!("sprint 2")),
json!({ "id": "2" })
);
}
#[test]
fn test_jira_array_transform() {
let field = JiraCustomField {
id: "customfield_10002".into(),
name: "Fix Versions".into(),
field_type: JiraFieldType::Array,
required: false,
options: vec![
JiraFieldOption {
id: "v1".into(),
name: "1.0".into(),
},
JiraFieldOption {
id: "v2".into(),
name: "2.0".into(),
},
],
};
assert_eq!(
field.transform_value(&json!(["1.0", "2.0"])),
json!([{ "id": "v1" }, { "id": "v2" }])
);
}
#[test]
fn test_metadata_single_project() {
let meta = JiraMetadata {
flavor: JiraFlavor::Cloud,
projects: [(
"PROJ".into(),
JiraProjectMetadata {
issue_types: vec![],
components: vec![],
priorities: vec![],
link_types: vec![],
custom_fields: vec![],
},
)]
.into_iter()
.collect(),
structures: vec![],
};
assert!(meta.is_single_project());
}
#[test]
fn test_metadata_all_issue_types_deduped() {
let meta = JiraMetadata {
flavor: JiraFlavor::Cloud,
projects: [
(
"PROJ".into(),
JiraProjectMetadata {
issue_types: vec![
JiraIssueType {
id: "1".into(),
name: "Task".into(),
subtask: false,
},
JiraIssueType {
id: "2".into(),
name: "Bug".into(),
subtask: false,
},
JiraIssueType {
id: "3".into(),
name: "Sub-task".into(),
subtask: true,
},
],
components: vec![],
priorities: vec![],
link_types: vec![],
custom_fields: vec![],
},
),
(
"INFRA".into(),
JiraProjectMetadata {
issue_types: vec![
JiraIssueType {
id: "1".into(),
name: "Task".into(),
subtask: false,
},
JiraIssueType {
id: "4".into(),
name: "Epic".into(),
subtask: false,
},
],
components: vec![],
priorities: vec![],
link_types: vec![],
custom_fields: vec![],
},
),
]
.into_iter()
.collect(),
structures: vec![],
};
let types = meta.all_issue_types();
assert_eq!(types, vec!["Bug", "Epic", "Task"]); }
#[test]
fn jira_metadata_deserialises_without_structures_field() {
let raw = serde_json::json!({
"flavor": "cloud",
"projects": {}
});
let meta: JiraMetadata = serde_json::from_value(raw).unwrap();
assert!(meta.structures.is_empty());
}
#[test]
fn jira_metadata_roundtrips_structures_list() {
let meta = JiraMetadata {
flavor: JiraFlavor::Cloud,
projects: Default::default(),
structures: vec![
JiraStructureRef {
id: 7,
name: "Q1 Planning".into(),
description: Some("Top-level roadmap".into()),
},
JiraStructureRef {
id: 42,
name: "Sprint Board".into(),
description: None,
},
],
};
let json = serde_json::to_value(&meta).unwrap();
assert_eq!(json["structures"][1].get("description"), None);
let restored: JiraMetadata = serde_json::from_value(json).unwrap();
assert_eq!(restored.structures, meta.structures);
}
}