Skip to main content

devboy_jira/
enricher.rs

1//! Jira schema enricher.
2//!
3//! Dynamic enricher supporting single-project and multi-project configurations.
4
5use devboy_core::{
6    CostModel, FollowUpLink, PropertySchema, SideEffectClass, ToolCategory, ToolEnricher,
7    ToolSchema, ToolValueModel, ValueClass, sanitize_field_name,
8};
9use serde_json::{Value, json};
10
11use crate::metadata::{JiraFieldType, JiraMetadata};
12
13/// Dynamic schema enricher for Jira provider.
14///
15/// Single-project mode: removes `projectId`, adds enums, replaces customFields with cf_*.
16/// Multi-project mode: keeps `projectId` as enum, shows known values in descriptions.
17pub struct JiraSchemaEnricher {
18    metadata: JiraMetadata,
19    /// Precomputed category list: always advertises `IssueTracker`, and
20    /// additionally advertises `JiraStructure` only when the metadata
21    /// actually carries accessible structures. `Executor::list_tools` uses
22    /// `supported_categories()` as a visibility filter, so returning
23    /// `JiraStructure` unconditionally would surface the 9 Structure tools
24    /// on Jira hosts without the plugin (pre-existing behaviour was to hide
25    /// them because no enricher claimed the category).
26    supported_categories: Vec<ToolCategory>,
27}
28
29impl JiraSchemaEnricher {
30    /// Build an enricher from cached project metadata (custom fields,
31    /// structures, statuses) used to refine MCP tool schemas at runtime.
32    pub fn new(metadata: JiraMetadata) -> Self {
33        let mut supported_categories = vec![ToolCategory::IssueTracker];
34        if !metadata.structures.is_empty() {
35            supported_categories.push(ToolCategory::JiraStructure);
36        }
37        Self {
38            metadata,
39            supported_categories,
40        }
41    }
42
43    /// Enrich the `structureId` parameter on the 7 Structure tools that
44    /// accept it. When the metadata carries a list of accessible structures
45    /// we embed `id (name) — description` for each one in the parameter's
46    /// description so an LLM sees the concrete IDs it can pick from.
47    ///
48    /// Note this is description-based enrichment, not a strict JSON Schema
49    /// `enum`: `PropertySchema.enum_values` is `Option<Vec<String>>` today
50    /// and widening that to support integer enums would be a cross-workspace
51    /// breaking change. The description-based hint mirrors the existing
52    /// priority-alias behaviour and is sufficient for LLM tool use; a
53    /// strict enum can follow when `PropertySchema` grows a typed enum
54    /// value type.
55    ///
56    /// When `metadata.structures` is empty the category is not advertised
57    /// in [`Self::supported_categories`] so this method will not be
58    /// invoked — but we still short-circuit defensively.
59    fn enrich_structure_id(&self, schema: &mut ToolSchema) {
60        if self.metadata.structures.is_empty() {
61            return;
62        }
63
64        let mut entries: Vec<&crate::metadata::JiraStructureRef> =
65            self.metadata.structures.iter().collect();
66        entries.sort_by_key(|s| s.id);
67
68        let list = entries
69            .iter()
70            .map(|s| match s.description.as_deref() {
71                Some(desc) if !desc.is_empty() => format!("{} ({}) — {}", s.id, s.name, desc),
72                _ => format!("{} ({})", s.id, s.name),
73            })
74            .collect::<Vec<_>>()
75            .join(", ");
76
77        let desc = format!(
78            "Structure ID. Must be one of the accessible structures: {list}. Pick the numeric ID (the part before parentheses).",
79        );
80        schema.set_description("structureId", &desc);
81    }
82}
83
84const REMOVE_PARAMS: &[&str] = &["points"];
85const GET_ISSUES_REMOVE_PARAMS: &[&str] = &["stateCategory"];
86
87/// Every tool in `ToolCategory::JiraStructure`. Structure-category schemas
88/// have a different parameter surface from IssueTracker tools (no
89/// `projectId`, no custom fields, etc), so the enricher routes them to a
90/// dedicated branch and skips the IssueTracker enrichment path entirely —
91/// even for `get_structures` and `create_structure`, which have no
92/// `structureId` to enrich but also nothing for the IssueTracker branch to
93/// say about them.
94const STRUCTURE_TOOLS: &[&str] = &[
95    "get_structures",
96    "get_structure_forest",
97    "add_structure_rows",
98    "move_structure_rows",
99    "remove_structure_row",
100    "get_structure_values",
101    "get_structure_views",
102    "save_structure_view",
103    "create_structure",
104];
105
106/// Subset of [`STRUCTURE_TOOLS`] that carry a `structureId` parameter —
107/// the only ones that receive description enrichment today.
108const STRUCTURE_ID_TOOLS: &[&str] = &[
109    "get_structure_forest",
110    "add_structure_rows",
111    "move_structure_rows",
112    "remove_structure_row",
113    "get_structure_values",
114    "get_structure_views",
115    "save_structure_view",
116];
117
118impl ToolEnricher for JiraSchemaEnricher {
119    fn supported_categories(&self) -> &[ToolCategory] {
120        &self.supported_categories
121    }
122
123    fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema) {
124        // Structure-category tools early-return regardless of whether they
125        // take `structureId` — the IssueTracker branch below would try to
126        // `remove_params(REMOVE_PARAMS)` / apply `priority`/`issueType` enums
127        // on schemas that do not own those parameters. Only the
128        // `structureId`-taking subset receives description enrichment today;
129        // `get_structures` and `create_structure` simply pass through.
130        if STRUCTURE_TOOLS.contains(&tool_name) {
131            if STRUCTURE_ID_TOOLS.contains(&tool_name) {
132                self.enrich_structure_id(schema);
133            }
134            return;
135        }
136
137        schema.remove_params(REMOVE_PARAMS);
138
139        if tool_name == "get_issues" {
140            schema.remove_params(GET_ISSUES_REMOVE_PARAMS);
141        }
142
143        let is_single = self.metadata.is_single_project();
144
145        // Project key handling
146        if is_single {
147            schema.remove_params(&["projectId"]);
148        } else {
149            let keys: Vec<String> = self
150                .metadata
151                .project_keys()
152                .iter()
153                .map(|k| k.to_string())
154                .collect();
155            if !keys.is_empty() {
156                schema.set_enum("projectId", &keys);
157                let desc = format!("REQUIRED. Jira project key. Available: {}", keys.join(", "));
158                schema.set_description("projectId", &desc);
159                schema.set_required("projectId", true);
160            }
161        }
162
163        // Issue types enum (non-subtask)
164        let issue_types = self.metadata.all_issue_types();
165        if !issue_types.is_empty() {
166            schema.set_enum("issueType", &issue_types);
167            let desc = format!("Issue type. Available: {}", issue_types.join(", "));
168            schema.set_description("issueType", &desc);
169        }
170
171        // Priorities enum with alias hints
172        let priorities = self.metadata.all_priorities();
173        if !priorities.is_empty() {
174            schema.set_enum("priority", &priorities);
175            let desc = format!(
176                "Priority. Available: {}. Aliases: urgent\u{2192}Highest, high\u{2192}High, normal\u{2192}Medium, low\u{2192}Low",
177                priorities.join(", ")
178            );
179            schema.set_description("priority", &desc);
180        }
181
182        // Components / fixVersions: Jira system fields. Added on
183        // the Jira enricher path only — non-Jira providers don't see
184        // them in their `tools/list` (Codex/Copilot review feedback
185        // on PR #260).
186        if tool_name == "create_issue" || tool_name == "update_issue" {
187            let components = self.metadata.all_components();
188            let comp_desc = if components.is_empty() {
189                "Jira component names to associate with the issue.".to_string()
190            } else {
191                format!("Components. Available: {}", components.join(", "))
192            };
193            let mut comp_prop =
194                PropertySchema::array(PropertySchema::string("component name"), &comp_desc);
195            if !components.is_empty() {
196                comp_prop.enum_values = Some(components.clone());
197                comp_prop.enriched = Some(true);
198            }
199            schema.add_param(
200                "components",
201                serde_json::to_value(comp_prop).unwrap_or_default(),
202            );
203
204            let fix_desc = if tool_name == "update_issue" {
205                "Replace fix versions with these Jira release names. Omit the field to leave existing fix versions untouched; pass an empty array to clear."
206            } else {
207                "Jira fix-version (release) names to associate with the issue. Each entry is a `ProjectVersion.name` (e.g., \"3.18.0\")."
208            };
209            let fix_prop =
210                PropertySchema::array(PropertySchema::string("fix version name"), fix_desc);
211            schema.add_param(
212                "fixVersions",
213                serde_json::to_value(fix_prop).unwrap_or_default(),
214            );
215        }
216
217        // Link types
218        if tool_name == "link_issues" {
219            let link_types = self.metadata.all_link_types();
220            if !link_types.is_empty() {
221                let values: Vec<&str> = link_types.iter().map(|s| s.as_str()).collect();
222                schema.add_enum_param("link_type", &values, "Issue link type");
223            }
224        }
225
226        // Customfields: union across projects, capped by
227        // `MAX_ENRICHMENT_PROJECTS`. Single-project and multi-
228        // project modes share one path — the difference is
229        // `custom_field_groups()` returns one entry per name in
230        // single-project mode, multiple entries (one per project
231        // carrying that name) in multi-project mode.
232        if tool_name == "create_issue" || tool_name == "update_issue" {
233            let groups = self.metadata.custom_field_groups();
234            if !groups.is_empty() {
235                // Keep `customFields` as a raw escape hatch so
236                // callers who want to set an instance-specific
237                // `customfield_NNNNN` directly (e.g. one not yet
238                // discovered through `get_custom_fields`) still
239                // can — the schema gains the typed `cf_*` /
240                // canonical-alias slots **in addition to**, not
241                // **instead of**, the escape hatch (Copilot review
242                // on PR #260).
243
244                for (name, variants) in &groups {
245                    let representative = &variants[0];
246                    let conflict = variants
247                        .iter()
248                        .skip(1)
249                        .any(|v| v.field_type != representative.field_type);
250
251                    match well_known_alias(name) {
252                        Some(alias) => {
253                            // Replace the raw `cf_*` slot with a
254                            // canonical typed parameter — agents work
255                            // with `epicKey` / `sprintId` /
256                            // `epicName` instead of the instance-
257                            // specific `customfield_NNNNN` id.
258                            let alias_schema = well_known_alias_schema(alias, representative);
259                            schema.add_param(alias, alias_schema);
260                        }
261                        None if conflict => {
262                            // Cross-project shape conflict — emit a
263                            // JSON Schema `anyOf` listing each
264                            // project's variant so an LLM-side
265                            // validator accepts whichever shape
266                            // matches the target project.
267                            let param_name = sanitize_field_name(name);
268                            let sub_schemas: Vec<PropertySchema> = variants
269                                .iter()
270                                .map(|cf| {
271                                    let raw = jira_custom_field_to_schema(cf);
272                                    serde_json::from_value(raw).unwrap_or_default()
273                                })
274                                .collect();
275                            let desc = format!(
276                                "Custom field: {} (varies per project — {} shapes detected).",
277                                name,
278                                sub_schemas.len()
279                            );
280                            schema.add_param(
281                                &param_name,
282                                serde_json::to_value(PropertySchema::any_of(&desc, sub_schemas))
283                                    .unwrap_or_default(),
284                            );
285                        }
286                        None => {
287                            let param_name = sanitize_field_name(name);
288                            let field_schema = jira_custom_field_to_schema(representative);
289                            schema.add_param(&param_name, field_schema);
290                        }
291                    }
292                }
293            }
294        }
295    }
296
297    fn transform_args(&self, tool_name: &str, args: &mut Value) {
298        if tool_name != "create_issue" && tool_name != "update_issue" {
299            return;
300        }
301
302        // Transform priority aliases
303        if let Some(obj) = args.as_object_mut()
304            && let Some(priority) = obj.get("priority").and_then(|v| v.as_str())
305        {
306            let mapped = match priority {
307                "urgent" => "Highest",
308                "high" => "High",
309                "normal" => "Medium",
310                "low" => "Low",
311                other => other,
312            };
313            obj.insert("priority".into(), json!(mapped));
314        }
315
316        // Transform aliases / cf_* params back to instance-specific
317        // `customField` ids. In multi-project mode we resolve via the
318        // project named in `args.projectId` — same display name can
319        // map to different `customfield_*` ids across projects, so we
320        // can't pick the first project blindly.
321        let Some(obj) = args.as_object_mut() else {
322            return;
323        };
324
325        let project_key = obj
326            .get("projectId")
327            .and_then(|v| v.as_str())
328            .map(str::to_string);
329
330        // Fields list to scan for: prefer the named project's
331        // metadata; fall back to first project (covers single-project
332        // and the `projectId`-omitted edge case).
333        let project_fields: Vec<crate::metadata::JiraCustomField> = match project_key.as_deref() {
334            Some(key) => self
335                .metadata
336                .projects
337                .get(key)
338                .map(|p| p.custom_fields.clone())
339                .or_else(|| {
340                    self.metadata
341                        .projects
342                        .values()
343                        .next()
344                        .map(|p| p.custom_fields.clone())
345                })
346                .unwrap_or_default(),
347            None => self
348                .metadata
349                .projects
350                .values()
351                .next()
352                .map(|p| p.custom_fields.clone())
353                .unwrap_or_default(),
354        };
355        if project_fields.is_empty() {
356            return;
357        }
358
359        let mut custom_fields = serde_json::Map::new();
360        let mut cf_keys_to_remove: Vec<String> = Vec::new();
361
362        for field in &project_fields {
363            let param_name: String = match well_known_alias(&field.name) {
364                Some(alias) => alias.to_string(),
365                None => sanitize_field_name(&field.name),
366            };
367            if let Some(value) = obj.get(&param_name) {
368                let transformed = field.transform_value(value);
369                custom_fields.insert(field.id.clone(), transformed);
370                cf_keys_to_remove.push(param_name);
371            }
372        }
373
374        for key in cf_keys_to_remove {
375            obj.remove(&key);
376        }
377        if !custom_fields.is_empty() {
378            // Merge into an existing `customFields` object if the
379            // caller already supplied one — otherwise replace would
380            // clobber raw `customfield_*` ids passed through the
381            // escape hatch (Copilot review on PR #260).
382            match obj.get_mut("customFields") {
383                Some(Value::Object(existing)) => {
384                    for (k, v) in custom_fields {
385                        existing.entry(k).or_insert(v);
386                    }
387                }
388                _ => {
389                    obj.insert("customFields".into(), Value::Object(custom_fields));
390                }
391            }
392        }
393    }
394
395    /// Paper 3 — Jira read-only chains (issues / comments). Issue
396    /// detail and comments are the highest-value follow-ups; create /
397    /// update / transition are mutating and never speculatable.
398    fn value_model(&self, tool_name: &str) -> Option<ToolValueModel> {
399        let model = match tool_name {
400            "get_issues" => ToolValueModel {
401                value_class: ValueClass::Supporting,
402                cost_model: CostModel {
403                    typical_kb: 4.0,
404                    max_kb: Some(40.0),
405                    latency_ms_p50: Some(450),
406                    freshness_ttl_s: Some(60),
407                    ..CostModel::default()
408                },
409                follow_up: vec![
410                    FollowUpLink {
411                        tool: "get_issue".into(),
412                        probability: 0.55,
413                        projection: Some("key".into()),
414                        projection_arg: Some("key".into()),
415                    },
416                    FollowUpLink {
417                        tool: "get_issue_comments".into(),
418                        probability: 0.45,
419                        projection: Some("key".into()),
420                        projection_arg: Some("key".into()),
421                    },
422                ],
423                side_effect_class: SideEffectClass::ReadOnly,
424                ..ToolValueModel::default()
425            },
426            "get_issue" => ToolValueModel {
427                value_class: ValueClass::Critical,
428                cost_model: CostModel {
429                    typical_kb: 1.5,
430                    latency_ms_p50: Some(220),
431                    freshness_ttl_s: Some(60),
432                    ..CostModel::default()
433                },
434                follow_up: vec![FollowUpLink {
435                    tool: "get_issue_comments".into(),
436                    probability: 0.50,
437                    projection: Some("key".into()),
438                    projection_arg: Some("key".into()),
439                }],
440                side_effect_class: SideEffectClass::ReadOnly,
441                ..ToolValueModel::default()
442            },
443            "get_issue_comments" => ToolValueModel {
444                value_class: ValueClass::Critical,
445                cost_model: CostModel {
446                    typical_kb: 2.5,
447                    latency_ms_p50: Some(280),
448                    freshness_ttl_s: Some(60),
449                    ..CostModel::default()
450                },
451                side_effect_class: SideEffectClass::ReadOnly,
452                ..ToolValueModel::default()
453            },
454            "create_issue" | "update_issue" | "add_issue_comment" | "link_issues"
455            | "transition_issue" => ToolValueModel {
456                value_class: ValueClass::Supporting,
457                cost_model: CostModel {
458                    typical_kb: 0.6,
459                    latency_ms_p50: Some(380),
460                    ..CostModel::default()
461                },
462                side_effect_class: SideEffectClass::MutatesExternal,
463                ..ToolValueModel::default()
464            },
465            _ => return None,
466        };
467        Some(model)
468    }
469
470    /// Jira hosts vary per deployment (Cloud vs Server vs Data
471    /// Center). Operators set `rate_limit_host` per-tool in TOML for
472    /// shared rate-limit grouping; we don't statically assume Cloud.
473    fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
474        None
475    }
476}
477
478/// Map a customfield display name to the canonical alias the
479/// enricher exposes in the schema. Keeps agents off the
480/// instance-specific `customfield_NNNNN` ids for the few well-known
481/// agile fields that have a stable name. Returns `None` for every
482/// other customfield — those fall back to `cf_<sanitized_name>`.
483fn well_known_alias(field_name: &str) -> Option<&'static str> {
484    match field_name {
485        "Epic Link" => Some("epicKey"),
486        "Sprint" => Some("sprintId"),
487        "Epic Name" => Some("epicName"),
488        _ => None,
489    }
490}
491
492/// Build the JSON Schema fragment for a well-known alias. Keeps the
493/// shape opinionated (`epicKey` is always a string, `sprintId` always
494/// a number) so the agent doesn't have to inspect the raw Jira
495/// field type.
496fn well_known_alias_schema(alias: &str, field: &crate::metadata::JiraCustomField) -> Value {
497    match alias {
498        "epicKey" => json!({
499            "type": "string",
500            "description": format!(
501                "Parent epic issue key (e.g. \"PROJ-12\"). Maps to the Jira `Epic Link` customfield ({}) on this instance.",
502                field.id
503            ),
504            "x-enriched": true,
505        }),
506        "sprintId" => json!({
507            "type": "integer",
508            "description": format!(
509                "Numeric sprint id. Use `get_board_sprints` to discover available ids. Maps to the Jira `Sprint` customfield ({}) on this instance.",
510                field.id
511            ),
512            "x-enriched": true,
513        }),
514        "epicName" => json!({
515            "type": "string",
516            "description": format!(
517                "Epic Name — required when creating an Epic on Server/DC and Cloud company-managed projects. Maps to the Jira `Epic Name` customfield ({}) on this instance.",
518                field.id
519            ),
520            "x-enriched": true,
521        }),
522        _ => jira_custom_field_to_schema(field),
523    }
524}
525
526/// Convert a Jira custom field definition to a JSON Schema property.
527fn jira_custom_field_to_schema(field: &crate::metadata::JiraCustomField) -> Value {
528    match field.field_type {
529        JiraFieldType::Option => {
530            let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
531            json!({
532                "type": "string",
533                "enum": options,
534                "description": format!("Custom field: {} (select). Choose one option.", field.name),
535                "x-enriched": true,
536            })
537        }
538        JiraFieldType::Array => {
539            let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
540            json!({
541                "type": "array",
542                "items": { "type": "string", "enum": options },
543                "description": format!("Custom field: {} (multi-select). Choose one or more.", field.name),
544                "x-enriched": true,
545            })
546        }
547        JiraFieldType::Number => json!({
548            "type": "number",
549            "description": format!("Custom field: {} (number).", field.name),
550            "x-enriched": true,
551        }),
552        JiraFieldType::Date => json!({
553            "type": "string",
554            "description": format!("Custom field: {} (date, YYYY-MM-DD).", field.name),
555            "x-enriched": true,
556        }),
557        JiraFieldType::DateTime => json!({
558            "type": "string",
559            "description": format!("Custom field: {} (datetime, ISO 8601).", field.name),
560            "x-enriched": true,
561        }),
562        JiraFieldType::String | JiraFieldType::Any => json!({
563            "type": "string",
564            "description": format!("Custom field: {} (text).", field.name),
565            "x-enriched": true,
566        }),
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::metadata::*;
574    use std::collections::HashMap;
575
576    fn single_project_metadata() -> JiraMetadata {
577        let mut projects = HashMap::new();
578        projects.insert(
579            "PROJ".into(),
580            JiraProjectMetadata {
581                issue_types: vec![
582                    JiraIssueType {
583                        id: "1".into(),
584                        name: "Task".into(),
585                        subtask: false,
586                    },
587                    JiraIssueType {
588                        id: "2".into(),
589                        name: "Bug".into(),
590                        subtask: false,
591                    },
592                    JiraIssueType {
593                        id: "3".into(),
594                        name: "Sub-task".into(),
595                        subtask: true,
596                    },
597                ],
598                priorities: vec![
599                    JiraPriority {
600                        id: "1".into(),
601                        name: "Highest".into(),
602                    },
603                    JiraPriority {
604                        id: "2".into(),
605                        name: "High".into(),
606                    },
607                    JiraPriority {
608                        id: "3".into(),
609                        name: "Medium".into(),
610                    },
611                    JiraPriority {
612                        id: "4".into(),
613                        name: "Low".into(),
614                    },
615                ],
616                components: vec![
617                    JiraComponent {
618                        id: "10".into(),
619                        name: "API".into(),
620                    },
621                    JiraComponent {
622                        id: "11".into(),
623                        name: "Frontend".into(),
624                    },
625                ],
626                link_types: vec![JiraLinkType {
627                    id: "1".into(),
628                    name: "Blocks".into(),
629                    outward: Some("blocks".into()),
630                    inward: Some("is blocked by".into()),
631                }],
632                custom_fields: vec![JiraCustomField {
633                    id: "customfield_10001".into(),
634                    name: "Story Points".into(),
635                    field_type: JiraFieldType::Number,
636                    required: false,
637                    options: vec![],
638                }],
639            },
640        );
641        JiraMetadata {
642            flavor: JiraFlavor::Cloud,
643            projects,
644            structures: vec![],
645        }
646    }
647
648    #[test]
649    fn test_jira_enricher_single_project_removes_project_id() {
650        let enricher = JiraSchemaEnricher::new(single_project_metadata());
651        let mut schema = ToolSchema::from_json(&json!({
652            "type": "object",
653            "properties": {
654                "projectId": { "type": "string" },
655                "issueType": { "type": "string" },
656                "priority": { "type": "string" },
657            },
658        }));
659
660        enricher.enrich_schema("create_issue", &mut schema);
661
662        assert!(!schema.properties.contains_key("projectId"));
663        assert_eq!(
664            schema.properties["issueType"].enum_values,
665            Some(vec!["Bug".into(), "Task".into()]) // sorted, no Sub-task
666        );
667        assert_eq!(
668            schema.properties["priority"].enum_values,
669            Some(vec![
670                "High".into(),
671                "Highest".into(),
672                "Low".into(),
673                "Medium".into()
674            ]) // sorted
675        );
676    }
677
678    #[test]
679    fn test_jira_enricher_adds_custom_fields() {
680        let enricher = JiraSchemaEnricher::new(single_project_metadata());
681        let mut schema = ToolSchema::from_json(&json!({
682            "type": "object",
683            "properties": {
684                "customFields": { "type": "object" },
685            },
686        }));
687
688        enricher.enrich_schema("create_issue", &mut schema);
689
690        // `customFields` is kept as a raw escape hatch alongside
691        // the expanded `cf_*` slots (Copilot review on PR #260).
692        assert!(schema.properties.contains_key("customFields"));
693        assert!(schema.properties.contains_key("cf_story_points"));
694        assert_eq!(schema.properties["cf_story_points"].schema_type, "number");
695    }
696
697    #[test]
698    fn test_jira_enricher_transform_priority_alias() {
699        let enricher = JiraSchemaEnricher::new(single_project_metadata());
700        let mut args = json!({ "title": "Test", "priority": "urgent" });
701
702        enricher.transform_args("create_issue", &mut args);
703
704        assert_eq!(args["priority"], "Highest");
705    }
706
707    fn agile_metadata() -> JiraMetadata {
708        // Project metadata that mimics a Jira-Software-enabled
709        // instance: the three well-known agile customfields are
710        // present, alongside an unrelated number field that should
711        // keep falling back to `cf_*`.
712        let mut projects = HashMap::new();
713        projects.insert(
714            "PROJ".into(),
715            JiraProjectMetadata {
716                issue_types: vec![],
717                priorities: vec![],
718                components: vec![],
719                link_types: vec![],
720                custom_fields: vec![
721                    JiraCustomField {
722                        id: "customfield_10014".into(),
723                        name: "Epic Link".into(),
724                        field_type: JiraFieldType::Any,
725                        required: false,
726                        options: vec![],
727                    },
728                    JiraCustomField {
729                        id: "customfield_10020".into(),
730                        name: "Sprint".into(),
731                        field_type: JiraFieldType::Any,
732                        required: false,
733                        options: vec![],
734                    },
735                    JiraCustomField {
736                        id: "customfield_10011".into(),
737                        name: "Epic Name".into(),
738                        field_type: JiraFieldType::String,
739                        required: false,
740                        options: vec![],
741                    },
742                    JiraCustomField {
743                        id: "customfield_10001".into(),
744                        name: "Story Points".into(),
745                        field_type: JiraFieldType::Number,
746                        required: false,
747                        options: vec![],
748                    },
749                ],
750            },
751        );
752        JiraMetadata {
753            flavor: JiraFlavor::Cloud,
754            projects,
755            structures: vec![],
756        }
757    }
758
759    /// When the project metadata carries Epic Link / Sprint / Epic
760    /// Name customfields, the enricher exposes them under canonical
761    /// aliases (`epicKey` / `sprintId` / `epicName`) instead of the
762    /// raw `cf_epic_link` / `cf_sprint` / `cf_epic_name` slots.
763    /// Other customfields stay on `cf_*`.
764    #[test]
765    fn test_jira_enricher_promotes_well_known_customfields_to_canonical_aliases() {
766        let enricher = JiraSchemaEnricher::new(agile_metadata());
767        let mut schema = ToolSchema::from_json(&json!({
768            "type": "object",
769            "properties": { "customFields": { "type": "object" } },
770        }));
771
772        enricher.enrich_schema("create_issue", &mut schema);
773
774        assert!(schema.properties.contains_key("epicKey"));
775        assert_eq!(schema.properties["epicKey"].schema_type, "string");
776        assert!(schema.properties.contains_key("sprintId"));
777        assert_eq!(schema.properties["sprintId"].schema_type, "integer");
778        assert!(schema.properties.contains_key("epicName"));
779        assert_eq!(schema.properties["epicName"].schema_type, "string");
780        // Unrelated customfields still surface as `cf_*`.
781        assert!(schema.properties.contains_key("cf_story_points"));
782        // Raw aliases are NOT exposed (avoiding duplication).
783        assert!(!schema.properties.contains_key("cf_epic_link"));
784        assert!(!schema.properties.contains_key("cf_sprint"));
785        assert!(!schema.properties.contains_key("cf_epic_name"));
786    }
787
788    /// `transform_args` translates canonical aliases back to the
789    /// instance-specific customfield ids the Jira REST API expects.
790    #[test]
791    fn test_jira_enricher_transforms_canonical_aliases_to_customfield_ids() {
792        let enricher = JiraSchemaEnricher::new(agile_metadata());
793        let mut args = json!({
794            "title": "Story under epic",
795            "epicKey": "PROJ-1",
796            "sprintId": 42,
797            "epicName": "Q4 platform",
798        });
799
800        enricher.transform_args("create_issue", &mut args);
801
802        assert!(args.get("epicKey").is_none());
803        assert!(args.get("sprintId").is_none());
804        assert!(args.get("epicName").is_none());
805        let cf = args.get("customFields").expect("customFields object");
806        assert_eq!(cf["customfield_10014"], "PROJ-1");
807        assert_eq!(cf["customfield_10020"], 42);
808        assert_eq!(cf["customfield_10011"], "Q4 platform");
809    }
810
811    /// Without the corresponding customfield in metadata, the
812    /// enricher does NOT add `epicKey` / `sprintId` / `epicName` to
813    /// the schema — agents see exactly what the instance supports.
814    #[test]
815    fn test_jira_enricher_omits_alias_when_customfield_absent() {
816        // `single_project_metadata()` only has the Story Points
817        // customfield, no Epic Link / Sprint / Epic Name.
818        let enricher = JiraSchemaEnricher::new(single_project_metadata());
819        let mut schema = ToolSchema::from_json(&json!({
820            "type": "object",
821            "properties": { "customFields": { "type": "object" } },
822        }));
823
824        enricher.enrich_schema("create_issue", &mut schema);
825
826        assert!(!schema.properties.contains_key("epicKey"));
827        assert!(!schema.properties.contains_key("sprintId"));
828        assert!(!schema.properties.contains_key("epicName"));
829    }
830
831    #[test]
832    fn test_jira_enricher_transform_custom_fields() {
833        let enricher = JiraSchemaEnricher::new(single_project_metadata());
834        let mut args = json!({
835            "title": "Test",
836            "cf_story_points": 8,
837        });
838
839        enricher.transform_args("create_issue", &mut args);
840
841        assert!(args.get("cf_story_points").is_none());
842        assert_eq!(args["customFields"]["customfield_10001"], 8);
843    }
844
845    #[test]
846    fn test_jira_enricher_multi_project_keeps_project_id() {
847        let mut meta = single_project_metadata();
848        meta.projects.insert(
849            "INFRA".into(),
850            JiraProjectMetadata {
851                issue_types: vec![],
852                priorities: vec![],
853                components: vec![],
854                link_types: vec![],
855                custom_fields: vec![],
856            },
857        );
858        let enricher = JiraSchemaEnricher::new(meta);
859        let mut schema = ToolSchema::from_json(&json!({
860            "type": "object",
861            "properties": {
862                "projectId": { "type": "string" },
863                "customFields": { "type": "object" },
864            },
865        }));
866
867        enricher.enrich_schema("create_issue", &mut schema);
868
869        // projectId kept as enum
870        assert!(schema.properties.contains_key("projectId"));
871        let project_enum = schema.properties["projectId"].enum_values.as_ref().unwrap();
872        assert!(project_enum.contains(&"PROJ".to_string()));
873        assert!(project_enum.contains(&"INFRA".to_string()));
874
875        // Multi-project mode keeps `customFields` raw escape hatch
876        // alongside the expanded `cf_*` slots so callers can still
877        // pass instance-specific ids not in metadata (Copilot
878        // review on PR #260).
879        assert!(schema.properties.contains_key("customFields"));
880        assert!(schema.properties.contains_key("cf_story_points"));
881    }
882
883    #[test]
884    fn test_jira_enricher_transform_args_skips_non_create() {
885        let enricher = JiraSchemaEnricher::new(single_project_metadata());
886        let mut args = json!({"priority": "urgent"});
887        enricher.transform_args("get_issues", &mut args);
888        // Should not transform priority for get_issues
889        assert_eq!(args["priority"], "urgent");
890    }
891
892    #[test]
893    fn test_jira_enricher_transform_args_normal_priority() {
894        let enricher = JiraSchemaEnricher::new(single_project_metadata());
895        let mut args = json!({"title": "T", "priority": "normal"});
896        enricher.transform_args("create_issue", &mut args);
897        assert_eq!(args["priority"], "Medium");
898    }
899
900    #[test]
901    fn test_jira_enricher_transform_args_non_alias_priority() {
902        let enricher = JiraSchemaEnricher::new(single_project_metadata());
903        let mut args = json!({"title": "T", "priority": "Highest"});
904        enricher.transform_args("create_issue", &mut args);
905        assert_eq!(args["priority"], "Highest"); // pass-through
906    }
907
908    /// When the same customfield name has different shapes across
909    /// projects (e.g. `Severity` is a dropdown in PROJ but free
910    /// text in INFRA), the enricher emits a JSON Schema `anyOf`
911    /// listing each variant instead of silently picking one.
912    #[test]
913    fn test_jira_enricher_multi_project_conflict_emits_any_of() {
914        let mut meta = single_project_metadata();
915        // PROJ has Severity as Option (dropdown).
916        meta.projects
917            .get_mut("PROJ")
918            .unwrap()
919            .custom_fields
920            .push(JiraCustomField {
921                id: "customfield_50001".into(),
922                name: "Severity".into(),
923                field_type: JiraFieldType::Option,
924                required: false,
925                options: vec![
926                    crate::metadata::JiraFieldOption {
927                        id: "1".into(),
928                        name: "High".into(),
929                    },
930                    crate::metadata::JiraFieldOption {
931                        id: "2".into(),
932                        name: "Low".into(),
933                    },
934                ],
935            });
936        // INFRA has Severity as plain String.
937        meta.projects.insert(
938            "INFRA".into(),
939            JiraProjectMetadata {
940                issue_types: vec![],
941                priorities: vec![],
942                components: vec![],
943                link_types: vec![],
944                custom_fields: vec![JiraCustomField {
945                    id: "customfield_60001".into(),
946                    name: "Severity".into(),
947                    field_type: JiraFieldType::String,
948                    required: false,
949                    options: vec![],
950                }],
951            },
952        );
953
954        let enricher = JiraSchemaEnricher::new(meta);
955        let mut schema = ToolSchema::from_json(&json!({
956            "type": "object",
957            "properties": { "customFields": { "type": "object" } },
958        }));
959
960        enricher.enrich_schema("create_issue", &mut schema);
961
962        let severity = schema
963            .properties
964            .get("cf_severity")
965            .expect("cf_severity present");
966        assert_eq!(severity.schema_type, "");
967        let variants = severity.any_of.as_ref().expect("anyOf set");
968        assert_eq!(variants.len(), 2);
969        // One variant must be the dropdown (with options), the
970        // other a plain string. Order isn't guaranteed.
971        let has_dropdown = variants.iter().any(|v| v.enum_values.is_some());
972        let has_plain_string = variants
973            .iter()
974            .any(|v| v.schema_type == "string" && v.enum_values.is_none());
975        assert!(has_dropdown, "missing dropdown variant: {variants:?}");
976        assert!(
977            has_plain_string,
978            "missing plain-string variant: {variants:?}"
979        );
980    }
981
982    /// Multi-project mode: `transform_args` resolves customfield ids
983    /// against the project named in `args.projectId` — the same
984    /// display name maps to different `customfield_*` ids across
985    /// projects on real instances.
986    #[test]
987    fn test_jira_enricher_multi_project_resolves_per_project_id() {
988        let mut meta = single_project_metadata();
989        // PROJ has Story Points = customfield_10001 (from
990        // single_project_metadata). INFRA gets the same display
991        // name with a different id — exactly the case where
992        // first-project resolution would target the wrong
993        // customfield.
994        meta.projects.insert(
995            "INFRA".into(),
996            JiraProjectMetadata {
997                issue_types: vec![],
998                priorities: vec![],
999                components: vec![],
1000                link_types: vec![],
1001                custom_fields: vec![JiraCustomField {
1002                    id: "customfield_20001".into(),
1003                    name: "Story Points".into(),
1004                    field_type: JiraFieldType::Number,
1005                    required: false,
1006                    options: vec![],
1007                }],
1008            },
1009        );
1010        let enricher = JiraSchemaEnricher::new(meta);
1011
1012        // projectId=INFRA → INFRA's customfield_20001
1013        let mut args = json!({"title": "T", "projectId": "INFRA", "cf_story_points": 5});
1014        enricher.transform_args("create_issue", &mut args);
1015        assert!(args.get("cf_story_points").is_none());
1016        assert_eq!(args["customFields"]["customfield_20001"], 5);
1017        assert!(args["customFields"].get("customfield_10001").is_none());
1018
1019        // projectId=PROJ → PROJ's customfield_10001
1020        let mut args = json!({"title": "T", "projectId": "PROJ", "cf_story_points": 9});
1021        enricher.transform_args("create_issue", &mut args);
1022        assert!(args.get("cf_story_points").is_none());
1023        assert_eq!(args["customFields"]["customfield_10001"], 9);
1024        assert!(args["customFields"].get("customfield_20001").is_none());
1025    }
1026
1027    /// `all_custom_fields` truncates above
1028    /// `MAX_ENRICHMENT_PROJECTS` so a 1000-project instance
1029    /// doesn't blow up the schema. Caller is expected to feed the
1030    /// most relevant subset.
1031    #[test]
1032    fn test_jira_metadata_caps_custom_fields_at_max_projects() {
1033        use crate::metadata::MAX_ENRICHMENT_PROJECTS;
1034        let mut meta = single_project_metadata();
1035        // Inflate to one over the cap with each project carrying a
1036        // unique customfield. Only the first MAX_ENRICHMENT_PROJECTS
1037        // (including the original PROJ) should make it into the
1038        // union.
1039        let extra = MAX_ENRICHMENT_PROJECTS;
1040        for i in 0..extra {
1041            meta.projects.insert(
1042                format!("EXTRA_{i}"),
1043                JiraProjectMetadata {
1044                    issue_types: vec![],
1045                    priorities: vec![],
1046                    components: vec![],
1047                    link_types: vec![],
1048                    custom_fields: vec![JiraCustomField {
1049                        id: format!("customfield_30{i:03}"),
1050                        name: format!("ExtraField{i}"),
1051                        field_type: JiraFieldType::String,
1052                        required: false,
1053                        options: vec![],
1054                    }],
1055                },
1056            );
1057        }
1058        // metadata now has 1 + MAX_ENRICHMENT_PROJECTS projects.
1059        let union = meta.all_custom_fields();
1060        // At most MAX_ENRICHMENT_PROJECTS projects' customfields
1061        // surface — exact set is HashMap-iteration-order-dependent
1062        // but the count is bounded.
1063        assert!(
1064            union.len() <= MAX_ENRICHMENT_PROJECTS,
1065            "union size {} exceeded cap {}",
1066            union.len(),
1067            MAX_ENRICHMENT_PROJECTS
1068        );
1069    }
1070
1071    #[test]
1072    fn test_jira_enricher_components_enum() {
1073        let enricher = JiraSchemaEnricher::new(single_project_metadata());
1074        let mut schema = ToolSchema::from_json(&json!({
1075            "type": "object",
1076            "properties": {
1077                "components": { "type": "array" }
1078            }
1079        }));
1080        enricher.enrich_schema("create_issue", &mut schema);
1081        let comp = schema.properties.get("components").unwrap();
1082        assert_eq!(
1083            comp.enum_values,
1084            Some(vec!["API".into(), "Frontend".into()])
1085        );
1086    }
1087
1088    #[test]
1089    fn test_jira_enricher_link_types() {
1090        let enricher = JiraSchemaEnricher::new(single_project_metadata());
1091        let mut schema = ToolSchema::new();
1092        enricher.enrich_schema("link_issues", &mut schema);
1093        let lt = schema.properties.get("link_type").unwrap();
1094        assert_eq!(lt.enum_values, Some(vec!["Blocks".into()]));
1095    }
1096
1097    #[test]
1098    fn test_jira_enricher_get_issues_removes_state_category() {
1099        let enricher = JiraSchemaEnricher::new(single_project_metadata());
1100        let mut schema = ToolSchema::from_json(&json!({
1101            "type": "object",
1102            "properties": {
1103                "state": { "type": "string" },
1104                "stateCategory": { "type": "string" }
1105            }
1106        }));
1107        enricher.enrich_schema("get_issues", &mut schema);
1108        assert!(!schema.properties.contains_key("stateCategory"));
1109        assert!(schema.properties.contains_key("state"));
1110    }
1111
1112    // -------------------------------------------------------------------------
1113    // Structure tool enrichment (depends on JiraMetadata.structures)
1114    // -------------------------------------------------------------------------
1115
1116    fn metadata_with_structures(refs: Vec<crate::metadata::JiraStructureRef>) -> JiraMetadata {
1117        let mut meta = single_project_metadata();
1118        meta.structures = refs;
1119        meta
1120    }
1121
1122    fn structureid_schema() -> ToolSchema {
1123        ToolSchema::from_json(&json!({
1124            "type": "object",
1125            "properties": {
1126                "structureId": {
1127                    "type": "integer",
1128                    "description": "Structure ID. Use get_structures to find it."
1129                }
1130            },
1131            "required": ["structureId"],
1132        }))
1133    }
1134
1135    #[test]
1136    fn jira_enricher_does_not_advertise_jira_structure_when_no_structures() {
1137        // `Executor::list_tools` uses `supported_categories()` as a visibility
1138        // filter. Advertising `JiraStructure` unconditionally would surface
1139        // the 9 Structure tools on Jira hosts without the plugin — a
1140        // regression relative to pre-patch behaviour, where no enricher
1141        // claimed the category and the tools were hidden.
1142        let enricher = JiraSchemaEnricher::new(single_project_metadata());
1143        let categories = enricher.supported_categories();
1144        assert!(categories.contains(&ToolCategory::IssueTracker));
1145        assert!(!categories.contains(&ToolCategory::JiraStructure));
1146    }
1147
1148    #[test]
1149    fn jira_enricher_advertises_jira_structure_when_metadata_has_structures() {
1150        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
1151            crate::metadata::JiraStructureRef {
1152                id: 1,
1153                name: "Only One".into(),
1154                description: None,
1155            },
1156        ]));
1157        let categories = enricher.supported_categories();
1158        assert!(categories.contains(&ToolCategory::IssueTracker));
1159        assert!(categories.contains(&ToolCategory::JiraStructure));
1160    }
1161
1162    #[test]
1163    fn jira_enricher_populates_structureid_description_for_all_seven_tools() {
1164        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
1165            crate::metadata::JiraStructureRef {
1166                id: 1,
1167                name: "Q1 Planning".into(),
1168                description: Some("Quarter 1 plan".into()),
1169            },
1170            crate::metadata::JiraStructureRef {
1171                id: 7,
1172                name: "Sprint Board".into(),
1173                description: None,
1174            },
1175        ]));
1176
1177        for tool in [
1178            "get_structure_forest",
1179            "add_structure_rows",
1180            "move_structure_rows",
1181            "remove_structure_row",
1182            "get_structure_values",
1183            "get_structure_views",
1184            "save_structure_view",
1185        ] {
1186            let mut schema = structureid_schema();
1187            enricher.enrich_schema(tool, &mut schema);
1188
1189            let prop = schema.properties.get("structureId").unwrap();
1190            let desc = prop.description.as_deref().unwrap_or("");
1191            assert!(
1192                desc.contains("Must be one of the accessible structures"),
1193                "tool={tool} desc={desc}",
1194            );
1195            assert!(
1196                desc.contains("1 (Q1 Planning) — Quarter 1 plan"),
1197                "tool={tool}"
1198            );
1199            assert!(desc.contains("7 (Sprint Board)"), "tool={tool}");
1200        }
1201    }
1202
1203    #[test]
1204    fn jira_enricher_sorts_structures_by_id_in_description() {
1205        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
1206            crate::metadata::JiraStructureRef {
1207                id: 42,
1208                name: "Roadmap".into(),
1209                description: None,
1210            },
1211            crate::metadata::JiraStructureRef {
1212                id: 1,
1213                name: "Q1".into(),
1214                description: None,
1215            },
1216            crate::metadata::JiraStructureRef {
1217                id: 7,
1218                name: "Sprint".into(),
1219                description: None,
1220            },
1221        ]));
1222
1223        let mut schema = structureid_schema();
1224        enricher.enrich_schema("get_structure_forest", &mut schema);
1225
1226        let desc = schema.properties["structureId"]
1227            .description
1228            .clone()
1229            .unwrap();
1230        let idx_1 = desc.find("1 (Q1)").expect("id 1 missing");
1231        let idx_7 = desc.find("7 (Sprint)").expect("id 7 missing");
1232        let idx_42 = desc.find("42 (Roadmap)").expect("id 42 missing");
1233        assert!(
1234            idx_1 < idx_7 && idx_7 < idx_42,
1235            "structures not sorted: {desc}"
1236        );
1237    }
1238
1239    #[test]
1240    fn jira_enricher_leaves_schema_untouched_when_no_structures() {
1241        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![]));
1242
1243        let mut schema = structureid_schema();
1244        let original_desc = schema.properties["structureId"]
1245            .description
1246            .clone()
1247            .unwrap();
1248
1249        enricher.enrich_schema("get_structure_forest", &mut schema);
1250
1251        let desc_after = schema.properties["structureId"]
1252            .description
1253            .clone()
1254            .unwrap();
1255        assert_eq!(desc_after, original_desc);
1256    }
1257
1258    #[test]
1259    fn jira_enricher_does_not_touch_get_structures_or_create_structure() {
1260        // These two tools carry the `JiraStructure` category but do not take
1261        // `structureId`. Previously the IssueTracker branch still ran on
1262        // them (because the early-return only covered the 7 id-taking
1263        // tools), which risked mutating unrelated params. After the fix
1264        // both should pass through unchanged: the enricher must not invent
1265        // `structureId` AND must not mutate parameters the Structure tools
1266        // actually own (e.g. `name` on `create_structure`).
1267        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
1268            crate::metadata::JiraStructureRef {
1269                id: 1,
1270                name: "One".into(),
1271                description: None,
1272            },
1273        ]));
1274
1275        for tool in ["get_structures", "create_structure"] {
1276            let mut schema = ToolSchema::from_json(&json!({
1277                "type": "object",
1278                "properties": {
1279                    "name": { "type": "string", "description": "original" },
1280                    "points": { "type": "integer", "description": "must survive" }
1281                }
1282            }));
1283            enricher.enrich_schema(tool, &mut schema);
1284            assert!(
1285                !schema.properties.contains_key("structureId"),
1286                "enricher inserted structureId on {tool}",
1287            );
1288            // `points` is in REMOVE_PARAMS for the IssueTracker branch —
1289            // must stay intact here because we early-return for
1290            // Structure-category tools.
1291            assert!(
1292                schema.properties.contains_key("points"),
1293                "enricher dropped `points` on {tool} — IssueTracker branch leaked into Structure handling",
1294            );
1295            assert_eq!(
1296                schema.properties["name"].description.as_deref(),
1297                Some("original"),
1298                "enricher mutated `name` description on {tool}",
1299            );
1300        }
1301    }
1302
1303    #[test]
1304    fn jira_enricher_skips_issuetracker_branch_for_structure_tools() {
1305        // Structure schemas have different parameters from IssueTracker tools
1306        // (e.g. no `points`). The Structure branch must early-return so the
1307        // IssueTracker `remove_params`/enum steps do not accidentally mutate
1308        // unrelated parameters.
1309        let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
1310            crate::metadata::JiraStructureRef {
1311                id: 1,
1312                name: "One".into(),
1313                description: None,
1314            },
1315        ]));
1316
1317        let mut schema = ToolSchema::from_json(&json!({
1318            "type": "object",
1319            "properties": {
1320                "structureId": { "type": "integer", "description": "old" },
1321                "points": { "type": "integer", "description": "must survive" }
1322            }
1323        }));
1324        enricher.enrich_schema("get_structure_forest", &mut schema);
1325
1326        assert!(schema.properties.contains_key("points"));
1327    }
1328}