use devboy_core::{
CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
ToolValueModel, ValueClass, sanitize_field_name,
};
use serde_json::{Value, json};
use crate::metadata::{JiraFieldType, JiraMetadata};
pub struct JiraSchemaEnricher {
metadata: JiraMetadata,
supported_categories: Vec<ToolCategory>,
}
impl JiraSchemaEnricher {
pub fn new(metadata: JiraMetadata) -> Self {
let mut supported_categories = vec![ToolCategory::IssueTracker];
if !metadata.structures.is_empty() {
supported_categories.push(ToolCategory::JiraStructure);
}
Self {
metadata,
supported_categories,
}
}
fn enrich_structure_id(&self, schema: &mut ToolSchema) {
if self.metadata.structures.is_empty() {
return;
}
let mut entries: Vec<&crate::metadata::JiraStructureRef> =
self.metadata.structures.iter().collect();
entries.sort_by_key(|s| s.id);
let list = entries
.iter()
.map(|s| match s.description.as_deref() {
Some(desc) if !desc.is_empty() => format!("{} ({}) — {}", s.id, s.name, desc),
_ => format!("{} ({})", s.id, s.name),
})
.collect::<Vec<_>>()
.join(", ");
let desc = format!(
"Structure ID. Must be one of the accessible structures: {list}. Pick the numeric ID (the part before parentheses).",
);
schema.set_description("structureId", &desc);
}
}
const REMOVE_PARAMS: &[&str] = &["points"];
const GET_ISSUES_REMOVE_PARAMS: &[&str] = &["stateCategory"];
const STRUCTURE_TOOLS: &[&str] = &[
"get_structures",
"get_structure_forest",
"add_structure_rows",
"move_structure_rows",
"remove_structure_row",
"get_structure_values",
"get_structure_views",
"save_structure_view",
"create_structure",
];
const STRUCTURE_ID_TOOLS: &[&str] = &[
"get_structure_forest",
"add_structure_rows",
"move_structure_rows",
"remove_structure_row",
"get_structure_values",
"get_structure_views",
"save_structure_view",
];
impl ToolEnricher for JiraSchemaEnricher {
fn supported_categories(&self) -> &[ToolCategory] {
&self.supported_categories
}
fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema) {
if STRUCTURE_TOOLS.contains(&tool_name) {
if STRUCTURE_ID_TOOLS.contains(&tool_name) {
self.enrich_structure_id(schema);
}
return;
}
schema.remove_params(REMOVE_PARAMS);
if tool_name == "get_issues" {
schema.remove_params(GET_ISSUES_REMOVE_PARAMS);
}
let is_single = self.metadata.is_single_project();
if is_single {
schema.remove_params(&["projectId"]);
} else {
let keys: Vec<String> = self
.metadata
.project_keys()
.iter()
.map(|k| k.to_string())
.collect();
if !keys.is_empty() {
schema.set_enum("projectId", &keys);
let desc = format!("REQUIRED. Jira project key. Available: {}", keys.join(", "));
schema.set_description("projectId", &desc);
schema.set_required("projectId", true);
}
}
let issue_types = self.metadata.all_issue_types();
if !issue_types.is_empty() {
schema.set_enum("issueType", &issue_types);
let desc = format!("Issue type. Available: {}", issue_types.join(", "));
schema.set_description("issueType", &desc);
}
let priorities = self.metadata.all_priorities();
if !priorities.is_empty() {
schema.set_enum("priority", &priorities);
let desc = format!(
"Priority. Available: {}. Aliases: urgent\u{2192}Highest, high\u{2192}High, normal\u{2192}Medium, low\u{2192}Low",
priorities.join(", ")
);
schema.set_description("priority", &desc);
}
let components = self.metadata.all_components();
if !components.is_empty() {
schema.set_enum("components", &components);
let desc = format!("Components. Available: {}", components.join(", "));
schema.set_description("components", &desc);
}
if tool_name == "link_issues" {
let link_types = self.metadata.all_link_types();
if !link_types.is_empty() {
let values: Vec<&str> = link_types.iter().map(|s| s.as_str()).collect();
schema.add_enum_param("link_type", &values, "Issue link type");
}
}
if (tool_name == "create_issue" || tool_name == "update_issue") && is_single {
schema.remove_params(&["customFields"]);
if let Some(project_meta) = self.metadata.projects.values().next() {
for field in &project_meta.custom_fields {
let param_name = sanitize_field_name(&field.name);
let field_schema = jira_custom_field_to_schema(field);
schema.add_param(¶m_name, field_schema);
}
}
}
}
fn transform_args(&self, tool_name: &str, args: &mut Value) {
if tool_name != "create_issue" && tool_name != "update_issue" {
return;
}
if let Some(obj) = args.as_object_mut()
&& let Some(priority) = obj.get("priority").and_then(|v| v.as_str())
{
let mapped = match priority {
"urgent" => "Highest",
"high" => "High",
"normal" => "Medium",
"low" => "Low",
other => other,
};
obj.insert("priority".into(), json!(mapped));
}
if !self.metadata.is_single_project() {
return;
}
let Some(project_meta) = self.metadata.projects.values().next() else {
return;
};
let Some(obj) = args.as_object_mut() else {
return;
};
let mut custom_fields = serde_json::Map::new();
let mut cf_keys_to_remove: Vec<String> = Vec::new();
for field in &project_meta.custom_fields {
let param_name = sanitize_field_name(&field.name);
if let Some(value) = obj.get(¶m_name) {
let transformed = field.transform_value(value);
custom_fields.insert(field.id.clone(), transformed);
cf_keys_to_remove.push(param_name);
}
}
for key in cf_keys_to_remove {
obj.remove(&key);
}
if !custom_fields.is_empty() {
obj.insert("customFields".into(), Value::Object(custom_fields));
}
}
fn value_model(&self, tool_name: &str) -> Option<ToolValueModel> {
let model = match tool_name {
"get_issues" => ToolValueModel {
value_class: ValueClass::Supporting,
cost_model: CostModel {
typical_kb: 4.0,
max_kb: Some(40.0),
latency_ms_p50: Some(450),
freshness_ttl_s: Some(60),
..CostModel::default()
},
follow_up: vec![
FollowUpLink {
tool: "get_issue".into(),
probability: 0.55,
projection: Some("key".into()),
projection_arg: Some("key".into()),
},
FollowUpLink {
tool: "get_issue_comments".into(),
probability: 0.45,
projection: Some("key".into()),
projection_arg: Some("key".into()),
},
],
side_effect_class: SideEffectClass::ReadOnly,
..ToolValueModel::default()
},
"get_issue" => ToolValueModel {
value_class: ValueClass::Critical,
cost_model: CostModel {
typical_kb: 1.5,
latency_ms_p50: Some(220),
freshness_ttl_s: Some(60),
..CostModel::default()
},
follow_up: vec![FollowUpLink {
tool: "get_issue_comments".into(),
probability: 0.50,
projection: Some("key".into()),
projection_arg: Some("key".into()),
}],
side_effect_class: SideEffectClass::ReadOnly,
..ToolValueModel::default()
},
"get_issue_comments" => ToolValueModel {
value_class: ValueClass::Critical,
cost_model: CostModel {
typical_kb: 2.5,
latency_ms_p50: Some(280),
freshness_ttl_s: Some(60),
..CostModel::default()
},
side_effect_class: SideEffectClass::ReadOnly,
..ToolValueModel::default()
},
"create_issue" | "update_issue" | "add_issue_comment" | "link_issues"
| "transition_issue" => ToolValueModel {
value_class: ValueClass::Supporting,
cost_model: CostModel {
typical_kb: 0.6,
latency_ms_p50: Some(380),
..CostModel::default()
},
side_effect_class: SideEffectClass::MutatesExternal,
..ToolValueModel::default()
},
_ => return None,
};
Some(model)
}
fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
None
}
}
fn jira_custom_field_to_schema(field: &crate::metadata::JiraCustomField) -> Value {
match field.field_type {
JiraFieldType::Option => {
let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
json!({
"type": "string",
"enum": options,
"description": format!("Custom field: {} (select). Choose one option.", field.name),
"x-enriched": true,
})
}
JiraFieldType::Array => {
let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
json!({
"type": "array",
"items": { "type": "string", "enum": options },
"description": format!("Custom field: {} (multi-select). Choose one or more.", field.name),
"x-enriched": true,
})
}
JiraFieldType::Number => json!({
"type": "number",
"description": format!("Custom field: {} (number).", field.name),
"x-enriched": true,
}),
JiraFieldType::Date => json!({
"type": "string",
"description": format!("Custom field: {} (date, YYYY-MM-DD).", field.name),
"x-enriched": true,
}),
JiraFieldType::DateTime => json!({
"type": "string",
"description": format!("Custom field: {} (datetime, ISO 8601).", field.name),
"x-enriched": true,
}),
JiraFieldType::String | JiraFieldType::Any => json!({
"type": "string",
"description": format!("Custom field: {} (text).", field.name),
"x-enriched": true,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metadata::*;
use std::collections::HashMap;
fn single_project_metadata() -> JiraMetadata {
let mut projects = HashMap::new();
projects.insert(
"PROJ".into(),
JiraProjectMetadata {
issue_types: vec![
JiraIssueType {
id: "1".into(),
name: "Task".into(),
subtask: false,
},
JiraIssueType {
id: "2".into(),
name: "Bug".into(),
subtask: false,
},
JiraIssueType {
id: "3".into(),
name: "Sub-task".into(),
subtask: true,
},
],
priorities: vec![
JiraPriority {
id: "1".into(),
name: "Highest".into(),
},
JiraPriority {
id: "2".into(),
name: "High".into(),
},
JiraPriority {
id: "3".into(),
name: "Medium".into(),
},
JiraPriority {
id: "4".into(),
name: "Low".into(),
},
],
components: vec![
JiraComponent {
id: "10".into(),
name: "API".into(),
},
JiraComponent {
id: "11".into(),
name: "Frontend".into(),
},
],
link_types: vec![JiraLinkType {
id: "1".into(),
name: "Blocks".into(),
outward: Some("blocks".into()),
inward: Some("is blocked by".into()),
}],
custom_fields: vec![JiraCustomField {
id: "customfield_10001".into(),
name: "Story Points".into(),
field_type: JiraFieldType::Number,
required: false,
options: vec![],
}],
},
);
JiraMetadata {
flavor: JiraFlavor::Cloud,
projects,
structures: vec![],
}
}
#[test]
fn test_jira_enricher_single_project_removes_project_id() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut schema = ToolSchema::from_json(&json!({
"type": "object",
"properties": {
"projectId": { "type": "string" },
"issueType": { "type": "string" },
"priority": { "type": "string" },
},
}));
enricher.enrich_schema("create_issue", &mut schema);
assert!(!schema.properties.contains_key("projectId"));
assert_eq!(
schema.properties["issueType"].enum_values,
Some(vec!["Bug".into(), "Task".into()]) );
assert_eq!(
schema.properties["priority"].enum_values,
Some(vec![
"High".into(),
"Highest".into(),
"Low".into(),
"Medium".into()
]) );
}
#[test]
fn test_jira_enricher_adds_custom_fields() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut schema = ToolSchema::from_json(&json!({
"type": "object",
"properties": {
"customFields": { "type": "object" },
},
}));
enricher.enrich_schema("create_issue", &mut schema);
assert!(!schema.properties.contains_key("customFields"));
assert!(schema.properties.contains_key("cf_story_points"));
assert_eq!(schema.properties["cf_story_points"].schema_type, "number");
}
#[test]
fn test_jira_enricher_transform_priority_alias() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut args = json!({ "title": "Test", "priority": "urgent" });
enricher.transform_args("create_issue", &mut args);
assert_eq!(args["priority"], "Highest");
}
#[test]
fn test_jira_enricher_transform_custom_fields() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut args = json!({
"title": "Test",
"cf_story_points": 8,
});
enricher.transform_args("create_issue", &mut args);
assert!(args.get("cf_story_points").is_none());
assert_eq!(args["customFields"]["customfield_10001"], 8);
}
#[test]
fn test_jira_enricher_multi_project_keeps_project_id() {
let mut meta = single_project_metadata();
meta.projects.insert(
"INFRA".into(),
JiraProjectMetadata {
issue_types: vec![],
priorities: vec![],
components: vec![],
link_types: vec![],
custom_fields: vec![],
},
);
let enricher = JiraSchemaEnricher::new(meta);
let mut schema = ToolSchema::from_json(&json!({
"type": "object",
"properties": {
"projectId": { "type": "string" },
"customFields": { "type": "object" },
},
}));
enricher.enrich_schema("create_issue", &mut schema);
assert!(schema.properties.contains_key("projectId"));
let project_enum = schema.properties["projectId"].enum_values.as_ref().unwrap();
assert!(project_enum.contains(&"PROJ".to_string()));
assert!(project_enum.contains(&"INFRA".to_string()));
assert!(schema.properties.contains_key("customFields"));
assert!(!schema.properties.contains_key("cf_story_points"));
}
#[test]
fn test_jira_enricher_transform_args_skips_non_create() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut args = json!({"priority": "urgent"});
enricher.transform_args("get_issues", &mut args);
assert_eq!(args["priority"], "urgent");
}
#[test]
fn test_jira_enricher_transform_args_normal_priority() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut args = json!({"title": "T", "priority": "normal"});
enricher.transform_args("create_issue", &mut args);
assert_eq!(args["priority"], "Medium");
}
#[test]
fn test_jira_enricher_transform_args_non_alias_priority() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut args = json!({"title": "T", "priority": "Highest"});
enricher.transform_args("create_issue", &mut args);
assert_eq!(args["priority"], "Highest"); }
#[test]
fn test_jira_enricher_multi_project_no_cf_transform() {
let mut meta = single_project_metadata();
meta.projects.insert(
"INFRA".into(),
JiraProjectMetadata {
issue_types: vec![],
priorities: vec![],
components: vec![],
link_types: vec![],
custom_fields: vec![],
},
);
let enricher = JiraSchemaEnricher::new(meta);
let mut args = json!({"title": "T", "cf_story_points": 5});
enricher.transform_args("create_issue", &mut args);
assert!(args.get("cf_story_points").is_some());
assert!(args.get("customFields").is_none());
}
#[test]
fn test_jira_enricher_components_enum() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut schema = ToolSchema::from_json(&json!({
"type": "object",
"properties": {
"components": { "type": "array" }
}
}));
enricher.enrich_schema("create_issue", &mut schema);
let comp = schema.properties.get("components").unwrap();
assert_eq!(
comp.enum_values,
Some(vec!["API".into(), "Frontend".into()])
);
}
#[test]
fn test_jira_enricher_link_types() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut schema = ToolSchema::new();
enricher.enrich_schema("link_issues", &mut schema);
let lt = schema.properties.get("link_type").unwrap();
assert_eq!(lt.enum_values, Some(vec!["Blocks".into()]));
}
#[test]
fn test_jira_enricher_get_issues_removes_state_category() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let mut schema = ToolSchema::from_json(&json!({
"type": "object",
"properties": {
"state": { "type": "string" },
"stateCategory": { "type": "string" }
}
}));
enricher.enrich_schema("get_issues", &mut schema);
assert!(!schema.properties.contains_key("stateCategory"));
assert!(schema.properties.contains_key("state"));
}
fn metadata_with_structures(refs: Vec<crate::metadata::JiraStructureRef>) -> JiraMetadata {
let mut meta = single_project_metadata();
meta.structures = refs;
meta
}
fn structureid_schema() -> ToolSchema {
ToolSchema::from_json(&json!({
"type": "object",
"properties": {
"structureId": {
"type": "integer",
"description": "Structure ID. Use get_structures to find it."
}
},
"required": ["structureId"],
}))
}
#[test]
fn jira_enricher_does_not_advertise_jira_structure_when_no_structures() {
let enricher = JiraSchemaEnricher::new(single_project_metadata());
let categories = enricher.supported_categories();
assert!(categories.contains(&ToolCategory::IssueTracker));
assert!(!categories.contains(&ToolCategory::JiraStructure));
}
#[test]
fn jira_enricher_advertises_jira_structure_when_metadata_has_structures() {
let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
crate::metadata::JiraStructureRef {
id: 1,
name: "Only One".into(),
description: None,
},
]));
let categories = enricher.supported_categories();
assert!(categories.contains(&ToolCategory::IssueTracker));
assert!(categories.contains(&ToolCategory::JiraStructure));
}
#[test]
fn jira_enricher_populates_structureid_description_for_all_seven_tools() {
let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
crate::metadata::JiraStructureRef {
id: 1,
name: "Q1 Planning".into(),
description: Some("Quarter 1 plan".into()),
},
crate::metadata::JiraStructureRef {
id: 7,
name: "Sprint Board".into(),
description: None,
},
]));
for tool in [
"get_structure_forest",
"add_structure_rows",
"move_structure_rows",
"remove_structure_row",
"get_structure_values",
"get_structure_views",
"save_structure_view",
] {
let mut schema = structureid_schema();
enricher.enrich_schema(tool, &mut schema);
let prop = schema.properties.get("structureId").unwrap();
let desc = prop.description.as_deref().unwrap_or("");
assert!(
desc.contains("Must be one of the accessible structures"),
"tool={tool} desc={desc}",
);
assert!(
desc.contains("1 (Q1 Planning) — Quarter 1 plan"),
"tool={tool}"
);
assert!(desc.contains("7 (Sprint Board)"), "tool={tool}");
}
}
#[test]
fn jira_enricher_sorts_structures_by_id_in_description() {
let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
crate::metadata::JiraStructureRef {
id: 42,
name: "Roadmap".into(),
description: None,
},
crate::metadata::JiraStructureRef {
id: 1,
name: "Q1".into(),
description: None,
},
crate::metadata::JiraStructureRef {
id: 7,
name: "Sprint".into(),
description: None,
},
]));
let mut schema = structureid_schema();
enricher.enrich_schema("get_structure_forest", &mut schema);
let desc = schema.properties["structureId"]
.description
.clone()
.unwrap();
let idx_1 = desc.find("1 (Q1)").expect("id 1 missing");
let idx_7 = desc.find("7 (Sprint)").expect("id 7 missing");
let idx_42 = desc.find("42 (Roadmap)").expect("id 42 missing");
assert!(
idx_1 < idx_7 && idx_7 < idx_42,
"structures not sorted: {desc}"
);
}
#[test]
fn jira_enricher_leaves_schema_untouched_when_no_structures() {
let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![]));
let mut schema = structureid_schema();
let original_desc = schema.properties["structureId"]
.description
.clone()
.unwrap();
enricher.enrich_schema("get_structure_forest", &mut schema);
let desc_after = schema.properties["structureId"]
.description
.clone()
.unwrap();
assert_eq!(desc_after, original_desc);
}
#[test]
fn jira_enricher_does_not_touch_get_structures_or_create_structure() {
let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
crate::metadata::JiraStructureRef {
id: 1,
name: "One".into(),
description: None,
},
]));
for tool in ["get_structures", "create_structure"] {
let mut schema = ToolSchema::from_json(&json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "original" },
"points": { "type": "integer", "description": "must survive" }
}
}));
enricher.enrich_schema(tool, &mut schema);
assert!(
!schema.properties.contains_key("structureId"),
"enricher inserted structureId on {tool}",
);
assert!(
schema.properties.contains_key("points"),
"enricher dropped `points` on {tool} — IssueTracker branch leaked into Structure handling",
);
assert_eq!(
schema.properties["name"].description.as_deref(),
Some("original"),
"enricher mutated `name` description on {tool}",
);
}
}
#[test]
fn jira_enricher_skips_issuetracker_branch_for_structure_tools() {
let enricher = JiraSchemaEnricher::new(metadata_with_structures(vec![
crate::metadata::JiraStructureRef {
id: 1,
name: "One".into(),
description: None,
},
]));
let mut schema = ToolSchema::from_json(&json!({
"type": "object",
"properties": {
"structureId": { "type": "integer", "description": "old" },
"points": { "type": "integer", "description": "must survive" }
}
}));
enricher.enrich_schema("get_structure_forest", &mut schema);
assert!(schema.properties.contains_key("points"));
}
}