1use 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"];
25use 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 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}