Skip to main content

devboy_linear/
enricher.rs

1//! Linear schema enricher.
2//!
3//! Static enrichment for the Linear issue-tracker provider.
4//!
5//! Linear supports the common issue-tool surface but not Jira-only
6//! fields such as issue types or project keys on read paths. It also
7//! exposes a fixed priority scale and semantic status categories that
8//! are worth surfacing directly in the tool schema even without cached
9//! per-team workflow metadata.
10
11use devboy_core::{ToolCategory, ToolEnricher, ToolSchema};
12use serde_json::Value;
13
14use crate::metadata::LinearMetadata;
15
16pub struct LinearSchemaEnricher;
17pub struct DynamicLinearSchemaEnricher {
18    metadata: LinearMetadata,
19}
20
21const CREATE_UPDATE_REMOVE_PARAMS: &[&str] = &["issueType"];
22const GET_ISSUES_REMOVE_PARAMS: &[&str] = &["projectKey", "nativeQuery"];
23
24const PRIORITY_VALUES: &[&str] = &["urgent", "high", "normal", "low"];
25/// Advertised values come straight from the client so the schema can never
26/// drift from what `build_issue_filter` actually accepts.
27use crate::client::STATE_CATEGORIES as STATE_CATEGORY_VALUES;
28const LABELS_OPERATOR_VALUES: &[&str] = &["and", "or"];
29
30impl DynamicLinearSchemaEnricher {
31    pub fn new(metadata: LinearMetadata) -> Self {
32        Self { metadata }
33    }
34}
35
36impl ToolEnricher for LinearSchemaEnricher {
37    fn supported_categories(&self) -> &[ToolCategory] {
38        &[ToolCategory::IssueTracker]
39    }
40
41    fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema) {
42        if tool_name == "create_issue" || tool_name == "update_issue" {
43            schema.remove_params(CREATE_UPDATE_REMOVE_PARAMS);
44            schema.add_enum_param(
45                "priority",
46                PRIORITY_VALUES,
47                "Linear priority. Available: urgent, high, normal, low",
48            );
49
50            if tool_name == "create_issue" {
51                schema.set_description(
52                    "projectId",
53                    "Optional Linear project ID (UUID). Overrides the default team-only placement when supplied.",
54                );
55                schema.set_description(
56                    "parentId",
57                    "Parent Linear issue key or native UUID. Creates a sub-issue when supplied.",
58                );
59            } else {
60                schema.set_description(
61                    "status",
62                    "Exact Linear workflow state name for this team (for example, \"In Review\"). Takes precedence over `state`. Use `get_available_statuses` to discover valid names.",
63                );
64                schema.set_description(
65                    "state",
66                    "Generic state shortcut. `open` maps to a non-completed workflow state; `closed` maps to a completed workflow state. For an exact Linear workflow state name, use `status` instead.",
67                );
68                schema.set_description(
69                    "parentId",
70                    "Parent Linear issue key or native UUID. Set to `none` or empty string to detach from the current parent.",
71                );
72            }
73        }
74
75        if tool_name == "get_issues" {
76            schema.remove_params(GET_ISSUES_REMOVE_PARAMS);
77            schema.add_enum_param(
78                "stateCategory",
79                STATE_CATEGORY_VALUES,
80                "Filter by semantic Linear workflow category: backlog, todo, in_progress, done, cancelled",
81            );
82            schema.add_enum_param(
83                "labelsOperator",
84                LABELS_OPERATOR_VALUES,
85                "Label matching logic: `and` requires all labels, `or` requires any label (default: `or`)",
86            );
87            schema.set_description(
88                "state",
89                "Filter by issue state. Built-in values: `open`, `closed`, `all`. You can also pass an exact Linear workflow state name.",
90            );
91            schema.set_description(
92                "assignee",
93                "Filter by assignee name, display name, or email.",
94            );
95            // Linear's PaginationOrderBy only exposes descending order, so
96            // advertise just that rather than letting a caller pick `asc`
97            // and get an error back.
98            schema.set_enum("sort_order", &["desc".to_string()]);
99            schema.set_description(
100                "sort_order",
101                "Linear paginates newest-first only; `asc` is not available",
102            );
103        }
104    }
105
106    fn transform_args(&self, _tool_name: &str, _args: &mut Value) {}
107}
108
109impl ToolEnricher for DynamicLinearSchemaEnricher {
110    fn supported_categories(&self) -> &[ToolCategory] {
111        &[ToolCategory::IssueTracker]
112    }
113
114    fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema) {
115        LinearSchemaEnricher.enrich_schema(tool_name, schema);
116
117        if tool_name == "update_issue" && !self.metadata.statuses.is_empty() {
118            let names: Vec<String> = self
119                .metadata
120                .statuses
121                .iter()
122                .map(|status| status.name.clone())
123                .collect();
124            schema.set_enum("status", &names);
125            schema.set_description(
126                "status",
127                &format!(
128                    "Exact Linear workflow state name for this team. Available: {}. Takes precedence over `state`",
129                    names.join(", ")
130                ),
131            );
132        }
133
134        if tool_name == "get_issues" && !self.metadata.statuses.is_empty() {
135            let names: Vec<String> = self
136                .metadata
137                .statuses
138                .iter()
139                .map(|status| status.name.clone())
140                .collect();
141            schema.set_description(
142                "state",
143                &format!(
144                    "Filter by issue state. Built-in values: `open`, `closed`, `all`. You can also pass an exact Linear workflow state name. Known team states: {}",
145                    names.join(", ")
146                ),
147            );
148        }
149    }
150
151    fn transform_args(&self, _tool_name: &str, _args: &mut Value) {}
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use serde_json::json;
158
159    #[test]
160    fn linear_enricher_adds_issue_filters_and_priority_enums() {
161        let enricher = LinearSchemaEnricher;
162
163        let mut get_issues = ToolSchema::from_json(&json!({
164            "type": "object",
165            "properties": {
166                "state": { "type": "string" },
167                "projectKey": { "type": "string" },
168                "nativeQuery": { "type": "string" }
169            }
170        }));
171        enricher.enrich_schema("get_issues", &mut get_issues);
172
173        assert!(!get_issues.properties.contains_key("projectKey"));
174        assert!(!get_issues.properties.contains_key("nativeQuery"));
175        assert!(get_issues.properties.contains_key("stateCategory"));
176        assert!(get_issues.properties.contains_key("labelsOperator"));
177
178        let mut create_issue = ToolSchema::from_json(&json!({
179            "type": "object",
180            "properties": {
181                "priority": { "type": "string" },
182                "issueType": { "type": "string" },
183                "projectId": { "type": "string" },
184                "parentId": { "type": "string" }
185            }
186        }));
187        enricher.enrich_schema("create_issue", &mut create_issue);
188
189        assert!(!create_issue.properties.contains_key("issueType"));
190        let priority = create_issue.properties.get("priority").unwrap();
191        assert_eq!(
192            priority.enum_values.as_ref().unwrap(),
193            &vec![
194                "urgent".to_string(),
195                "high".to_string(),
196                "normal".to_string(),
197                "low".to_string()
198            ]
199        );
200        assert!(
201            create_issue.properties["projectId"]
202                .description
203                .as_deref()
204                .unwrap()
205                .contains("UUID")
206        );
207    }
208
209    #[test]
210    fn linear_enricher_updates_status_descriptions() {
211        let enricher = LinearSchemaEnricher;
212        let mut update_issue = ToolSchema::from_json(&json!({
213            "type": "object",
214            "properties": {
215                "state": { "type": "string" },
216                "status": { "type": "string" },
217                "priority": { "type": "string" },
218                "parentId": { "type": "string" },
219                "issueType": { "type": "string" }
220            }
221        }));
222
223        enricher.enrich_schema("update_issue", &mut update_issue);
224
225        assert!(!update_issue.properties.contains_key("issueType"));
226        assert!(
227            update_issue.properties["status"]
228                .description
229                .as_deref()
230                .unwrap()
231                .contains("get_available_statuses")
232        );
233        assert!(
234            update_issue.properties["state"]
235                .description
236                .as_deref()
237                .unwrap()
238                .contains("Generic state shortcut")
239        );
240    }
241
242    #[test]
243    fn dynamic_linear_enricher_sets_exact_status_enum() {
244        let enricher = DynamicLinearSchemaEnricher::new(LinearMetadata {
245            statuses: vec![
246                crate::metadata::LinearStatus {
247                    id: "1".into(),
248                    name: "Backlog".into(),
249                    category: Some("backlog".into()),
250                },
251                crate::metadata::LinearStatus {
252                    id: "2".into(),
253                    name: "In Review".into(),
254                    category: Some("in_progress".into()),
255                },
256            ],
257        });
258        let mut update_issue = ToolSchema::from_json(&json!({
259            "type": "object",
260            "properties": {
261                "status": { "type": "string" },
262                "state": { "type": "string" }
263            }
264        }));
265
266        enricher.enrich_schema("update_issue", &mut update_issue);
267
268        assert_eq!(
269            update_issue.properties["status"]
270                .enum_values
271                .as_ref()
272                .unwrap(),
273            &vec!["Backlog".to_string(), "In Review".to_string()]
274        );
275        assert!(
276            update_issue.properties["status"]
277                .description
278                .as_deref()
279                .unwrap()
280                .contains("Available: Backlog, In Review")
281        );
282    }
283
284    #[test]
285    fn linear_enricher_reports_supported_category_and_preserves_unknown_tools() {
286        let enricher = LinearSchemaEnricher;
287        assert_eq!(
288            enricher.supported_categories(),
289            &[ToolCategory::IssueTracker]
290        );
291
292        let original = json!({
293            "type": "object",
294            "properties": {
295                "custom": { "type": "string" }
296            }
297        });
298        let mut schema = ToolSchema::from_json(&original);
299        enricher.enrich_schema("get_merge_requests", &mut schema);
300
301        let mut args = json!({ "custom": "value" });
302        enricher.transform_args("get_merge_requests", &mut args);
303
304        assert!(schema.properties.contains_key("custom"));
305        assert_eq!(args["custom"], "value");
306    }
307
308    #[test]
309    fn dynamic_linear_enricher_updates_get_issues_state_description() {
310        let enricher = DynamicLinearSchemaEnricher::new(LinearMetadata {
311            statuses: vec![
312                crate::metadata::LinearStatus {
313                    id: "1".into(),
314                    name: "Backlog".into(),
315                    category: Some("backlog".into()),
316                },
317                crate::metadata::LinearStatus {
318                    id: "2".into(),
319                    name: "Canceled".into(),
320                    category: Some("cancelled".into()),
321                },
322            ],
323        });
324        let mut get_issues = ToolSchema::from_json(&json!({
325            "type": "object",
326            "properties": {
327                "state": { "type": "string" },
328                "assignee": { "type": "string" }
329            }
330        }));
331
332        enricher.enrich_schema("get_issues", &mut get_issues);
333
334        assert!(
335            get_issues.properties["state"]
336                .description
337                .as_deref()
338                .unwrap()
339                .contains("Known team states: Backlog, Canceled")
340        );
341        assert!(
342            get_issues.properties["assignee"]
343                .description
344                .as_deref()
345                .unwrap()
346                .contains("display name")
347        );
348    }
349}