1use devboy_core::{
7 CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
8 ToolValueModel, ValueClass, sanitize_field_name,
9};
10use serde_json::{Value, json};
11
12use crate::metadata::{ClickUpFieldType, ClickUpMetadata};
13
14pub struct ClickUpSchemaEnricher {
25 metadata: ClickUpMetadata,
26}
27
28impl ClickUpSchemaEnricher {
29 pub fn new(metadata: ClickUpMetadata) -> Self {
32 Self { metadata }
33 }
34}
35
36const REMOVE_PARAMS: &[&str] = &["issueType", "components", "projectId"];
38
39const GET_ISSUES_REMOVE_PARAMS: &[&str] = &["projectKey", "nativeQuery"];
40
41impl ToolEnricher for ClickUpSchemaEnricher {
42 fn supported_categories(&self) -> &[ToolCategory] {
43 &[ToolCategory::IssueTracker, ToolCategory::Epics]
44 }
45
46 fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema) {
47 schema.remove_params(REMOVE_PARAMS);
48
49 if tool_name == "get_issues" {
50 schema.remove_params(GET_ISSUES_REMOVE_PARAMS);
51
52 schema.add_enum_param(
54 "stateCategory",
55 &["backlog", "todo", "in_progress", "done", "cancelled"],
56 "Filter by semantic status category. Maps to provider-specific statuses using name heuristics.",
57 );
58
59 schema.add_enum_param(
61 "labelsOperator",
62 &["and", "or"],
63 "Label matching logic: 'and' requires all labels, 'or' requires any (default: 'or').",
64 );
65 }
66
67 if !self.metadata.statuses.is_empty() {
69 let status_names: Vec<String> = self
70 .metadata
71 .statuses
72 .iter()
73 .map(|s| s.name.clone())
74 .collect();
75
76 schema.set_enum("status", &status_names);
77 let desc = format!(
78 "Filter by exact status name. Available: {}",
79 status_names.join(", ")
80 );
81 schema.set_description("status", &desc);
82 }
83
84 schema.add_enum_param(
86 "priority",
87 &["urgent", "high", "normal", "low"],
88 "Priority. Available: urgent, high, normal, low",
89 );
90
91 if tool_name == "link_issues" {
93 schema.add_enum_param(
94 "link_type",
95 &["blocks", "blocked_by", "relates_to", "subtask"],
96 "Link type between tasks",
97 );
98 }
99
100 if tool_name == "create_issue" || tool_name == "update_issue" {
102 schema.remove_params(&["customFields"]);
103
104 for field in &self.metadata.custom_fields {
105 let field_schema = custom_field_to_schema(field);
106 if field_schema.is_null() {
107 continue; }
109 let param_name = sanitize_field_name(&field.name);
110 schema.add_param(¶m_name, field_schema);
111 }
112 }
113 }
114
115 fn transform_args(&self, tool_name: &str, args: &mut Value) {
116 let is_issue_tool = tool_name == "create_issue" || tool_name == "update_issue";
117 let is_epic_tool = tool_name == "create_epic" || tool_name == "update_epic";
118
119 if !is_issue_tool && !is_epic_tool {
120 return;
121 }
122
123 if is_epic_tool
126 && let Some(obj) = args.as_object_mut()
127 && let Some(goal_id) = obj.get("goalId").cloned()
128 {
129 let cf_name = sanitize_field_name("Goals");
130 obj.insert(cf_name, goal_id);
131 }
132
133 if is_issue_tool
136 && let Some(obj) = args.as_object_mut()
137 && let Some(priority) = obj.get("priority").and_then(|v| v.as_str())
138 {
139 let numeric = match priority {
140 "urgent" => 1,
141 "high" => 2,
142 "normal" => 3,
143 "low" => 4,
144 _ => 3, };
146 obj.insert("priority".into(), json!(numeric));
147 }
148
149 let Some(obj) = args.as_object_mut() else {
151 return;
152 };
153
154 let mut custom_fields: Vec<Value> = Vec::new();
155 let mut cf_keys_to_remove: Vec<String> = Vec::new();
156
157 for field in &self.metadata.custom_fields {
158 let param_name = sanitize_field_name(&field.name);
159 if let Some(value) = obj.get(¶m_name) {
160 let transformed = field.transform_value(value);
161 custom_fields.push(json!({
162 "id": field.id,
163 "value": transformed,
164 }));
165 cf_keys_to_remove.push(param_name);
166 }
167 }
168
169 for key in cf_keys_to_remove {
171 obj.remove(&key);
172 }
173 if !custom_fields.is_empty() {
174 obj.insert("customFields".into(), json!(custom_fields));
175 }
176 }
177
178 fn value_model(&self, tool_name: &str) -> Option<ToolValueModel> {
180 let model = match tool_name {
181 "get_issues" => ToolValueModel {
182 value_class: ValueClass::Supporting,
183 cost_model: CostModel {
184 typical_kb: 4.0,
185 latency_ms_p50: Some(420),
186 freshness_ttl_s: Some(60),
187 ..CostModel::default()
188 },
189 follow_up: vec![
190 FollowUpLink {
191 tool: "get_issue".into(),
192 probability: 0.50,
193 projection: Some("id".into()),
194 projection_arg: Some("key".into()),
195 },
196 FollowUpLink {
197 tool: "get_issue_comments".into(),
198 probability: 0.40,
199 projection: Some("id".into()),
200 projection_arg: Some("key".into()),
201 },
202 ],
203 side_effect_class: SideEffectClass::ReadOnly,
204 ..ToolValueModel::default()
205 },
206 "get_issue" => ToolValueModel {
207 value_class: ValueClass::Critical,
208 cost_model: CostModel {
209 typical_kb: 1.4,
210 latency_ms_p50: Some(220),
211 freshness_ttl_s: Some(60),
212 ..CostModel::default()
213 },
214 follow_up: vec![FollowUpLink {
215 tool: "get_issue_comments".into(),
216 probability: 0.50,
217 projection: Some("id".into()),
218 projection_arg: Some("key".into()),
219 }],
220 side_effect_class: SideEffectClass::ReadOnly,
221 ..ToolValueModel::default()
222 },
223 "get_issue_comments" => ToolValueModel {
224 value_class: ValueClass::Critical,
225 cost_model: CostModel {
226 typical_kb: 2.0,
227 latency_ms_p50: Some(260),
228 freshness_ttl_s: Some(60),
229 ..CostModel::default()
230 },
231 side_effect_class: SideEffectClass::ReadOnly,
232 ..ToolValueModel::default()
233 },
234 "create_issue" | "update_issue" | "add_issue_comment" | "link_issues" => {
235 ToolValueModel {
236 value_class: ValueClass::Supporting,
237 cost_model: CostModel {
238 typical_kb: 0.5,
239 latency_ms_p50: Some(360),
240 ..CostModel::default()
241 },
242 side_effect_class: SideEffectClass::MutatesExternal,
243 ..ToolValueModel::default()
244 }
245 }
246 _ => return None,
247 };
248 Some(model)
249 }
250
251 fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
253 Some("api.clickup.com".into())
254 }
255}
256
257fn custom_field_to_schema(field: &crate::metadata::ClickUpCustomField) -> Value {
259 let type_desc = match field.field_type {
260 ClickUpFieldType::Dropdown => {
261 let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
262 return json!({
263 "type": "string",
264 "enum": options,
265 "description": format!("Custom field: {} (dropdown). Select one option.", field.name),
266 "x-enriched": true,
267 });
268 }
269 ClickUpFieldType::Labels => {
270 let options: Vec<&str> = field.options.iter().map(|o| o.name.as_str()).collect();
271 return json!({
272 "type": "array",
273 "items": { "type": "string", "enum": options },
274 "description": format!("Custom field: {} (labels). Select one or more.", field.name),
275 "x-enriched": true,
276 });
277 }
278 ClickUpFieldType::Number | ClickUpFieldType::Currency => {
279 return json!({
280 "type": "number",
281 "description": format!("Custom field: {} ({:?}).", field.name, field.field_type),
282 "x-enriched": true,
283 });
284 }
285 ClickUpFieldType::Checkbox => {
286 return json!({
287 "type": "boolean",
288 "description": format!("Custom field: {} (checkbox).", field.name),
289 "x-enriched": true,
290 });
291 }
292 ClickUpFieldType::Date => "date (ISO 8601)",
293 ClickUpFieldType::Text => "text",
294 ClickUpFieldType::Email => "email",
295 ClickUpFieldType::Url => "url",
296 ClickUpFieldType::Phone => "phone",
297 ClickUpFieldType::Unknown => return json!(null), };
299
300 json!({
301 "type": "string",
302 "description": format!("Custom field: {} ({}).", field.name, type_desc),
303 "x-enriched": true,
304 })
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::metadata::*;
311
312 fn sample_metadata() -> ClickUpMetadata {
313 ClickUpMetadata {
314 statuses: vec![
315 ClickUpStatus {
316 name: "To Do".into(),
317 r#type: Some("open".into()),
318 },
319 ClickUpStatus {
320 name: "In Progress".into(),
321 r#type: Some("custom".into()),
322 },
323 ClickUpStatus {
324 name: "Done".into(),
325 r#type: Some("closed".into()),
326 },
327 ],
328 custom_fields: vec![
329 ClickUpCustomField {
330 id: "uuid-1".into(),
331 name: "Story Points".into(),
332 field_type: ClickUpFieldType::Number,
333 required: false,
334 options: vec![],
335 },
336 ClickUpCustomField {
337 id: "uuid-2".into(),
338 name: "Risk Level".into(),
339 field_type: ClickUpFieldType::Dropdown,
340 required: false,
341 options: vec![
342 ClickUpFieldOption {
343 id: "opt-1".into(),
344 name: "Low".into(),
345 orderindex: Some(0),
346 },
347 ClickUpFieldOption {
348 id: "opt-2".into(),
349 name: "Medium".into(),
350 orderindex: Some(1),
351 },
352 ClickUpFieldOption {
353 id: "opt-3".into(),
354 name: "High".into(),
355 orderindex: Some(2),
356 },
357 ],
358 },
359 ],
360 }
361 }
362
363 #[test]
364 fn test_clickup_enricher_adds_status_enum() {
365 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
366 let mut schema = ToolSchema::from_json(&json!({
367 "type": "object",
368 "properties": {
369 "status": { "type": "string" },
370 },
371 }));
372
373 enricher.enrich_schema("get_issues", &mut schema);
374
375 let status = schema.properties.get("status").unwrap();
376 assert_eq!(
377 status.enum_values,
378 Some(vec!["To Do".into(), "In Progress".into(), "Done".into()])
379 );
380 }
381
382 #[test]
383 fn test_clickup_enricher_adds_priority_enum() {
384 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
385 let mut schema = ToolSchema::new();
386
387 enricher.enrich_schema("create_issue", &mut schema);
388
389 let priority = schema.properties.get("priority").unwrap();
390 assert_eq!(
391 priority.enum_values,
392 Some(vec![
393 "urgent".into(),
394 "high".into(),
395 "normal".into(),
396 "low".into()
397 ])
398 );
399 }
400
401 #[test]
402 fn test_clickup_enricher_adds_custom_field_params() {
403 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
404 let mut schema = ToolSchema::from_json(&json!({
405 "type": "object",
406 "properties": {
407 "title": { "type": "string" },
408 "customFields": { "type": "object" },
409 },
410 }));
411
412 enricher.enrich_schema("create_issue", &mut schema);
413
414 assert!(!schema.properties.contains_key("customFields"));
416
417 assert!(schema.properties.contains_key("cf_story_points"));
419 assert!(schema.properties.contains_key("cf_risk_level"));
420
421 let risk = schema.properties.get("cf_risk_level").unwrap();
422 assert_eq!(
423 risk.enum_values,
424 Some(vec!["Low".into(), "Medium".into(), "High".into()])
425 );
426
427 let points = schema.properties.get("cf_story_points").unwrap();
428 assert_eq!(points.schema_type, "number");
429 }
430
431 #[test]
432 fn test_clickup_enricher_removes_unsupported_params() {
433 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
434 let mut schema = ToolSchema::from_json(&json!({
435 "type": "object",
436 "properties": {
437 "title": { "type": "string" },
438 "issueType": { "type": "string" },
439 "components": { "type": "array" },
440 "projectId": { "type": "string" },
441 },
442 }));
443
444 enricher.enrich_schema("create_issue", &mut schema);
445
446 assert!(!schema.properties.contains_key("issueType"));
447 assert!(!schema.properties.contains_key("components"));
448 assert!(!schema.properties.contains_key("projectId"));
449 }
450
451 #[test]
452 fn test_clickup_enricher_transform_args_priority() {
453 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
454 let mut args = json!({
455 "title": "Test",
456 "priority": "high",
457 });
458
459 enricher.transform_args("create_issue", &mut args);
460
461 assert_eq!(args["priority"], 2);
462 }
463
464 #[test]
465 fn test_clickup_enricher_transform_args_custom_fields() {
466 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
467 let mut args = json!({
468 "title": "Test",
469 "cf_story_points": 5,
470 "cf_risk_level": "Medium",
471 });
472
473 enricher.transform_args("create_issue", &mut args);
474
475 assert!(args.get("cf_story_points").is_none());
477 assert!(args.get("cf_risk_level").is_none());
478
479 let custom_fields = args["customFields"].as_array().unwrap();
481 assert_eq!(custom_fields.len(), 2);
482
483 let sp = custom_fields.iter().find(|f| f["id"] == "uuid-1").unwrap();
485 assert_eq!(sp["value"], 5);
486
487 let rl = custom_fields.iter().find(|f| f["id"] == "uuid-2").unwrap();
489 assert_eq!(rl["value"], 1); }
491
492 #[test]
493 fn test_clickup_enricher_transform_args_skips_non_create() {
494 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
495 let mut args = json!({"cf_story_points": 5});
496 enricher.transform_args("get_issues", &mut args);
497 assert!(args.get("cf_story_points").is_some());
499 }
500
501 #[test]
502 fn test_clickup_enricher_transform_args_no_custom_fields() {
503 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
504 let mut args = json!({"title": "Test"});
505 enricher.transform_args("create_issue", &mut args);
506 assert!(args.get("customFields").is_none());
508 }
509
510 #[test]
511 fn test_clickup_enricher_link_types() {
512 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
513 let mut schema = ToolSchema::new();
514 enricher.enrich_schema("link_issues", &mut schema);
515 let lt = schema.properties.get("link_type").unwrap();
516 assert_eq!(
517 lt.enum_values,
518 Some(vec![
519 "blocks".into(),
520 "blocked_by".into(),
521 "relates_to".into(),
522 "subtask".into()
523 ])
524 );
525 }
526
527 #[test]
528 fn test_clickup_enricher_empty_metadata() {
529 let enricher = ClickUpSchemaEnricher::new(ClickUpMetadata {
530 statuses: vec![],
531 custom_fields: vec![],
532 });
533 let mut schema = ToolSchema::from_json(&json!({
534 "type": "object",
535 "properties": {
536 "customFields": { "type": "object" }
537 }
538 }));
539 enricher.enrich_schema("create_issue", &mut schema);
540 assert!(!schema.properties.contains_key("customFields"));
542 assert!(schema.properties.contains_key("priority")); }
544
545 #[test]
546 fn test_clickup_enricher_priority_default() {
547 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
548 let mut args = json!({"title": "Test", "priority": "unknown_value"});
549 enricher.transform_args("create_issue", &mut args);
550 assert_eq!(args["priority"], 3); }
552
553 #[test]
554 fn test_clickup_enricher_state_category_not_removed() {
555 let enricher = ClickUpSchemaEnricher::new(sample_metadata());
556 let mut schema = ToolSchema::from_json(&json!({
557 "type": "object",
558 "properties": {
559 "stateCategory": { "type": "string" },
560 "nativeQuery": { "type": "string" },
561 "projectKey": { "type": "string" },
562 },
563 }));
564
565 enricher.enrich_schema("get_issues", &mut schema);
566
567 assert!(schema.properties.contains_key("stateCategory"));
569 let sc = schema.properties.get("stateCategory").unwrap();
570 assert_eq!(
571 sc.enum_values,
572 Some(vec![
573 "backlog".into(),
574 "todo".into(),
575 "in_progress".into(),
576 "done".into(),
577 "cancelled".into(),
578 ])
579 );
580
581 assert!(!schema.properties.contains_key("nativeQuery"));
583 assert!(!schema.properties.contains_key("projectKey"));
584 }
585}