Skip to main content

devboy_clickup/
enricher.rs

1//! ClickUp schema enricher.
2//!
3//! Dynamic enricher that uses list metadata to populate enum values
4//! and generate custom field parameters.
5
6use 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
14/// Dynamic schema enricher for ClickUp provider.
15///
16/// Created with metadata from a ClickUp list. Adds:
17/// - Status enum from list statuses
18/// - Priority enum (static: urgent, high, normal, low)
19/// - Custom field `cf_*` parameters from list custom fields
20/// - Link types enum
21///
22/// Removes:
23/// - `issueType`, `components`, `projectId` (not applicable)
24pub struct ClickUpSchemaEnricher {
25    metadata: ClickUpMetadata,
26}
27
28impl ClickUpSchemaEnricher {
29    /// Build an enricher from cached workspace metadata (custom fields,
30    /// statuses) used to refine MCP tool schemas at runtime.
31    pub fn new(metadata: ClickUpMetadata) -> Self {
32        Self { metadata }
33    }
34}
35
36/// Parameters to remove from ClickUp issue tools.
37const 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            // Add stateCategory enum for semantic status filtering
53            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            // Add labelsOperator enum
60            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        // Add status enum from metadata
68        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        // Add priority enum (static for ClickUp)
85        schema.add_enum_param(
86            "priority",
87            &["urgent", "high", "normal", "low"],
88            "Priority. Available: urgent, high, normal, low",
89        );
90
91        // Add link types for link_issues
92        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        // Replace customFields with individual cf_* params
101        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; // Skip unsupported field types
108                }
109                let param_name = sanitize_field_name(&field.name);
110                schema.add_param(&param_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        // For epic tools: copy goalId → cf_goals so custom field transform picks it up.
124        // Keep goalId in args — executor needs it for tag transition.
125        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        // Transform priority name to ClickUp numeric value (only for direct issue tools,
134        // epic tools pass priority as string to executor which handles conversion).
135        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, // default to normal
145            };
146            obj.insert("priority".into(), json!(numeric));
147        }
148
149        // Transform cf_* params to customFields array
150        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(&param_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        // Remove cf_* params and insert customFields
170        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    /// Paper 3 — ClickUp issue → comments / detail chain.
179    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    /// ClickUp SaaS uses `api.clickup.com`.
252    fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
253        Some("api.clickup.com".into())
254    }
255}
256
257/// Convert a ClickUp custom field definition to a JSON Schema property.
258fn 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), // Skip unsupported field types
298    };
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        // customFields removed
415        assert!(!schema.properties.contains_key("customFields"));
416
417        // cf_* params added
418        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        // cf_* params removed
476        assert!(args.get("cf_story_points").is_none());
477        assert!(args.get("cf_risk_level").is_none());
478
479        // customFields created
480        let custom_fields = args["customFields"].as_array().unwrap();
481        assert_eq!(custom_fields.len(), 2);
482
483        // Story Points: pass-through
484        let sp = custom_fields.iter().find(|f| f["id"] == "uuid-1").unwrap();
485        assert_eq!(sp["value"], 5);
486
487        // Risk Level: name → orderindex
488        let rl = custom_fields.iter().find(|f| f["id"] == "uuid-2").unwrap();
489        assert_eq!(rl["value"], 1); // Medium = orderindex 1
490    }
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        // Should not transform — only create/update
498        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        // No cf_* → no customFields key
507        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        // customFields removed, no cf_* added
541        assert!(!schema.properties.contains_key("customFields"));
542        assert!(schema.properties.contains_key("priority")); // always added
543    }
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); // default to normal
551    }
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        // stateCategory should be enriched with enum, NOT removed
568        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        // These should still be removed
582        assert!(!schema.properties.contains_key("nativeQuery"));
583        assert!(!schema.properties.contains_key("projectKey"));
584    }
585}