1use 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#[derive(Debug, Clone, Deserialize, JsonSchema)]
19pub struct SearchIssuesInput {
20 #[serde(default)]
22 pub query: Option<String>,
23 #[serde(default)]
25 pub include_comments: Option<bool>,
26 #[serde(default)]
28 pub priority: Option<i32>,
29 #[serde(default)]
31 pub state_id: Option<String>,
32 #[serde(default)]
34 pub assignee_id: Option<String>,
35 #[serde(default)]
37 pub team_id: Option<String>,
38 #[serde(default)]
40 pub project_id: Option<String>,
41 #[serde(default)]
43 pub created_after: Option<String>,
44 #[serde(default)]
46 pub created_before: Option<String>,
47 #[serde(default)]
49 pub updated_after: Option<String>,
50 #[serde(default)]
52 pub updated_before: Option<String>,
53 #[serde(default)]
55 pub first: Option<i32>,
56 #[serde(default)]
58 pub after: Option<String>,
59}
60
61#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
114pub struct ReadIssueInput {
115 pub issue: String,
117}
118
119#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
159pub struct CreateIssueInput {
160 pub team_id: String,
162 pub title: String,
164 #[serde(default)]
166 pub description: Option<String>,
167 #[serde(default)]
169 pub priority: Option<i32>,
170 #[serde(default)]
172 pub assignee_id: Option<String>,
173 #[serde(default)]
175 pub project_id: Option<String>,
176 #[serde(default)]
178 pub state_id: Option<String>,
179 #[serde(default)]
181 pub parent_id: Option<String>,
182 #[serde(default)]
184 pub label_ids: Vec<String>,
185}
186
187#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
236pub struct AddCommentInput {
237 pub issue: String,
239 pub body: String,
241 #[serde(default)]
243 pub parent_id: Option<String>,
244}
245
246#[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
279pub 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
293fn 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}