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
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use serde_json::json;
257
258 fn sample_option_field() -> JiraCustomField {
259 JiraCustomField {
260 id: "customfield_10001".into(),
261 name: "Sprint".into(),
262 field_type: JiraFieldType::Option,
263 required: false,
264 options: vec![
265 JiraFieldOption {
266 id: "1".into(),
267 name: "Sprint 1".into(),
268 },
269 JiraFieldOption {
270 id: "2".into(),
271 name: "Sprint 2".into(),
272 },
273 ],
274 }
275 }
276
277 #[test]
278 fn test_jira_option_transform() {
279 let field = sample_option_field();
280 assert_eq!(
281 field.transform_value(&json!("Sprint 1")),
282 json!({ "id": "1" })
283 );
284 }
285
286 #[test]
287 fn test_jira_option_case_insensitive() {
288 let field = sample_option_field();
289 assert_eq!(
290 field.transform_value(&json!("sprint 2")),
291 json!({ "id": "2" })
292 );
293 }
294
295 #[test]
296 fn test_jira_array_transform() {
297 let field = JiraCustomField {
298 id: "customfield_10002".into(),
299 name: "Fix Versions".into(),
300 field_type: JiraFieldType::Array,
301 required: false,
302 options: vec![
303 JiraFieldOption {
304 id: "v1".into(),
305 name: "1.0".into(),
306 },
307 JiraFieldOption {
308 id: "v2".into(),
309 name: "2.0".into(),
310 },
311 ],
312 };
313 assert_eq!(
314 field.transform_value(&json!(["1.0", "2.0"])),
315 json!([{ "id": "v1" }, { "id": "v2" }])
316 );
317 }
318
319 #[test]
320 fn test_metadata_single_project() {
321 let meta = JiraMetadata {
322 flavor: JiraFlavor::Cloud,
323 projects: [(
324 "PROJ".into(),
325 JiraProjectMetadata {
326 issue_types: vec![],
327 components: vec![],
328 priorities: vec![],
329 link_types: vec![],
330 custom_fields: vec![],
331 },
332 )]
333 .into_iter()
334 .collect(),
335 structures: vec![],
336 };
337 assert!(meta.is_single_project());
338 }
339
340 #[test]
341 fn test_metadata_all_issue_types_deduped() {
342 let meta = JiraMetadata {
343 flavor: JiraFlavor::Cloud,
344 projects: [
345 (
346 "PROJ".into(),
347 JiraProjectMetadata {
348 issue_types: vec![
349 JiraIssueType {
350 id: "1".into(),
351 name: "Task".into(),
352 subtask: false,
353 },
354 JiraIssueType {
355 id: "2".into(),
356 name: "Bug".into(),
357 subtask: false,
358 },
359 JiraIssueType {
360 id: "3".into(),
361 name: "Sub-task".into(),
362 subtask: true,
363 },
364 ],
365 components: vec![],
366 priorities: vec![],
367 link_types: vec![],
368 custom_fields: vec![],
369 },
370 ),
371 (
372 "INFRA".into(),
373 JiraProjectMetadata {
374 issue_types: vec![
375 JiraIssueType {
376 id: "1".into(),
377 name: "Task".into(),
378 subtask: false,
379 },
380 JiraIssueType {
381 id: "4".into(),
382 name: "Epic".into(),
383 subtask: false,
384 },
385 ],
386 components: vec![],
387 priorities: vec![],
388 link_types: vec![],
389 custom_fields: vec![],
390 },
391 ),
392 ]
393 .into_iter()
394 .collect(),
395 structures: vec![],
396 };
397 let types = meta.all_issue_types();
398 assert_eq!(types, vec!["Bug", "Epic", "Task"]); }
400
401 #[test]
402 fn jira_metadata_deserialises_without_structures_field() {
403 let raw = serde_json::json!({
407 "flavor": "cloud",
408 "projects": {}
409 });
410 let meta: JiraMetadata = serde_json::from_value(raw).unwrap();
411 assert!(meta.structures.is_empty());
412 }
413
414 #[test]
415 fn jira_metadata_roundtrips_structures_list() {
416 let meta = JiraMetadata {
417 flavor: JiraFlavor::Cloud,
418 projects: Default::default(),
419 structures: vec![
420 JiraStructureRef {
421 id: 7,
422 name: "Q1 Planning".into(),
423 description: Some("Top-level roadmap".into()),
424 },
425 JiraStructureRef {
426 id: 42,
427 name: "Sprint Board".into(),
428 description: None,
429 },
430 ],
431 };
432
433 let json = serde_json::to_value(&meta).unwrap();
434 assert_eq!(json["structures"][1].get("description"), None);
436
437 let restored: JiraMetadata = serde_json::from_value(json).unwrap();
438 assert_eq!(restored.structures, meta.structures);
439 }
440}