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::{CommentResult, CreateIssueResult, IssueDetails, SearchResult};
7use agentic_tools_core::{Tool, ToolContext, ToolError, ToolRegistry};
8use futures::future::BoxFuture;
9use schemars::JsonSchema;
10use serde::Deserialize;
11use std::sync::Arc;
12
13// ============================================================================
14// SearchIssues Tool
15// ============================================================================
16
17/// Input for search_issues tool.
18#[derive(Debug, Clone, Deserialize, JsonSchema)]
19pub struct SearchIssuesInput {
20    /// Full-text search term (searches title, description, and optionally comments)
21    #[serde(default)]
22    pub query: Option<String>,
23    /// Include comments in full-text search (default: true, only applies when query is provided)
24    #[serde(default)]
25    pub include_comments: Option<bool>,
26    /// Filter by priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)
27    #[serde(default)]
28    pub priority: Option<i32>,
29    /// Workflow state ID (UUID)
30    #[serde(default)]
31    pub state_id: Option<String>,
32    /// Assignee user ID (UUID)
33    #[serde(default)]
34    pub assignee_id: Option<String>,
35    /// Team ID (UUID)
36    #[serde(default)]
37    pub team_id: Option<String>,
38    /// Project ID (UUID)
39    #[serde(default)]
40    pub project_id: Option<String>,
41    /// Only issues created after this ISO 8601 date
42    #[serde(default)]
43    pub created_after: Option<String>,
44    /// Only issues created before this ISO 8601 date
45    #[serde(default)]
46    pub created_before: Option<String>,
47    /// Only issues updated after this ISO 8601 date
48    #[serde(default)]
49    pub updated_after: Option<String>,
50    /// Only issues updated before this ISO 8601 date
51    #[serde(default)]
52    pub updated_before: Option<String>,
53    /// Page size (default 50, max 100)
54    #[serde(default)]
55    pub first: Option<i32>,
56    /// Pagination cursor
57    #[serde(default)]
58    pub after: Option<String>,
59}
60
61/// Tool for searching Linear issues.
62#[derive(Clone)]
63pub struct SearchIssuesTool {
64    linear: Arc<LinearTools>,
65}
66
67impl SearchIssuesTool {
68    pub fn new(linear: Arc<LinearTools>) -> Self {
69        Self { linear }
70    }
71}
72
73impl Tool for SearchIssuesTool {
74    type Input = SearchIssuesInput;
75    type Output = SearchResult;
76    const NAME: &'static str = "linear_search_issues";
77    const DESCRIPTION: &'static str = "Search Linear issues using full-text search and/or filters";
78
79    fn call(
80        &self,
81        input: Self::Input,
82        _ctx: &ToolContext,
83    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
84        let linear = self.linear.clone();
85        Box::pin(async move {
86            linear
87                .search_issues(
88                    input.query,
89                    input.include_comments,
90                    input.priority,
91                    input.state_id,
92                    input.assignee_id,
93                    input.team_id,
94                    input.project_id,
95                    input.created_after,
96                    input.created_before,
97                    input.updated_after,
98                    input.updated_before,
99                    input.first,
100                    input.after,
101                )
102                .await
103                .map_err(map_anyhow_to_tool_error)
104        })
105    }
106}
107
108// ============================================================================
109// ReadIssue Tool
110// ============================================================================
111
112/// Input for read_issue tool.
113#[derive(Debug, Clone, Deserialize, JsonSchema)]
114pub struct ReadIssueInput {
115    /// Issue ID, identifier (e.g., ENG-245), or URL
116    pub issue: String,
117}
118
119/// Tool for reading a single Linear issue.
120#[derive(Clone)]
121pub struct ReadIssueTool {
122    linear: Arc<LinearTools>,
123}
124
125impl ReadIssueTool {
126    pub fn new(linear: Arc<LinearTools>) -> Self {
127        Self { linear }
128    }
129}
130
131impl Tool for ReadIssueTool {
132    type Input = ReadIssueInput;
133    type Output = IssueDetails;
134    const NAME: &'static str = "linear_read_issue";
135    const DESCRIPTION: &'static str =
136        "Read a Linear issue by ID, identifier (e.g., ENG-245), or URL";
137
138    fn call(
139        &self,
140        input: Self::Input,
141        _ctx: &ToolContext,
142    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
143        let linear = self.linear.clone();
144        Box::pin(async move {
145            linear
146                .read_issue(input.issue)
147                .await
148                .map_err(map_anyhow_to_tool_error)
149        })
150    }
151}
152
153// ============================================================================
154// CreateIssue Tool
155// ============================================================================
156
157/// Input for create_issue tool.
158#[derive(Debug, Clone, Deserialize, JsonSchema)]
159pub struct CreateIssueInput {
160    /// Team ID (UUID) to create the issue in
161    pub team_id: String,
162    /// Issue title
163    pub title: String,
164    /// Issue description (markdown supported)
165    #[serde(default)]
166    pub description: Option<String>,
167    /// Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)
168    #[serde(default)]
169    pub priority: Option<i32>,
170    /// Assignee user ID (UUID)
171    #[serde(default)]
172    pub assignee_id: Option<String>,
173    /// Project ID (UUID)
174    #[serde(default)]
175    pub project_id: Option<String>,
176    /// Workflow state ID (UUID)
177    #[serde(default)]
178    pub state_id: Option<String>,
179    /// Parent issue ID (UUID) for sub-issues
180    #[serde(default)]
181    pub parent_id: Option<String>,
182    /// Label IDs (UUIDs)
183    #[serde(default)]
184    pub label_ids: Vec<String>,
185}
186
187/// Tool for creating a new Linear issue.
188#[derive(Clone)]
189pub struct CreateIssueTool {
190    linear: Arc<LinearTools>,
191}
192
193impl CreateIssueTool {
194    pub fn new(linear: Arc<LinearTools>) -> Self {
195        Self { linear }
196    }
197}
198
199impl Tool for CreateIssueTool {
200    type Input = CreateIssueInput;
201    type Output = CreateIssueResult;
202    const NAME: &'static str = "linear_create_issue";
203    const DESCRIPTION: &'static str = "Create a new Linear issue in a team";
204
205    fn call(
206        &self,
207        input: Self::Input,
208        _ctx: &ToolContext,
209    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
210        let linear = self.linear.clone();
211        Box::pin(async move {
212            linear
213                .create_issue(
214                    input.team_id,
215                    input.title,
216                    input.description,
217                    input.priority,
218                    input.assignee_id,
219                    input.project_id,
220                    input.state_id,
221                    input.parent_id,
222                    input.label_ids,
223                )
224                .await
225                .map_err(map_anyhow_to_tool_error)
226        })
227    }
228}
229
230// ============================================================================
231// AddComment Tool
232// ============================================================================
233
234/// Input for add_comment tool.
235#[derive(Debug, Clone, Deserialize, JsonSchema)]
236pub struct AddCommentInput {
237    /// Issue ID, identifier (e.g., ENG-245), or URL
238    pub issue: String,
239    /// Comment body (markdown supported)
240    pub body: String,
241    /// Parent comment ID for replies (UUID)
242    #[serde(default)]
243    pub parent_id: Option<String>,
244}
245
246/// Tool for adding a comment to a Linear issue.
247#[derive(Clone)]
248pub struct AddCommentTool {
249    linear: Arc<LinearTools>,
250}
251
252impl AddCommentTool {
253    pub fn new(linear: Arc<LinearTools>) -> Self {
254        Self { linear }
255    }
256}
257
258impl Tool for AddCommentTool {
259    type Input = AddCommentInput;
260    type Output = CommentResult;
261    const NAME: &'static str = "linear_add_comment";
262    const DESCRIPTION: &'static str = "Add a comment to a Linear issue";
263
264    fn call(
265        &self,
266        input: Self::Input,
267        _ctx: &ToolContext,
268    ) -> BoxFuture<'static, Result<Self::Output, ToolError>> {
269        let linear = self.linear.clone();
270        Box::pin(async move {
271            linear
272                .add_comment(input.issue, input.body, input.parent_id)
273                .await
274                .map_err(map_anyhow_to_tool_error)
275        })
276    }
277}
278
279// ============================================================================
280// Registry Builder
281// ============================================================================
282
283/// Build a ToolRegistry containing all linear_tools tools.
284pub fn build_registry(linear: Arc<LinearTools>) -> ToolRegistry {
285    ToolRegistry::builder()
286        .register::<SearchIssuesTool, ()>(SearchIssuesTool::new(linear.clone()))
287        .register::<ReadIssueTool, ()>(ReadIssueTool::new(linear.clone()))
288        .register::<CreateIssueTool, ()>(CreateIssueTool::new(linear.clone()))
289        .register::<AddCommentTool, ()>(AddCommentTool::new(linear))
290        .finish()
291}
292
293// ============================================================================
294// Error Conversion
295// ============================================================================
296
297/// Map anyhow::Error to agentic_tools_core::ToolError based on error message patterns.
298fn map_anyhow_to_tool_error(e: anyhow::Error) -> ToolError {
299    let msg = e.to_string();
300    let lc = msg.to_lowercase();
301    if lc.contains("permission") || lc.contains("401") || lc.contains("403") {
302        ToolError::Permission(msg)
303    } else if lc.contains("not found") || lc.contains("404") {
304        ToolError::NotFound(msg)
305    } else if lc.contains("invalid") || lc.contains("bad request") {
306        ToolError::InvalidInput(msg)
307    } else if lc.contains("timeout") || lc.contains("network") || lc.contains("rate limit") {
308        ToolError::External(msg)
309    } else {
310        ToolError::Internal(msg)
311    }
312}