1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct JiraMetadata {
11 #[serde(default = "default_flavor")]
13 pub flavor: JiraFlavor,
14 pub projects: std::collections::HashMap<String, JiraProjectMetadata>,
16 #[serde(default)]
25 pub structures: Vec<JiraStructureRef>,
26}
27
28fn default_flavor() -> JiraFlavor {
29 JiraFlavor::Cloud
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "snake_case")]
35pub enum JiraFlavor {
36 Cloud,
38 SelfHosted,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct JiraProjectMetadata {
45 #[serde(default)]
47 pub issue_types: Vec<JiraIssueType>,
48 #[serde(default)]
49 pub components: Vec<JiraComponent>,
50 #[serde(default)]
51 pub priorities: Vec<JiraPriority>,
52 #[serde(default)]
53 pub link_types: Vec<JiraLinkType>,
54 #[serde(default)]
55 pub custom_fields: Vec<JiraCustomField>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct JiraIssueType {
60 pub id: String,
61 pub name: String,
62 #[serde(default)]
64 pub subtask: bool,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct JiraComponent {
69 pub id: String,
70 pub name: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct JiraPriority {
75 pub id: String,
76 pub name: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct JiraLinkType {
81 pub id: String,
82 pub name: String,
83 #[serde(default)]
85 pub outward: Option<String>,
86 #[serde(default)]
88 pub inward: Option<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct JiraCustomField {
94 pub id: String,
96 pub name: String,
98 pub field_type: JiraFieldType,
99 #[serde(default)]
101 pub required: bool,
102 #[serde(default)]
104 pub options: Vec<JiraFieldOption>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108#[serde(rename_all = "snake_case")]
109pub enum JiraFieldType {
110 Option,
112 Array,
114 Number,
116 Date,
118 DateTime,
120 String,
122 Any,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct JiraFieldOption {
129 pub id: String,
130 pub name: String,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141pub struct JiraStructureRef {
142 pub id: u64,
143 pub name: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub description: Option<String>,
146}
147
148impl JiraCustomField {
149 pub fn transform_value(&self, value: &serde_json::Value) -> serde_json::Value {
155 match self.field_type {
156 JiraFieldType::Option => {
157 if let Some(name) = value.as_str()
158 && let Some(opt) = self
159 .options
160 .iter()
161 .find(|o| o.name.eq_ignore_ascii_case(name))
162 {
163 return serde_json::json!({ "id": opt.id });
164 }
165 value.clone()
166 }
167 JiraFieldType::Array => {
168 if let Some(names) = value.as_array() {
169 let ids: Vec<serde_json::Value> = names
170 .iter()
171 .filter_map(|n| {
172 let name = n.as_str()?;
173 self.options
174 .iter()
175 .find(|o| o.name.eq_ignore_ascii_case(name))
176 .map(|o| serde_json::json!({ "id": o.id }))
177 })
178 .collect();
179 return serde_json::json!(ids);
180 }
181 value.clone()
182 }
183 _ => value.clone(),
184 }
185 }
186}
187
188impl JiraMetadata {
189 pub fn is_single_project(&self) -> bool {
191 self.projects.len() == 1
192 }
193
194 pub fn project_keys(&self) -> Vec<&str> {
196 self.projects.keys().map(|k| k.as_str()).collect()
197 }
198
199 pub fn all_issue_types(&self) -> Vec<String> {
201 let mut types: Vec<String> = self
202 .projects
203 .values()
204 .flat_map(|p| {
205 p.issue_types
206 .iter()
207 .filter(|t| !t.subtask)
208 .map(|t| t.name.clone())
209 })
210 .collect();
211 types.sort();
212 types.dedup();
213 types
214 }
215
216 pub fn all_priorities(&self) -> Vec<String> {
218 let mut prios: Vec<String> = self
219 .projects
220 .values()
221 .flat_map(|p| p.priorities.iter().map(|pr| pr.name.clone()))
222 .collect();
223 prios.sort();
224 prios.dedup();
225 prios
226 }
227
228 pub fn all_components(&self) -> Vec<String> {
230 let mut comps: Vec<String> = self
231 .projects
232 .values()
233 .flat_map(|p| p.components.iter().map(|c| c.name.clone()))
234 .collect();
235 comps.sort();
236 comps.dedup();
237 comps
238 }
239
240 pub fn all_link_types(&self) -> Vec<String> {
242 let mut types: Vec<String> = self
243 .projects
244 .values()
245 .flat_map(|p| p.link_types.iter().map(|lt| lt.name.clone()))
246 .collect();
247 types.sort();
248 types.dedup();
249 types
250 }
251
252 pub fn all_custom_fields(&self) -> Vec<JiraCustomField> {
269 if self.projects.len() > MAX_ENRICHMENT_PROJECTS {
270 tracing::warn!(
271 project_count = self.projects.len(),
272 cap = MAX_ENRICHMENT_PROJECTS,
273 "Jira metadata carries more projects than the enrichment cap; \
274 customfield schema will only reflect the first {} (by sorted \
275 project key) — narrow the metadata loader's project \
276 selection (top-N by recency, allowlist, etc.) for full \
277 coverage.",
278 MAX_ENRICHMENT_PROJECTS
279 );
280 }
281 let mut project_keys: Vec<&String> = self.projects.keys().collect();
285 project_keys.sort();
286 let mut by_name: std::collections::HashMap<String, JiraCustomField> =
287 std::collections::HashMap::new();
288 for key in project_keys.iter().take(MAX_ENRICHMENT_PROJECTS) {
289 if let Some(proj) = self.projects.get(*key) {
290 for cf in &proj.custom_fields {
291 by_name.entry(cf.name.clone()).or_insert_with(|| cf.clone());
292 }
293 }
294 }
295 let mut result: Vec<JiraCustomField> = by_name.into_values().collect();
296 result.sort_by(|a, b| a.name.cmp(&b.name));
297 result
298 }
299
300 pub fn custom_field_for_project(
305 &self,
306 project_key: &str,
307 field_name: &str,
308 ) -> Option<&JiraCustomField> {
309 self.projects
310 .get(project_key)?
311 .custom_fields
312 .iter()
313 .find(|cf| cf.name == field_name)
314 }
315
316 pub fn custom_field_groups(&self) -> Vec<(String, Vec<JiraCustomField>)> {
322 if self.projects.len() > MAX_ENRICHMENT_PROJECTS {
323 tracing::warn!(
324 project_count = self.projects.len(),
325 cap = MAX_ENRICHMENT_PROJECTS,
326 "Jira metadata carries more projects than the enrichment cap; \
327 customfield groups will only reflect the first {} (by sorted \
328 project key).",
329 MAX_ENRICHMENT_PROJECTS
330 );
331 }
332 let mut project_keys: Vec<&String> = self.projects.keys().collect();
336 project_keys.sort();
337 let mut groups: std::collections::HashMap<String, Vec<JiraCustomField>> =
338 std::collections::HashMap::new();
339 for key in project_keys.iter().take(MAX_ENRICHMENT_PROJECTS) {
340 if let Some(proj) = self.projects.get(*key) {
341 for cf in &proj.custom_fields {
342 groups.entry(cf.name.clone()).or_default().push(cf.clone());
343 }
344 }
345 }
346 let mut result: Vec<(String, Vec<JiraCustomField>)> = groups.into_iter().collect();
347 result.sort_by(|a, b| a.0.cmp(&b.0));
348 result
349 }
350}
351
352pub const MAX_ENRICHMENT_PROJECTS: usize = 30;
358
359#[derive(Debug, Clone)]
366pub enum MetadataLoadStrategy {
367 Configured(Vec<String>),
370 MyProjects,
375 RecentActivity { days: u32 },
379 All,
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use serde_json::json;
390
391 #[test]
392 fn test_metadata_load_strategy_variants_construct() {
393 let _configured = MetadataLoadStrategy::Configured(vec!["PROJ".into()]);
397 let _my_projects = MetadataLoadStrategy::MyProjects;
398 let _recent = MetadataLoadStrategy::RecentActivity { days: 90 };
399 let _all = MetadataLoadStrategy::All;
400 }
401
402 fn sample_option_field() -> JiraCustomField {
403 JiraCustomField {
404 id: "customfield_10001".into(),
405 name: "Sprint".into(),
406 field_type: JiraFieldType::Option,
407 required: false,
408 options: vec![
409 JiraFieldOption {
410 id: "1".into(),
411 name: "Sprint 1".into(),
412 },
413 JiraFieldOption {
414 id: "2".into(),
415 name: "Sprint 2".into(),
416 },
417 ],
418 }
419 }
420
421 #[test]
422 fn test_jira_option_transform() {
423 let field = sample_option_field();
424 assert_eq!(
425 field.transform_value(&json!("Sprint 1")),
426 json!({ "id": "1" })
427 );
428 }
429
430 #[test]
431 fn test_jira_option_case_insensitive() {
432 let field = sample_option_field();
433 assert_eq!(
434 field.transform_value(&json!("sprint 2")),
435 json!({ "id": "2" })
436 );
437 }
438
439 #[test]
440 fn test_jira_array_transform() {
441 let field = JiraCustomField {
442 id: "customfield_10002".into(),
443 name: "Fix Versions".into(),
444 field_type: JiraFieldType::Array,
445 required: false,
446 options: vec![
447 JiraFieldOption {
448 id: "v1".into(),
449 name: "1.0".into(),
450 },
451 JiraFieldOption {
452 id: "v2".into(),
453 name: "2.0".into(),
454 },
455 ],
456 };
457 assert_eq!(
458 field.transform_value(&json!(["1.0", "2.0"])),
459 json!([{ "id": "v1" }, { "id": "v2" }])
460 );
461 }
462
463 #[test]
464 fn test_metadata_single_project() {
465 let meta = JiraMetadata {
466 flavor: JiraFlavor::Cloud,
467 projects: [(
468 "PROJ".into(),
469 JiraProjectMetadata {
470 issue_types: vec![],
471 components: vec![],
472 priorities: vec![],
473 link_types: vec![],
474 custom_fields: vec![],
475 },
476 )]
477 .into_iter()
478 .collect(),
479 structures: vec![],
480 };
481 assert!(meta.is_single_project());
482 }
483
484 #[test]
485 fn test_metadata_all_issue_types_deduped() {
486 let meta = JiraMetadata {
487 flavor: JiraFlavor::Cloud,
488 projects: [
489 (
490 "PROJ".into(),
491 JiraProjectMetadata {
492 issue_types: vec![
493 JiraIssueType {
494 id: "1".into(),
495 name: "Task".into(),
496 subtask: false,
497 },
498 JiraIssueType {
499 id: "2".into(),
500 name: "Bug".into(),
501 subtask: false,
502 },
503 JiraIssueType {
504 id: "3".into(),
505 name: "Sub-task".into(),
506 subtask: true,
507 },
508 ],
509 components: vec![],
510 priorities: vec![],
511 link_types: vec![],
512 custom_fields: vec![],
513 },
514 ),
515 (
516 "INFRA".into(),
517 JiraProjectMetadata {
518 issue_types: vec![
519 JiraIssueType {
520 id: "1".into(),
521 name: "Task".into(),
522 subtask: false,
523 },
524 JiraIssueType {
525 id: "4".into(),
526 name: "Epic".into(),
527 subtask: false,
528 },
529 ],
530 components: vec![],
531 priorities: vec![],
532 link_types: vec![],
533 custom_fields: vec![],
534 },
535 ),
536 ]
537 .into_iter()
538 .collect(),
539 structures: vec![],
540 };
541 let types = meta.all_issue_types();
542 assert_eq!(types, vec!["Bug", "Epic", "Task"]); }
544
545 #[test]
546 fn jira_metadata_deserialises_without_structures_field() {
547 let raw = serde_json::json!({
551 "flavor": "cloud",
552 "projects": {}
553 });
554 let meta: JiraMetadata = serde_json::from_value(raw).unwrap();
555 assert!(meta.structures.is_empty());
556 }
557
558 #[test]
559 fn jira_metadata_roundtrips_structures_list() {
560 let meta = JiraMetadata {
561 flavor: JiraFlavor::Cloud,
562 projects: Default::default(),
563 structures: vec![
564 JiraStructureRef {
565 id: 7,
566 name: "Q1 Planning".into(),
567 description: Some("Top-level roadmap".into()),
568 },
569 JiraStructureRef {
570 id: 42,
571 name: "Sprint Board".into(),
572 description: None,
573 },
574 ],
575 };
576
577 let json = serde_json::to_value(&meta).unwrap();
578 assert_eq!(json["structures"][1].get("description"), None);
580
581 let restored: JiraMetadata = serde_json::from_value(json).unwrap();
582 assert_eq!(restored.structures, meta.structures);
583 }
584}