1use devboy_core::{
6 CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
7 ToolValueModel, ValueClass, sanitize_field_name,
8};
9use serde_json::{Value, json};
10
11use crate::metadata::{JiraFieldType, JiraMetadata};
12
13pub struct JiraSchemaEnricher {
18 metadata: JiraMetadata,
19 supported_categories: Vec<ToolCategory>,
27}
28
29impl JiraSchemaEnricher {
30 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 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
87const 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
106const 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 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 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 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 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 let components = self.metadata.all_components();
184 if !components.is_empty() {
185 schema.set_enum("components", &components);
186 let desc = format!("Components. Available: {}", components.join(", "));
187 schema.set_description("components", &desc);
188 }
189
190 if tool_name == "link_issues" {
192 let link_types = self.metadata.all_link_types();
193 if !link_types.is_empty() {
194 let values: Vec<&str> = link_types.iter().map(|s| s.as_str()).collect();
195 schema.add_enum_param("link_type", &values, "Issue link type");
196 }
197 }
198
199 if (tool_name == "create_issue" || tool_name == "update_issue") && is_single {
201 schema.remove_params(&["customFields"]);
202
203 if let Some(project_meta) = self.metadata.projects.values().next() {
204 for field in &project_meta.custom_fields {
205 let param_name = sanitize_field_name(&field.name);
206 let field_schema = jira_custom_field_to_schema(field);
207 schema.add_param(¶m_name, field_schema);
208 }
209 }
210 }
211 }
212
213 fn transform_args(&self, tool_name: &str, args: &mut Value) {
214 if tool_name != "create_issue" && tool_name != "update_issue" {
215 return;
216 }
217
218 if let Some(obj) = args.as_object_mut()
220 && let Some(priority) = obj.get("priority").and_then(|v| v.as_str())
221 {
222 let mapped = match priority {
223 "urgent" => "Highest",
224 "high" => "High",
225 "normal" => "Medium",
226 "low" => "Low",
227 other => other,
228 };
229 obj.insert("priority".into(), json!(mapped));
230 }
231
232 if !self.metadata.is_single_project() {
234 return;
235 }
236
237 let Some(project_meta) = self.metadata.projects.values().next() else {
238 return;
239 };
240
241 let Some(obj) = args.as_object_mut() else {
242 return;
243 };
244
245 let mut custom_fields = serde_json::Map::new();
246 let mut cf_keys_to_remove: Vec<String> = Vec::new();
247
248 for field in &project_meta.custom_fields {
249 let param_name = sanitize_field_name(&field.name);
250 if let Some(value) = obj.get(¶m_name) {
251 let transformed = field.transform_value(value);
252 custom_fields.insert(field.id.clone(), transformed);
253 cf_keys_to_remove.push(param_name);
254 }
255 }
256
257 for key in cf_keys_to_remove {
258 obj.remove(&key);
259 }
260 if !custom_fields.is_empty() {
261 obj.insert("customFields".into(), Value::Object(custom_fields));
262 }
263 }
264
265 fn value_model(&self, tool_name: &str) -> Option<ToolValueModel> {
269 let model = match tool_name {
270 "get_issues" => ToolValueModel {
271 value_class: ValueClass::Supporting,
272 cost_model: CostModel {
273 typical_kb: 4.0,
274 max_kb: Some(40.0),
275 latency_ms_p50: Some(450),
276 freshness_ttl_s: Some(60),
277 ..CostModel::default()
278 },
279 follow_up: vec![
280 FollowUpLink {
281 tool: "get_issue".into(),
282 probability: 0.55,
283 projection: Some("key".into()),
284 projection_arg: Some("key".into()),
285 },
286 FollowUpLink {
287 tool: "get_issue_comments".into(),
288 probability: 0.45,
289 projection: Some("key".into()),
290 projection_arg: Some("key".into()),
291 },
292 ],
293 side_effect_class: SideEffectClass::ReadOnly,
294 ..ToolValueModel::default()
295 },
296 "get_issue" => ToolValueModel {
297 value_class: ValueClass::Critical,
298 cost_model: CostModel {
299 typical_kb: 1.5,
300 latency_ms_p50: Some(220),
301 freshness_ttl_s: Some(60),
302 ..CostModel::default()
303 },
304 follow_up: vec![FollowUpLink {
305 tool: "get_issue_comments".into(),
306 probability: 0.50,
307 projection: Some("key".into()),
308 projection_arg: Some("key".into()),
309 }],
310 side_effect_class: SideEffectClass::ReadOnly,
311 ..ToolValueModel::default()
312 },
313 "get_issue_comments" => ToolValueModel {
314 value_class: ValueClass::Critical,
315 cost_model: CostModel {
316 typical_kb: 2.5,
317 latency_ms_p50: Some(280),
318 freshness_ttl_s: Some(60),
319 ..CostModel::default()
320 },
321 side_effect_class: SideEffectClass::ReadOnly,
322 ..ToolValueModel::default()
323 },
324 "create_issue" | "update_issue" | "add_issue_comment" | "link_issues"
325 | "transition_issue" => ToolValueModel {
326 value_class: ValueClass::Supporting,
327 cost_model: CostModel {
328 typical_kb: 0.6,
329 latency_ms_p50: Some(380),
330 ..CostModel::default()
331 },
332 side_effect_class: SideEffectClass::MutatesExternal,
333 ..ToolValueModel::default()
334 },
335 _ => return None,
336 };
337 Some(model)
338 }
339
340 fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
344 None
345 }
346}
347
348fn jira_custom_field_to_schema(field: &crate::metadata::JiraCustomField) -> Value {
350 match field.field_type {
351 JiraFieldType::Option => {
352 let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
353 json!({
354 "type": "string",
355 "enum": options,
356 "description": format!("Custom field: {} (select). Choose one option.", field.name),
357 "x-enriched": true,
358 })
359 }
360 JiraFieldType::Array => {
361 let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
362 json!({
363 "type": "array",
364 "items": { "type": "string", "enum": options },
365 "description": format!("Custom field: {} (multi-select). Choose one or more.", field.name),
366 "x-enriched": true,
367 })
368 }
369 JiraFieldType::Number => json!({
370 "type": "number",
371 "description": format!("Custom field: {} (number).", field.name),
372 "x-enriched": true,
373 }),
374 JiraFieldType::Date => json!({
375 "type": "string",
376 "description": format!("Custom field: {} (date, YYYY-MM-DD).", field.name),
377 "x-enriched": true,
378 }),
379 JiraFieldType::DateTime => json!({
380 "type": "string",
381 "description": format!("Custom field: {} (datetime, ISO 8601).", field.name),
382 "x-enriched": true,
383 }),
384 JiraFieldType::String | JiraFieldType::Any => json!({
385 "type": "string",
386 "description": format!("Custom field: {} (text).", field.name),
387 "x-enriched": true,
388 }),
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::metadata::*;
396 use std::collections::HashMap;
397
398 fn single_project_metadata() -> JiraMetadata {
399 let mut projects = HashMap::new();
400 projects.insert(
401 "PROJ".into(),
402 JiraProjectMetadata {
403 issue_types: vec![
404 JiraIssueType {
405 id: "1".into(),
406 name: "Task".into(),
407 subtask: false,
408 },
409 JiraIssueType {
410 id: "2".into(),
411 name: "Bug".into(),
412 subtask: false,
413 },
414 JiraIssueType {
415 id: "3".into(),
416 name: "Sub-task".into(),
417 subtask: true,
418 },
419 ],
420 priorities: vec![
421 JiraPriority {
422 id: "1".into(),
423 name: "Highest".into(),
424 },
425 JiraPriority {
426 id: "2".into(),
427 name: "High".into(),
428 },
429 JiraPriority {
430 id: "3".into(),
431 name: "Medium".into(),
432 },
433 JiraPriority {
434 id: "4".into(),
435 name: "Low".into(),
436 },
437 ],
438 components: vec![
439 JiraComponent {
440 id: "10".into(),
441 name: "API".into(),
442 },
443 JiraComponent {
444 id: "11".into(),
445 name: "Frontend".into(),
446 },
447 ],
448 link_types: vec![JiraLinkType {
449 id: "1".into(),
450 name: "Blocks".into(),
451 outward: Some("blocks".into()),
452 inward: Some("is blocked by".into()),
453 }],
454 custom_fields: vec![JiraCustomField {
455 id: "customfield_10001".into(),
456 name: "Story Points".into(),
457 field_type: JiraFieldType::Number,
458 required: false,
459 options: vec![],
460 }],
461 },
462 );
463 JiraMetadata {
464 flavor: JiraFlavor::Cloud,
465 projects,
466 structures: vec![],
467 }
468 }
469
470 #[test]
471 fn test_jira_enricher_single_project_removes_project_id() {
472 let enricher = JiraSchemaEnricher::new(single_project_metadata());
473 let mut schema = ToolSchema::from_json(&json!({
474 "type": "object",
475 "properties": {
476 "projectId": { "type": "string" },
477 "issueType": { "type": "string" },
478 "priority": { "type": "string" },
479 },
480 }));
481
482 enricher.enrich_schema("create_issue", &mut schema);
483
484 assert!(!schema.properties.contains_key("projectId"));
485 assert_eq!(
486 schema.properties["issueType"].enum_values,
487 Some(vec!["Bug".into(), "Task".into()]) );
489 assert_eq!(
490 schema.properties["priority"].enum_values,
491 Some(vec![
492 "High".into(),
493 "Highest".into(),
494 "Low".into(),
495 "Medium".into()
496 ]) );
498 }
499
500 #[test]
501 fn test_jira_enricher_adds_custom_fields() {
502 let enricher = JiraSchemaEnricher::new(single_project_metadata());
503 let mut schema = ToolSchema::from_json(&json!({
504 "type": "object",
505 "properties": {
506 "customFields": { "type": "object" },
507 },
508 }));
509
510 enricher.enrich_schema("create_issue", &mut schema);
511
512 assert!(!schema.properties.contains_key("customFields"));
513 assert!(schema.properties.contains_key("cf_story_points"));
514 assert_eq!(schema.properties["cf_story_points"].schema_type, "number");
515 }
516
517 #[test]
518 fn test_jira_enricher_transform_priority_alias() {
519 let enricher = JiraSchemaEnricher::new(single_project_metadata());
520 let mut args = json!({ "title": "Test", "priority": "urgent" });
521
522 enricher.transform_args("create_issue", &mut args);
523
524 assert_eq!(args["priority"], "Highest");
525 }
526
527 #[test]
528 fn test_jira_enricher_transform_custom_fields() {
529 let enricher = JiraSchemaEnricher::new(single_project_metadata());
530 let mut args = json!({
531 "title": "Test",
532 "cf_story_points": 8,
533 });
534
535 enricher.transform_args("create_issue", &mut args);
536
537 assert!(args.get("cf_story_points").is_none());
538 assert_eq!(args["customFields"]["customfield_10001"], 8);
539 }
540
541 #[test]
542 fn test_jira_enricher_multi_project_keeps_project_id() {
543 let mut meta = single_project_metadata();
544 meta.projects.insert(
545 "INFRA".into(),
546 JiraProjectMetadata {
547 issue_types: vec![],
548 priorities: vec![],
549 components: vec![],
550 link_types: vec![],
551 custom_fields: vec![],
552 },
553 );
554 let enricher = JiraSchemaEnricher::new(meta);
555 let mut schema = ToolSchema::from_json(&json!({
556 "type": "object",
557 "properties": {
558 "projectId": { "type": "string" },
559 "customFields": { "type": "object" },
560 },
561 }));
562
563 enricher.enrich_schema("create_issue", &mut schema);
564
565 assert!(schema.properties.contains_key("projectId"));
567 let project_enum = schema.properties["projectId"].enum_values.as_ref().unwrap();
568 assert!(project_enum.contains(&"PROJ".to_string()));
569 assert!(project_enum.contains(&"INFRA".to_string()));
570
571 assert!(schema.properties.contains_key("customFields"));
573 assert!(!schema.properties.contains_key("cf_story_points"));
574 }
575
576 #[test]
577 fn test_jira_enricher_transform_args_skips_non_create() {
578 let enricher = JiraSchemaEnricher::new(single_project_metadata());
579 let mut args = json!({"priority": "urgent"});
580 enricher.transform_args("get_issues", &mut args);
581 assert_eq!(args["priority"], "urgent");
583 }
584
585 #[test]
586 fn test_jira_enricher_transform_args_normal_priority() {
587 let enricher = JiraSchemaEnricher::new(single_project_metadata());
588 let mut args = json!({"title": "T", "priority": "normal"});
589 enricher.transform_args("create_issue", &mut args);
590 assert_eq!(args["priority"], "Medium");
591 }
592
593 #[test]
594 fn test_jira_enricher_transform_args_non_alias_priority() {
595 let enricher = JiraSchemaEnricher::new(single_project_metadata());
596 let mut args = json!({"title": "T", "priority": "Highest"});
597 enricher.transform_args("create_issue", &mut args);
598 assert_eq!(args["priority"], "Highest"); }
600
601 #[test]
602 fn test_jira_enricher_multi_project_no_cf_transform() {
603 let mut meta = single_project_metadata();
604 meta.projects.insert(
605 "INFRA".into(),
606 JiraProjectMetadata {
607 issue_types: vec![],
608 priorities: vec![],
609 components: vec![],
610 link_types: vec![],
611 custom_fields: vec![],
612 },
613 );
614 let enricher = JiraSchemaEnricher::new(meta);
615 let mut args = json!({"title": "T", "cf_story_points": 5});
616 enricher.transform_args("create_issue", &mut args);
617 assert!(args.get("cf_story_points").is_some());
619 assert!(args.get("customFields").is_none());
620 }
621
622 #[test]
623 fn test_jira_enricher_components_enum() {
624 let enricher = JiraSchemaEnricher::new(single_project_metadata());
625 let mut schema = ToolSchema::from_json(&json!({
626 "type": "object",
627 "properties": {
628 "components": { "type": "array" }
629 }
630 }));
631 enricher.enrich_schema("create_issue", &mut schema);
632 let comp = schema.properties.get("components").unwrap();
633 assert_eq!(
634 comp.enum_values,
635 Some(vec!["API".into(), "Frontend".into()])
636 );
637 }
638
639 #[test]
640 fn test_jira_enricher_link_types() {
641 let enricher = JiraSchemaEnricher::new(single_project_metadata());
642 let mut schema = ToolSchema::new();
643 enricher.enrich_schema("link_issues", &mut schema);
644 let lt = schema.properties.get("link_type").unwrap();
645 assert_eq!(lt.enum_values, Some(vec!["Blocks".into()]));
646 }
647
648 #[test]
649 fn test_jira_enricher_get_issues_removes_state_category() {
650 let enricher = JiraSchemaEnricher::new(single_project_metadata());
651 let mut schema = ToolSchema::from_json(&json!({
652 "type": "object",
653 "properties": {
654 "state": { "type": "string" },
655 "stateCategory": { "type": "string" }
656 }
657 }));
658 enricher.enrich_schema("get_issues", &mut schema);
659 assert!(!schema.properties.contains_key("stateCategory"));
660 assert!(schema.properties.contains_key("state"));
661 }
662
663 fn metadata_with_structures(refs: Vec<crate::metadata::JiraStructureRef>) -> JiraMetadata {
668 let mut meta = single_project_metadata();
669 meta.structures = refs;
670 meta
671 }
672
673 fn structureid_schema() -> ToolSchema {
674 ToolSchema::from_json(&json!({
675 "type": "object",
676 "properties": {
677 "structureId": {
678 "type": "integer",
679 "description": "Structure ID. Use get_structures to find it."
680 }
681 },
682 "required": ["structureId"],
683 }))
684 }
685
686 #[test]
687 fn jira_enricher_does_not_advertise_jira_structure_when_no_structures() {
688 let enricher = JiraSchemaEnricher::new(single_project_metadata());
694 let categories = enricher.supported_categories();
695 assert!(categories.contains(&ToolCategory::IssueTracker));
696 assert!(!categories.contains(&ToolCategory::JiraStructure));
697 }
698
699 #[test]
700 fn jira_enricher_advertises_jira_structure_when_metadata_has_structures() {
701 let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
702 crate::metadata::JiraStructureRef {
703 id: 1,
704 name: "Only One".into(),
705 description: None,
706 },
707 ]));
708 let categories = enricher.supported_categories();
709 assert!(categories.contains(&ToolCategory::IssueTracker));
710 assert!(categories.contains(&ToolCategory::JiraStructure));
711 }
712
713 #[test]
714 fn jira_enricher_populates_structureid_description_for_all_seven_tools() {
715 let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
716 crate::metadata::JiraStructureRef {
717 id: 1,
718 name: "Q1 Planning".into(),
719 description: Some("Quarter 1 plan".into()),
720 },
721 crate::metadata::JiraStructureRef {
722 id: 7,
723 name: "Sprint Board".into(),
724 description: None,
725 },
726 ]));
727
728 for tool in [
729 "get_structure_forest",
730 "add_structure_rows",
731 "move_structure_rows",
732 "remove_structure_row",
733 "get_structure_values",
734 "get_structure_views",
735 "save_structure_view",
736 ] {
737 let mut schema = structureid_schema();
738 enricher.enrich_schema(tool, &mut schema);
739
740 let prop = schema.properties.get("structureId").unwrap();
741 let desc = prop.description.as_deref().unwrap_or("");
742 assert!(
743 desc.contains("Must be one of the accessible structures"),
744 "tool={tool} desc={desc}",
745 );
746 assert!(
747 desc.contains("1 (Q1 Planning) — Quarter 1 plan"),
748 "tool={tool}"
749 );
750 assert!(desc.contains("7 (Sprint Board)"), "tool={tool}");
751 }
752 }
753
754 #[test]
755 fn jira_enricher_sorts_structures_by_id_in_description() {
756 let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
757 crate::metadata::JiraStructureRef {
758 id: 42,
759 name: "Roadmap".into(),
760 description: None,
761 },
762 crate::metadata::JiraStructureRef {
763 id: 1,
764 name: "Q1".into(),
765 description: None,
766 },
767 crate::metadata::JiraStructureRef {
768 id: 7,
769 name: "Sprint".into(),
770 description: None,
771 },
772 ]));
773
774 let mut schema = structureid_schema();
775 enricher.enrich_schema("get_structure_forest", &mut schema);
776
777 let desc = schema.properties["structureId"]
778 .description
779 .clone()
780 .unwrap();
781 let idx_1 = desc.find("1 (Q1)").expect("id 1 missing");
782 let idx_7 = desc.find("7 (Sprint)").expect("id 7 missing");
783 let idx_42 = desc.find("42 (Roadmap)").expect("id 42 missing");
784 assert!(
785 idx_1 < idx_7 && idx_7 < idx_42,
786 "structures not sorted: {desc}"
787 );
788 }
789
790 #[test]
791 fn jira_enricher_leaves_schema_untouched_when_no_structures() {
792 let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![]));
793
794 let mut schema = structureid_schema();
795 let original_desc = schema.properties["structureId"]
796 .description
797 .clone()
798 .unwrap();
799
800 enricher.enrich_schema("get_structure_forest", &mut schema);
801
802 let desc_after = schema.properties["structureId"]
803 .description
804 .clone()
805 .unwrap();
806 assert_eq!(desc_after, original_desc);
807 }
808
809 #[test]
810 fn jira_enricher_does_not_touch_get_structures_or_create_structure() {
811 let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
819 crate::metadata::JiraStructureRef {
820 id: 1,
821 name: "One".into(),
822 description: None,
823 },
824 ]));
825
826 for tool in ["get_structures", "create_structure"] {
827 let mut schema = ToolSchema::from_json(&json!({
828 "type": "object",
829 "properties": {
830 "name": { "type": "string", "description": "original" },
831 "points": { "type": "integer", "description": "must survive" }
832 }
833 }));
834 enricher.enrich_schema(tool, &mut schema);
835 assert!(
836 !schema.properties.contains_key("structureId"),
837 "enricher inserted structureId on {tool}",
838 );
839 assert!(
843 schema.properties.contains_key("points"),
844 "enricher dropped `points` on {tool} — IssueTracker branch leaked into Structure handling",
845 );
846 assert_eq!(
847 schema.properties["name"].description.as_deref(),
848 Some("original"),
849 "enricher mutated `name` description on {tool}",
850 );
851 }
852 }
853
854 #[test]
855 fn jira_enricher_skips_issuetracker_branch_for_structure_tools() {
856 let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
861 crate::metadata::JiraStructureRef {
862 id: 1,
863 name: "One".into(),
864 description: None,
865 },
866 ]));
867
868 let mut schema = ToolSchema::from_json(&json!({
869 "type": "object",
870 "properties": {
871 "structureId": { "type": "integer", "description": "old" },
872 "points": { "type": "integer", "description": "must survive" }
873 }
874 }));
875 enricher.enrich_schema("get_structure_forest", &mut schema);
876
877 assert!(schema.properties.contains_key("points"));
878 }
879}