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    /// Union of customfields visible across the first
253    /// [`MAX_ENRICHMENT_PROJECTS`] projects, deduplicated by `name`.
254    /// First-write-wins on collisions — if `Severity` exists in
255    /// multiple projects with different ids, the schema is built
256    /// from the earliest project's definition (matches HashMap
257    /// iteration order; not deterministic across runs but stable
258    /// within one). Per-project resolution at dispatch time is done
259    /// by [`Self::custom_field_for_project`], which reads the
260    /// current project's metadata directly.
261    ///
262    /// The cap protects token budgets on enterprise instances with
263    /// hundreds of projects: enrichment beyond 30 projects emits a
264    /// `tracing::warn!` and silently truncates rather than letting
265    /// the schema explode. Selecting the relevant 30 (most-recent
266    /// activity, allowlist, etc.) is the metadata loader's job, not
267    /// this crate's.
268    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        // Sort project keys before truncating so the "first 30" set
282        // is deterministic across reloads — HashMap iteration order
283        // is not stable (Codex review on PR #260).
284        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    /// Resolve a customfield by display name **within a specific
301    /// project** — used by `transform_args` to pick the right
302    /// `customfield_*` id when the same name maps to different ids
303    /// across projects in multi-project mode.
304    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    /// Group customfields across capped projects by display name —
317    /// returns `(name, [variants])` pairs sorted by name. A name
318    /// with two entries that have different `field_type`s flags a
319    /// cross-project shape conflict the enricher resolves with
320    /// `anyOf` instead of first-wins.
321    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        // Sort project keys before truncating so the selected
333        // subset is deterministic across reloads — see
334        // `all_custom_fields` (Codex review on PR #260).
335        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
352/// Cap on how many projects the schema enricher walks when building
353/// the customfield union. Higher values trade richer cross-project
354/// coverage for fatter `tools/list` payloads — 30 is empirically
355/// enough to surface common agile fields on enterprise instances
356/// without overflowing token budgets.
357pub const MAX_ENRICHMENT_PROJECTS: usize = 30;
358
359/// Strategy for choosing which Jira projects' metadata to fetch
360/// when building the enricher cache. Different deployments need
361/// different selection logic — a 5-project team wants `All`, a
362/// 1000-project enterprise wants `RecentActivity` or
363/// `Configured` so the enricher stays inside the
364/// `MAX_ENRICHMENT_PROJECTS` budget.
365#[derive(Debug, Clone)]
366pub enum MetadataLoadStrategy {
367    /// Explicit list of project keys (e.g. from app config).
368    /// Fastest path: skips Jira-side filtering entirely.
369    Configured(Vec<String>),
370    /// Projects the authenticated user has interacted with most
371    /// recently. Resolves through Jira's `/project/search?recent=N`
372    /// (Cloud) or the `recent` flag on `/project` (Server/DC).
373    /// Best default when the operator hasn't picked a list.
374    MyProjects,
375    /// Projects with issues updated in the last `days`. Uses a
376    /// JQL search across `lastIssueUpdateTime`. Picks up
377    /// stale-but-still-active projects that `MyProjects` may miss.
378    RecentActivity { days: u32 },
379    /// Every project the user can read. Errors out (with a
380    /// suggestion to switch strategies) if total >
381    /// [`MAX_ENRICHMENT_PROJECTS`] — loading 1000 projects'
382    /// metadata is rarely what anyone wanted.
383    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        // Smoke test: every variant constructs with realistic args.
394        // Concrete behaviour lives in `JiraClient::load_default_metadata`
395        // strategy implementations (follow-up commits).
396        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"]); // sorted, deduped, no subtask
543    }
544
545    #[test]
546    fn jira_metadata_deserialises_without_structures_field() {
547        // Back-compat: pre-existing persisted metadata does not carry the
548        // new `structures` field. `#[serde(default)]` must fill in an
549        // empty vec so old payloads still round-trip cleanly.
550        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        // `description: None` is skipped on serialize for compactness.
579        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}