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::{
7    ArchiveIssueResult, CommentResult, CreateIssueResult, GetMetadataResult, IssueDetails,
8    SearchResult,
9};
10use agentic_tools_core::{Tool, ToolContext, ToolError, ToolRegistry};
11use futures::future::BoxFuture;
12use schemars::JsonSchema;
13use serde::Deserialize;
14use std::sync::Arc;
15
16// ============================================================================
17// SearchIssues Tool
18// ============================================================================
19
20/// Input for search_issues tool.
21#[derive(Debug, Clone, Deserialize, JsonSchema)]
22pub struct SearchIssuesInput {
23    /// Full-text search term (searches title, description, and optionally comments)
24    #[serde(default)]
25    pub query: Option<String>,
26    /// Include comments in full-text search (default: true, only applies when query is provided)
27    #[serde(default)]
28    pub include_comments: Option<bool>,
29    /// Filter by priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)
30    #[serde(default)]
31    pub priority: Option<i32>,
32    /// Workflow state ID (UUID)
33    #[serde(default)]
34    pub state_id: Option<String>,
35    /// Assignee user ID (UUID)
36    #[serde(default)]
37    pub assignee_id: Option<String>,
38    /// Team ID (UUID)
39    #[serde(default)]
40    pub team_id: Option<String>,
41    /// Project ID (UUID)
42    #[serde(default)]
43    pub project_id: Option<String>,
44    /// Only issues created after this ISO 8601 date
45    #[serde(default)]
46    pub created_after: Option<String>,
47    /// Only issues created before this ISO 8601 date
48    #[serde(default)]
49    pub created_before: Option<String>,
50    /// Only issues updated after this ISO 8601 date
51    #[serde(default)]
52    pub updated_after: Option<String>,
53    /// Only issues updated before this ISO 8601 date
54    #[serde(default)]
55    pub updated_before: Option<String>,
56    /// Page size (default 50, max 100)
57    #[serde(default)]
58    pub first: Option<i32>,
59    /// Pagination cursor
60    #[serde(default)]
61    pub after: Option<String>,
62}
63
64/// Tool for searching Linear issues.
65#[derive(Clone)]
66pub struct SearchIssuesTool {
67    linear: Arc<LinearTools>,
68}
69
70impl SearchIssuesTool {
71    pub fn new(linear: Arc<LinearTools>) -> Self {
72        Self { linear }
73    }
74}
75
76impl Tool for SearchIssuesTool {
77    type Input = SearchIssuesInput;
78    type Output = SearchResult;
79    const NAME: &'static str = "linear_search_issues";
80    const DESCRIPTION: &'static str = "Search Linear issues using full-text search and/or filters";
81
82    fn call(
83        &self,
84        input: Self::Input,
85        _ctx: &ToolContext,
86    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
87        let linear = self.linear.clone();
88        Box::pin(async move {
89            linear
90                .search_issues(
91                    input.query,
92                    input.include_comments,
93                    input.priority,
94                    input.state_id,
95                    input.assignee_id,
96                    input.team_id,
97                    input.project_id,
98                    input.created_after,
99                    input.created_before,
100                    input.updated_after,
101                    input.updated_before,
102                    input.first,
103                    input.after,
104                )
105                .await
106                .map_err(map_anyhow_to_tool_error)
107        })
108    }
109}
110
111// ============================================================================
112// ReadIssue Tool
113// ============================================================================
114
115/// Input for read_issue tool.
116#[derive(Debug, Clone, Deserialize, JsonSchema)]
117pub struct ReadIssueInput {
118    /// Issue ID, identifier (e.g., ENG-245), or URL
119    pub issue: String,
120}
121
122/// Tool for reading a single Linear issue.
123#[derive(Clone)]
124pub struct ReadIssueTool {
125    linear: Arc<LinearTools>,
126}
127
128impl ReadIssueTool {
129    pub fn new(linear: Arc<LinearTools>) -> Self {
130        Self { linear }
131    }
132}
133
134impl Tool for ReadIssueTool {
135    type Input = ReadIssueInput;
136    type Output = IssueDetails;
137    const NAME: &'static str = "linear_read_issue";
138    const DESCRIPTION: &'static str =
139        "Read a Linear issue by ID, identifier (e.g., ENG-245), or URL";
140
141    fn call(
142        &self,
143        input: Self::Input,
144        _ctx: &ToolContext,
145    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
146        let linear = self.linear.clone();
147        Box::pin(async move {
148            linear
149                .read_issue(input.issue)
150                .await
151                .map_err(map_anyhow_to_tool_error)
152        })
153    }
154}
155
156// ============================================================================
157// CreateIssue Tool
158// ============================================================================
159
160/// Input for create_issue tool.
161#[derive(Debug, Clone, Deserialize, JsonSchema)]
162pub struct CreateIssueInput {
163    /// Team ID (UUID) to create the issue in
164    pub team_id: String,
165    /// Issue title
166    pub title: String,
167    /// Issue description (markdown supported)
168    #[serde(default)]
169    pub description: Option<String>,
170    /// Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)
171    #[serde(default)]
172    pub priority: Option<i32>,
173    /// Assignee user ID (UUID)
174    #[serde(default)]
175    pub assignee_id: Option<String>,
176    /// Project ID (UUID)
177    #[serde(default)]
178    pub project_id: Option<String>,
179    /// Workflow state ID (UUID)
180    #[serde(default)]
181    pub state_id: Option<String>,
182    /// Parent issue ID (UUID) for sub-issues
183    #[serde(default)]
184    pub parent_id: Option<String>,
185    /// Label IDs (UUIDs)
186    #[serde(default)]
187    pub label_ids: Vec<String>,
188}
189
190/// Tool for creating a new Linear issue.
191#[derive(Clone)]
192pub struct CreateIssueTool {
193    linear: Arc<LinearTools>,
194}
195
196impl CreateIssueTool {
197    pub fn new(linear: Arc<LinearTools>) -> Self {
198        Self { linear }
199    }
200}
201
202impl Tool for CreateIssueTool {
203    type Input = CreateIssueInput;
204    type Output = CreateIssueResult;
205    const NAME: &'static str = "linear_create_issue";
206    const DESCRIPTION: &'static str = "Create a new Linear issue in a team";
207
208    fn call(
209        &self,
210        input: Self::Input,
211        _ctx: &ToolContext,
212    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
213        let linear = self.linear.clone();
214        Box::pin(async move {
215            linear
216                .create_issue(
217                    input.team_id,
218                    input.title,
219                    input.description,
220                    input.priority,
221                    input.assignee_id,
222                    input.project_id,
223                    input.state_id,
224                    input.parent_id,
225                    input.label_ids,
226                )
227                .await
228                .map_err(map_anyhow_to_tool_error)
229        })
230    }
231}
232
233// ============================================================================
234// AddComment Tool
235// ============================================================================
236
237/// Input for add_comment tool.
238#[derive(Debug, Clone, Deserialize, JsonSchema)]
239pub struct AddCommentInput {
240    /// Issue ID, identifier (e.g., ENG-245), or URL
241    pub issue: String,
242    /// Comment body (markdown supported)
243    pub body: String,
244    /// Parent comment ID for replies (UUID)
245    #[serde(default)]
246    pub parent_id: Option<String>,
247}
248
249/// Tool for adding a comment to a Linear issue.
250#[derive(Clone)]
251pub struct AddCommentTool {
252    linear: Arc<LinearTools>,
253}
254
255impl AddCommentTool {
256    pub fn new(linear: Arc<LinearTools>) -> Self {
257        Self { linear }
258    }
259}
260
261impl Tool for AddCommentTool {
262    type Input = AddCommentInput;
263    type Output = CommentResult;
264    const NAME: &'static str = "linear_add_comment";
265    const DESCRIPTION: &'static str = "Add a comment to a Linear issue";
266
267    fn call(
268        &self,
269        input: Self::Input,
270        _ctx: &ToolContext,
271    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
272        let linear = self.linear.clone();
273        Box::pin(async move {
274            linear
275                .add_comment(input.issue, input.body, input.parent_id)
276                .await
277                .map_err(map_anyhow_to_tool_error)
278        })
279    }
280}
281
282// ============================================================================
283// ArchiveIssue Tool
284// ============================================================================
285
286/// Input for archive_issue tool.
287#[derive(Debug, Clone, Deserialize, JsonSchema)]
288pub struct ArchiveIssueInput {
289    /// Issue ID, identifier (e.g., ENG-245), or URL
290    pub issue: String,
291}
292
293/// Tool for archiving a Linear issue.
294#[derive(Clone)]
295pub struct ArchiveIssueTool {
296    linear: Arc<LinearTools>,
297}
298
299impl ArchiveIssueTool {
300    pub fn new(linear: Arc<LinearTools>) -> Self {
301        Self { linear }
302    }
303}
304
305impl Tool for ArchiveIssueTool {
306    type Input = ArchiveIssueInput;
307    type Output = ArchiveIssueResult;
308    const NAME: &'static str = "linear_archive_issue";
309    const DESCRIPTION: &'static str =
310        "Archive a Linear issue by ID, identifier (e.g., ENG-245), or URL";
311
312    fn call(
313        &self,
314        input: Self::Input,
315        _ctx: &ToolContext,
316    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
317        let linear = self.linear.clone();
318        Box::pin(async move {
319            linear
320                .archive_issue(input.issue)
321                .await
322                .map_err(map_anyhow_to_tool_error)
323        })
324    }
325}
326
327// ============================================================================
328// GetMetadata Tool
329// ============================================================================
330
331/// Input for get_metadata tool.
332#[derive(Debug, Clone, Deserialize, JsonSchema)]
333pub struct GetMetadataInput {
334    /// Kind of metadata to retrieve
335    pub kind: crate::models::MetadataKind,
336    /// Optional search string (case-insensitive name match)
337    #[serde(default)]
338    pub search: Option<String>,
339    /// Optional team ID to filter by (relevant for workflow_states and labels)
340    #[serde(default)]
341    pub team_id: Option<String>,
342    /// Maximum number of results (default: 50)
343    #[serde(default)]
344    pub first: Option<i32>,
345    /// Pagination cursor for next page
346    #[serde(default)]
347    pub after: Option<String>,
348}
349
350/// Tool for looking up Linear metadata (users, teams, projects, workflow states, labels).
351#[derive(Clone)]
352pub struct GetMetadataTool {
353    linear: Arc<LinearTools>,
354}
355
356impl GetMetadataTool {
357    pub fn new(linear: Arc<LinearTools>) -> Self {
358        Self { linear }
359    }
360}
361
362impl Tool for GetMetadataTool {
363    type Input = GetMetadataInput;
364    type Output = GetMetadataResult;
365    const NAME: &'static str = "linear_get_metadata";
366    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.";
367
368    fn call(
369        &self,
370        input: Self::Input,
371        _ctx: &ToolContext,
372    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
373        let linear = self.linear.clone();
374        Box::pin(async move {
375            linear
376                .get_metadata(
377                    input.kind,
378                    input.search,
379                    input.team_id,
380                    input.first,
381                    input.after,
382                )
383                .await
384                .map_err(map_anyhow_to_tool_error)
385        })
386    }
387}
388
389// ============================================================================
390// Registry Builder
391// ============================================================================
392
393/// Build a ToolRegistry containing all linear_tools tools.
394pub fn build_registry(linear: Arc<LinearTools>) -> ToolRegistry {
395    ToolRegistry::builder()
396        .register::<SearchIssuesTool, ()>(SearchIssuesTool::new(linear.clone()))
397        .register::<ReadIssueTool, ()>(ReadIssueTool::new(linear.clone()))
398        .register::<CreateIssueTool, ()>(CreateIssueTool::new(linear.clone()))
399        .register::<AddCommentTool, ()>(AddCommentTool::new(linear.clone()))
400        .register::<ArchiveIssueTool, ()>(ArchiveIssueTool::new(linear.clone()))
401        .register::<GetMetadataTool, ()>(GetMetadataTool::new(linear))
402        .finish()
403}
404
405// ============================================================================
406// Error Conversion
407// ============================================================================
408
409/// Map anyhow::Error to agentic_tools_core::ToolError based on error message patterns.
410fn map_anyhow_to_tool_error(e: anyhow::Error) -> ToolError {
411    let msg = e.to_string();
412    let lc = msg.to_lowercase();
413    if lc.contains("permission") || lc.contains("401") || lc.contains("403") {
414        ToolError::Permission(msg)
415    } else if lc.contains("not found") || lc.contains("404") {
416        ToolError::NotFound(msg)
417    } else if lc.contains("invalid") || lc.contains("bad request") {
418        ToolError::InvalidInput(msg)
419    } else if lc.contains("timeout") || lc.contains("network") || lc.contains("rate limit") {
420        ToolError::External(msg)
421    } else {
422        ToolError::Internal(msg)
423    }
424}