1use 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
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 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 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 if tool_name == "create_issue" || tool_name == "update_issue" {
233 let groups = self.metadata.custom_field_groups();
234 if !groups.is_empty() {
235 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 let alias_schema = well_known_alias_schema(alias, representative);
259 schema.add_param(alias, alias_schema);
260 }
261 None if conflict => {
262 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 ¶m_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(¶m_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 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 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 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(¶m_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 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 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 fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
474 None
475 }
476}
477
478fn 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
492fn 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
526fn 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()]) );
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 ]) );
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 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 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 #[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 assert!(schema.properties.contains_key("cf_story_points"));
782 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 #[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 #[test]
815 fn test_jira_enricher_omits_alias_when_customfield_absent() {
816 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 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 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 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"); }
907
908 #[test]
913 fn test_jira_enricher_multi_project_conflict_emits_any_of() {
914 let mut meta = single_project_metadata();
915 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 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 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 #[test]
987 fn test_jira_enricher_multi_project_resolves_per_project_id() {
988 let mut meta = single_project_metadata();
989 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 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 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 #[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 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 let union = meta.all_custom_fields();
1060 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 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 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 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 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 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}