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