Skip to main content

linear_tools/
tools.rs

1//! Tool wrappers for linear_tools using agentic-tools-core.
2//!
3//! Each tool delegates to the corresponding method on [`LinearTools`].
4
5use 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// ============================================================================
24// SearchIssues Tool
25// ============================================================================
26
27/// Input for search_issues tool.
28#[derive(Debug, Clone, Deserialize, JsonSchema)]
29pub struct SearchIssuesInput {
30    /// Full-text search term (searches title, description, and optionally comments)
31    #[serde(default)]
32    pub query: Option<String>,
33    /// Include comments in full-text search (default: true, only applies when query is provided)
34    #[serde(default)]
35    pub include_comments: Option<bool>,
36    /// Filter by priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)
37    #[serde(default)]
38    pub priority: Option<i32>,
39    /// Workflow state ID (UUID)
40    #[serde(default)]
41    pub state_id: Option<String>,
42    /// Assignee user ID (UUID)
43    #[serde(default)]
44    pub assignee_id: Option<String>,
45    /// Creator user ID (UUID)
46    #[serde(default)]
47    pub creator_id: Option<String>,
48    /// Team ID (UUID)
49    #[serde(default)]
50    pub team_id: Option<String>,
51    /// Project ID (UUID)
52    #[serde(default)]
53    pub project_id: Option<String>,
54    /// Only issues created after this ISO 8601 date
55    #[serde(default)]
56    pub created_after: Option<String>,
57    /// Only issues created before this ISO 8601 date
58    #[serde(default)]
59    pub created_before: Option<String>,
60    /// Only issues updated after this ISO 8601 date
61    #[serde(default)]
62    pub updated_after: Option<String>,
63    /// Only issues updated before this ISO 8601 date
64    #[serde(default)]
65    pub updated_before: Option<String>,
66    /// Page size (default 50, max 100)
67    #[serde(default)]
68    pub first: Option<i32>,
69    /// Pagination cursor
70    #[serde(default)]
71    pub after: Option<String>,
72}
73
74/// Tool for searching Linear issues.
75#[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// ============================================================================
123// ReadIssue Tool
124// ============================================================================
125
126/// Input for read_issue tool.
127#[derive(Debug, Clone, Deserialize, JsonSchema)]
128pub struct ReadIssueInput {
129    /// Issue ID, identifier (e.g., ENG-245), or URL
130    pub issue: String,
131}
132
133/// Tool for reading a single Linear issue.
134#[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// ============================================================================
168// CreateIssue Tool
169// ============================================================================
170
171/// Input for create_issue tool.
172#[derive(Debug, Clone, Deserialize, JsonSchema)]
173pub struct CreateIssueInput {
174    /// Team ID (UUID) to create the issue in
175    pub team_id: String,
176    /// Issue title
177    pub title: String,
178    /// Issue description (markdown supported)
179    #[serde(default)]
180    pub description: Option<String>,
181    /// Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)
182    #[serde(default)]
183    pub priority: Option<i32>,
184    /// Assignee user ID (UUID)
185    #[serde(default)]
186    pub assignee_id: Option<String>,
187    /// Project ID (UUID)
188    #[serde(default)]
189    pub project_id: Option<String>,
190    /// Workflow state ID (UUID)
191    #[serde(default)]
192    pub state_id: Option<String>,
193    /// Parent issue ID (UUID) for sub-issues
194    #[serde(default)]
195    pub parent_id: Option<String>,
196    /// Label IDs (UUIDs)
197    #[serde(default)]
198    pub label_ids: Vec<String>,
199}
200
201/// Tool for creating a new Linear issue.
202#[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// ============================================================================
245// AddComment Tool
246// ============================================================================
247
248/// Input for add_comment tool.
249#[derive(Debug, Clone, Deserialize, JsonSchema)]
250pub struct AddCommentInput {
251    /// Issue ID, identifier (e.g., ENG-245), or URL
252    pub issue: String,
253    /// Comment body (markdown supported)
254    pub body: String,
255    /// Parent comment ID for replies (UUID)
256    #[serde(default)]
257    pub parent_id: Option<String>,
258}
259
260/// Tool for adding a comment to a Linear issue.
261#[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// ============================================================================
294// GetIssueComments Tool
295// ============================================================================
296
297/// Input for get_issue_comments tool.
298#[derive(Debug, Clone, Deserialize, JsonSchema)]
299pub struct GetIssueCommentsInput {
300    /// Issue ID, identifier (e.g., ENG-245), or URL
301    pub issue: String,
302}
303
304/// Tool for fetching comments on a Linear issue with implicit pagination.
305#[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// ============================================================================
338// ArchiveIssue Tool
339// ============================================================================
340
341/// Input for archive_issue tool.
342#[derive(Debug, Clone, Deserialize, JsonSchema)]
343pub struct ArchiveIssueInput {
344    /// Issue ID, identifier (e.g., ENG-245), or URL
345    pub issue: String,
346}
347
348/// Tool for archiving a Linear issue.
349#[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// ============================================================================
383// UpdateIssue Tool
384// ============================================================================
385
386/// Input for updating an existing Linear issue
387#[derive(Debug, Clone, Deserialize, JsonSchema)]
388pub struct UpdateIssueInput {
389    /// Issue identifier (UUID, key like ENG-245, or Linear URL)
390    pub issue: String,
391    /// New title for the issue
392    #[serde(default)]
393    pub title: Option<String>,
394    /// New description (markdown supported)
395    #[serde(default)]
396    pub description: Option<String>,
397    /// Priority: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low
398    #[serde(default)]
399    pub priority: Option<i32>,
400    /// Assignee user ID (UUID)
401    #[serde(default)]
402    pub assignee_id: Option<String>,
403    /// Workflow state ID (UUID)
404    #[serde(default)]
405    pub state_id: Option<String>,
406    /// Project ID (UUID)
407    #[serde(default)]
408    pub project_id: Option<String>,
409    /// Parent issue ID (UUID) for sub-issues
410    #[serde(default)]
411    pub parent_id: Option<String>,
412    /// Replace all labels with these IDs (overrides existing)
413    #[serde(default)]
414    pub label_ids: Option<Vec<String>>,
415    /// Add these label IDs (incremental)
416    #[serde(default)]
417    pub added_label_ids: Option<Vec<String>>,
418    /// Remove these label IDs (incremental)
419    #[serde(default)]
420    pub removed_label_ids: Option<Vec<String>>,
421    /// Due date in ISO 8601 format (YYYY-MM-DD)
422    #[serde(default)]
423    pub due_date: Option<String>,
424}
425
426/// Tool for updating an existing Linear issue.
427#[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// ============================================================================
473// SetRelation Tool
474// ============================================================================
475
476/// Input for setting or removing an issue relation
477#[derive(Debug, Clone, Deserialize, JsonSchema)]
478pub struct SetRelationInput {
479    /// Source issue identifier (UUID, key like ENG-245, or URL)
480    pub issue: String,
481    /// Related issue identifier (UUID, key like ENG-245, or URL)
482    pub related_issue: String,
483    /// Relation type: "blocks", "duplicate", "related". Null/omitted to remove relation.
484    #[serde(default)]
485    pub relation_type: Option<String>,
486}
487
488/// Tool for setting or removing issue relations.
489#[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// ============================================================================
522// GetMetadata Tool
523// ============================================================================
524
525/// Input for get_metadata tool.
526#[derive(Debug, Clone, Deserialize, JsonSchema)]
527pub struct GetMetadataInput {
528    /// Kind of metadata to retrieve
529    pub kind: crate::models::MetadataKind,
530    /// Optional search string (case-insensitive name match)
531    #[serde(default)]
532    pub search: Option<String>,
533    /// Optional team ID to filter by (relevant for workflow_states and labels)
534    #[serde(default)]
535    pub team_id: Option<String>,
536    /// Maximum number of results (default: 50)
537    #[serde(default)]
538    pub first: Option<i32>,
539    /// Pagination cursor for next page
540    #[serde(default)]
541    pub after: Option<String>,
542}
543
544/// Tool for looking up Linear metadata (users, teams, projects, workflow states, labels).
545#[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
583// ============================================================================
584// Registry Builder
585// ============================================================================
586
587/// Build a ToolRegistry containing all linear_tools tools.
588pub 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
602// ============================================================================
603// Error Conversion
604// ============================================================================
605
606/// Map anyhow::Error to agentic_tools_core::ToolError based on error message patterns.
607fn 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}