Skip to main content

devboy_jira/
metadata.rs

1//! Jira provider metadata types for dynamic schema enrichment.
2
3use serde::{Deserialize, Serialize};
4
5/// Metadata for Jira project(s), used for dynamic schema enrichment.
6///
7/// Supports both single-project and multi-project configurations.
8/// Multi-project unions enum values across projects.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct JiraMetadata {
11    /// Jira flavor (affects API version and auth).
12    #[serde(default = "default_flavor")]
13    pub flavor: JiraFlavor,
14    /// Per-project metadata keyed by project key (e.g., "PROJ").
15    pub projects: std::collections::HashMap<String, JiraProjectMetadata>,
16    /// Structures the integration user can see across the Jira instance.
17    ///
18    /// `/rest/structure/2.0/structure` is **not** keyed by Jira project — it
19    /// returns every structure the caller has read access to. Placed here
20    /// (on the instance-level metadata) rather than on `JiraProjectMetadata`.
21    /// Empty when the Structure plugin is not installed or the user has no
22    /// read access; that is the graceful-degrade signal the schema enricher
23    /// keys on to decide whether to enrich Structure tools.
24    #[serde(default)]
25    pub structures: Vec<JiraStructureRef>,
26}
27
28fn default_flavor() -> JiraFlavor {
29    JiraFlavor::Cloud
30}
31
32/// Jira deployment flavor.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "snake_case")]
35pub enum JiraFlavor {
36    /// Jira Cloud (API v3, ADF format, accountId-based users)
37    Cloud,
38    /// Jira Self-Hosted / Data Center (API v2, plain text, username-based users)
39    SelfHosted,
40}
41
42/// Metadata for a single Jira project.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct JiraProjectMetadata {
45    /// Available issue types (filter out subtask types for create_issue).
46    #[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    /// Whether this is a subtask type (exclude from create_issue enum).
63    #[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    /// Outward description (e.g., "blocks").
84    #[serde(default)]
85    pub outward: Option<String>,
86    /// Inward description (e.g., "is blocked by").
87    #[serde(default)]
88    pub inward: Option<String>,
89}
90
91/// Jira custom field definition.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct JiraCustomField {
94    /// Field ID in Jira (e.g., "customfield_10001").
95    pub id: String,
96    /// Human-readable name.
97    pub name: String,
98    pub field_type: JiraFieldType,
99    /// Whether this field is required.
100    #[serde(default)]
101    pub required: bool,
102    /// Options for option/array fields.
103    #[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    /// Single select → name → `{ id: option_id }`.
111    Option,
112    /// Multi-select → name array → `[{ id }, ...]`.
113    Array,
114    /// Numeric → pass-through.
115    Number,
116    /// Date (YYYY-MM-DD) → pass-through.
117    Date,
118    /// DateTime (ISO 8601) → pass-through.
119    DateTime,
120    /// Free text → pass-through.
121    String,
122    /// Catch-all (epic link, etc.) → pass-through as string key.
123    Any,
124}
125
126/// Option for Jira option/array custom fields.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct JiraFieldOption {
129    pub id: String,
130    pub name: String,
131}
132
133/// Reference to a Jira Structure the integration user can access.
134///
135/// Populated from `/rest/structure/2.0/structure`. Stored in
136/// [`JiraMetadata::structures`] and consumed by `JiraSchemaEnricher` to
137/// add description-based hints for the `structureId` parameter on the 7
138/// Structure tools that take it (the strict JSON Schema `enum` is deferred
139/// until `PropertySchema.enum_values` supports non-string variants).
140#[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    /// Convert a human-readable value to Jira API format.
150    ///
151    /// - Option: name → `{ "id": "option_id" }`
152    /// - Array: name array → `[{ "id": "id1" }, { "id": "id2" }]`
153    /// - Other types: pass-through
154    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    /// Whether this is a single-project configuration.
190    pub fn is_single_project(&self) -> bool {
191        self.projects.len() == 1
192    }
193
194    /// Get project keys.
195    pub fn project_keys(&self) -> Vec<&str> {
196        self.projects.keys().map(|k| k.as_str()).collect()
197    }
198
199    /// Get union of all issue types across projects (non-subtask only).
200    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    /// Get union of all priorities across projects.
217    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    /// Get union of all components across projects.
229    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    /// Get union of all link types across projects.
241    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"]); // sorted, deduped, no subtask
399    }
400
401    #[test]
402    fn jira_metadata_deserialises_without_structures_field() {
403        // Back-compat: pre-existing persisted metadata does not carry the
404        // new `structures` field. `#[serde(default)]` must fill in an
405        // empty vec so old payloads still round-trip cleanly.
406        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        // `description: None` is skipped on serialize for compactness.
435        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}