1use crate::LinearTools;
6use crate::models::ArchiveIssueResult;
7use crate::models::CommentResult;
8use crate::models::CreateIssueResult;
9use crate::models::GetMetadataResult;
10use crate::models::IssueDetails;
11use crate::models::IssueResult;
12use crate::models::SearchResult;
13use crate::models::SetRelationResult;
14use agentic_tools_core::Tool;
15use agentic_tools_core::ToolContext;
16use agentic_tools_core::ToolError;
17use agentic_tools_core::ToolRegistry;
18use futures::future::BoxFuture;
19use schemars::JsonSchema;
20use serde::Deserialize;
21use std::sync::Arc;
22
23#[derive(Debug, Clone, Deserialize, JsonSchema)]
29pub struct SearchIssuesInput {
30 #[serde(default)]
32 pub query: Option<String>,
33 #[serde(default)]
35 pub include_comments: Option<bool>,
36 #[serde(default)]
38 pub priority: Option<i32>,
39 #[serde(default)]
41 pub state_id: Option<String>,
42 #[serde(default)]
44 pub assignee_id: Option<String>,
45 #[serde(default)]
47 pub creator_id: Option<String>,
48 #[serde(default)]
50 pub team_id: Option<String>,
51 #[serde(default)]
53 pub project_id: Option<String>,
54 #[serde(default)]
56 pub created_after: Option<String>,
57 #[serde(default)]
59 pub created_before: Option<String>,
60 #[serde(default)]
62 pub updated_after: Option<String>,
63 #[serde(default)]
65 pub updated_before: Option<String>,
66 #[serde(default)]
68 pub first: Option<i32>,
69 #[serde(default)]
71 pub after: Option<String>,
72}
73
74#[derive(Clone)]
76pub struct SearchIssuesTool {
77 linear: Arc<LinearTools>,
78}
79
80impl SearchIssuesTool {
81 pub fn new(linear: Arc<LinearTools>) -> Self {
82 Self { linear }
83 }
84}
85
86impl Tool for SearchIssuesTool {
87 type Input = SearchIssuesInput;
88 type Output = SearchResult;
89 const NAME: &'static str = "linear_search_issues";
90 const DESCRIPTION: &'static str = "Search Linear issues using full-text search and/or filters";
91
92 fn call(
93 &self,
94 input: Self::Input,
95 _ctx: &ToolContext,
96 ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
97 let linear = self.linear.clone();
98 Box::pin(async move {
99 linear
100 .search_issues(
101 input.query,
102 input.include_comments,
103 input.priority,
104 input.state_id,
105 input.assignee_id,
106 input.creator_id,
107 input.team_id,
108 input.project_id,
109 input.created_after,
110 input.created_before,
111 input.updated_after,
112 input.updated_before,
113 input.first,
114 input.after,
115 )
116 .await
117 .map_err(map_anyhow_to_tool_error)
118 })
119 }
120}
121
122#[derive(Debug, Clone, Deserialize, JsonSchema)]
128pub struct ReadIssueInput {
129 pub issue: String,
131}
132
133#[derive(Clone)]
135pub struct ReadIssueTool {
136 linear: Arc<LinearTools>,
137}
138
139impl ReadIssueTool {
140 pub fn new(linear: Arc<LinearTools>) -> Self {
141 Self { linear }
142 }
143}
144
145impl Tool for ReadIssueTool {
146 type Input = ReadIssueInput;
147 type Output = IssueDetails;
148 const NAME: &'static str = "linear_read_issue";
149 const DESCRIPTION: &'static str =
150 "Read a Linear issue by ID, identifier (e.g., ENG-245), or URL";
151
152 fn call(
153 &self,
154 input: Self::Input,
155 _ctx: &ToolContext,
156 ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
157 let linear = self.linear.clone();
158 Box::pin(async move {
159 linear
160 .read_issue(input.issue)
161 .await
162 .map_err(map_anyhow_to_tool_error)
163 })
164 }
165}
166
167#[derive(Debug, Clone, Deserialize, JsonSchema)]
173pub struct CreateIssueInput {
174 pub team_id: String,
176 pub title: String,
178 #[serde(default)]
180 pub description: Option<String>,
181 #[serde(default)]
183 pub priority: Option<i32>,
184 #[serde(default)]
186 pub assignee_id: Option<String>,
187 #[serde(default)]
189 pub project_id: Option<String>,
190 #[serde(default)]
192 pub state_id: Option<String>,
193 #[serde(default)]
195 pub parent_id: Option<String>,
196 #[serde(default)]
198 pub label_ids: Vec<String>,
199}
200
201#[derive(Clone)]
203pub struct CreateIssueTool {
204 linear: Arc<LinearTools>,
205}
206
207impl CreateIssueTool {
208 pub fn new(linear: Arc<LinearTools>) -> Self {
209 Self { linear }
210 }
211}
212
213impl Tool for CreateIssueTool {
214 type Input = CreateIssueInput;
215 type Output = CreateIssueResult;
216 const NAME: &'static str = "linear_create_issue";
217 const DESCRIPTION: &'static str = "Create a new Linear issue in a team";
218
219 fn call(
220 &self,
221 input: Self::Input,
222 _ctx: &ToolContext,
223 ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
224 let linear = self.linear.clone();
225 Box::pin(async move {
226 linear
227 .create_issue(
228 input.team_id,
229 input.title,
230 input.description,
231 input.priority,
232 input.assignee_id,
233 input.project_id,
234 input.state_id,
235 input.parent_id,
236 input.label_ids,
237 )
238 .await
239 .map_err(map_anyhow_to_tool_error)
240 })
241 }
242}
243
244#[derive(Debug, Clone, Deserialize, JsonSchema)]
250pub struct AddCommentInput {
251 pub issue: String,
253 pub body: String,
255 #[serde(default)]
257 pub parent_id: Option<String>,
258}
259
260#[derive(Clone)]
262pub struct AddCommentTool {
263 linear: Arc<LinearTools>,
264}
265
266impl AddCommentTool {
267 pub fn new(linear: Arc<LinearTools>) -> Self {
268 Self { linear }
269 }
270}
271
272impl Tool for AddCommentTool {
273 type Input = AddCommentInput;
274 type Output = CommentResult;
275 const NAME: &'static str = "linear_add_comment";
276 const DESCRIPTION: &'static str = "Add a comment to a Linear issue";
277
278 fn call(
279 &self,
280 input: Self::Input,
281 _ctx: &ToolContext,
282 ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
283 let linear = self.linear.clone();
284 Box::pin(async move {
285 linear
286 .add_comment(input.issue, input.body, input.parent_id)
287 .await
288 .map_err(map_anyhow_to_tool_error)
289 })
290 }
291}
292
293#[derive(Debug, Clone, Deserialize, JsonSchema)]
299pub struct GetIssueCommentsInput {
300 pub issue: String,
302}
303
304#[derive(Clone)]
306pub struct GetIssueCommentsTool {
307 linear: Arc<LinearTools>,
308}
309
310impl GetIssueCommentsTool {
311 pub fn new(linear: Arc<LinearTools>) -> Self {
312 Self { linear }
313 }
314}
315
316impl Tool for GetIssueCommentsTool {
317 type Input = GetIssueCommentsInput;
318 type Output = crate::models::CommentsResult;
319 const NAME: &'static str = "linear_get_issue_comments";
320 const DESCRIPTION: &'static str = "Get comments on a Linear issue. Returns 10 comments per call with implicit pagination - call again with the same issue to get more comments.";
321
322 fn call(
323 &self,
324 input: Self::Input,
325 _ctx: &ToolContext,
326 ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
327 let linear = self.linear.clone();
328 Box::pin(async move {
329 linear
330 .get_issue_comments(input.issue)
331 .await
332 .map_err(map_anyhow_to_tool_error)
333 })
334 }
335}
336
337#[derive(Debug, Clone, Deserialize, JsonSchema)]
343pub struct ArchiveIssueInput {
344 pub issue: String,
346}
347
348#[derive(Clone)]
350pub struct ArchiveIssueTool {
351 linear: Arc<LinearTools>,
352}
353
354impl ArchiveIssueTool {
355 pub fn new(linear: Arc<LinearTools>) -> Self {
356 Self { linear }
357 }
358}
359
360impl Tool for ArchiveIssueTool {
361 type Input = ArchiveIssueInput;
362 type Output = ArchiveIssueResult;
363 const NAME: &'static str = "linear_archive_issue";
364 const DESCRIPTION: &'static str =
365 "Archive a Linear issue by ID, identifier (e.g., ENG-245), or URL";
366
367 fn call(
368 &self,
369 input: Self::Input,
370 _ctx: &ToolContext,
371 ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
372 let linear = self.linear.clone();
373 Box::pin(async move {
374 linear
375 .archive_issue(input.issue)
376 .await
377 .map_err(map_anyhow_to_tool_error)
378 })
379 }
380}
381
382#[derive(Debug, Clone, Deserialize, JsonSchema)]
388pub struct UpdateIssueInput {
389 pub issue: String,
391 #[serde(default)]
393 pub title: Option<String>,
394 #[serde(default)]
396 pub description: Option<String>,
397 #[serde(default)]
399 pub priority: Option<i32>,
400 #[serde(default)]
402 pub assignee_id: Option<String>,
403 #[serde(default)]
405 pub state_id: Option<String>,
406 #[serde(default)]
408 pub project_id: Option<String>,
409 #[serde(default)]
411 pub parent_id: Option<String>,
412 #[serde(default)]
414 pub label_ids: Option<Vec<String>>,
415 #[serde(default)]
417 pub added_label_ids: Option<Vec<String>>,
418 #[serde(default)]
420 pub removed_label_ids: Option<Vec<String>>,
421 #[serde(default)]
423 pub due_date: Option<String>,
424}
425
426#[derive(Clone)]
428pub struct UpdateIssueTool {
429 linear: Arc<LinearTools>,
430}
431
432impl UpdateIssueTool {
433 pub fn new(linear: Arc<LinearTools>) -> Self {
434 Self { linear }
435 }
436}
437
438impl Tool for UpdateIssueTool {
439 type Input = UpdateIssueInput;
440 type Output = IssueResult;
441 const NAME: &'static str = "linear_update_issue";
442 const DESCRIPTION: &'static str = "Update an existing Linear issue. Use linear_get_metadata to look up user IDs, state IDs, project IDs, and label IDs.";
443
444 fn call(
445 &self,
446 input: Self::Input,
447 _ctx: &ToolContext,
448 ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
449 let linear = self.linear.clone();
450 Box::pin(async move {
451 linear
452 .update_issue(
453 input.issue,
454 input.title,
455 input.description,
456 input.priority,
457 input.assignee_id,
458 input.state_id,
459 input.project_id,
460 input.parent_id,
461 input.label_ids,
462 input.added_label_ids,
463 input.removed_label_ids,
464 input.due_date,
465 )
466 .await
467 .map_err(map_anyhow_to_tool_error)
468 })
469 }
470}
471
472#[derive(Debug, Clone, Deserialize, JsonSchema)]
478pub struct SetRelationInput {
479 pub issue: String,
481 pub related_issue: String,
483 #[serde(default)]
485 pub relation_type: Option<String>,
486}
487
488#[derive(Clone)]
490pub struct SetRelationTool {
491 linear: Arc<LinearTools>,
492}
493
494impl SetRelationTool {
495 pub fn new(linear: Arc<LinearTools>) -> Self {
496 Self { linear }
497 }
498}
499
500impl Tool for SetRelationTool {
501 type Input = SetRelationInput;
502 type Output = SetRelationResult;
503 const NAME: &'static str = "linear_set_relation";
504 const DESCRIPTION: &'static str = "Set or remove a relation between two issues. Provide relation_type to create (blocks/duplicate/related), or omit/null to remove any existing relation.";
505
506 fn call(
507 &self,
508 input: Self::Input,
509 _ctx: &ToolContext,
510 ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
511 let linear = self.linear.clone();
512 Box::pin(async move {
513 linear
514 .set_relation(input.issue, input.related_issue, input.relation_type)
515 .await
516 .map_err(map_anyhow_to_tool_error)
517 })
518 }
519}
520
521#[derive(Debug, Clone, Deserialize, JsonSchema)]
527pub struct GetMetadataInput {
528 pub kind: crate::models::MetadataKind,
530 #[serde(default)]
532 pub search: Option<String>,
533 #[serde(default)]
535 pub team_id: Option<String>,
536 #[serde(default)]
538 pub first: Option<i32>,
539 #[serde(default)]
541 pub after: Option<String>,
542}
543
544#[derive(Clone)]
546pub struct GetMetadataTool {
547 linear: Arc<LinearTools>,
548}
549
550impl GetMetadataTool {
551 pub fn new(linear: Arc<LinearTools>) -> Self {
552 Self { linear }
553 }
554}
555
556impl Tool for GetMetadataTool {
557 type Input = GetMetadataInput;
558 type Output = GetMetadataResult;
559 const NAME: &'static str = "linear_get_metadata";
560 const DESCRIPTION: &'static str = "Look up Linear metadata: users, teams, projects, workflow states, or labels. Use this to discover IDs for filtering and updating issues.";
561
562 fn call(
563 &self,
564 input: Self::Input,
565 _ctx: &ToolContext,
566 ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
567 let linear = self.linear.clone();
568 Box::pin(async move {
569 linear
570 .get_metadata(
571 input.kind,
572 input.search,
573 input.team_id,
574 input.first,
575 input.after,
576 )
577 .await
578 .map_err(map_anyhow_to_tool_error)
579 })
580 }
581}
582
583pub fn build_registry(linear: Arc<LinearTools>) -> ToolRegistry {
589 ToolRegistry::builder()
590 .register::<SearchIssuesTool, ()>(SearchIssuesTool::new(linear.clone()))
591 .register::<ReadIssueTool, ()>(ReadIssueTool::new(linear.clone()))
592 .register::<CreateIssueTool, ()>(CreateIssueTool::new(linear.clone()))
593 .register::<AddCommentTool, ()>(AddCommentTool::new(linear.clone()))
594 .register::<GetIssueCommentsTool, ()>(GetIssueCommentsTool::new(linear.clone()))
595 .register::<ArchiveIssueTool, ()>(ArchiveIssueTool::new(linear.clone()))
596 .register::<UpdateIssueTool, ()>(UpdateIssueTool::new(linear.clone()))
597 .register::<SetRelationTool, ()>(SetRelationTool::new(linear.clone()))
598 .register::<GetMetadataTool, ()>(GetMetadataTool::new(linear))
599 .finish()
600}
601
602fn map_anyhow_to_tool_error(e: anyhow::Error) -> ToolError {
608 let msg = e.to_string();
609 let lc = msg.to_lowercase();
610 if lc.contains("permission") || lc.contains("401") || lc.contains("403") {
611 ToolError::Permission(msg)
612 } else if lc.contains("not found") || lc.contains("404") {
613 ToolError::NotFound(msg)
614 } else if lc.contains("invalid") || lc.contains("bad request") {
615 ToolError::InvalidInput(msg)
616 } else if lc.contains("timeout") || lc.contains("network") || lc.contains("rate limit") {
617 ToolError::External(msg)
618 } else {
619 ToolError::Internal(msg)
620 }
621}