1use 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#[derive(Debug, Clone, Deserialize, JsonSchema)]
27pub struct SearchIssuesInput {
28 #[serde(default)]
30 pub query: Option<String>,
31 #[serde(default)]
33 pub include_comments: Option<bool>,
34 #[serde(default)]
36 pub priority: Option<i32>,
37 #[serde(default)]
39 pub state_id: Option<String>,
40 #[serde(default)]
42 pub assignee_id: Option<String>,
43 #[serde(default)]
45 pub team_id: Option<String>,
46 #[serde(default)]
48 pub project_id: Option<String>,
49 #[serde(default)]
51 pub created_after: Option<String>,
52 #[serde(default)]
54 pub created_before: Option<String>,
55 #[serde(default)]
57 pub updated_after: Option<String>,
58 #[serde(default)]
60 pub updated_before: Option<String>,
61 #[serde(default)]
63 pub first: Option<i32>,
64 #[serde(default)]
66 pub after: Option<String>,
67}
68
69#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
122pub struct ReadIssueInput {
123 pub issue: String,
125}
126
127#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
167pub struct CreateIssueInput {
168 pub team_id: String,
170 pub title: String,
172 #[serde(default)]
174 pub description: Option<String>,
175 #[serde(default)]
177 pub priority: Option<i32>,
178 #[serde(default)]
180 pub assignee_id: Option<String>,
181 #[serde(default)]
183 pub project_id: Option<String>,
184 #[serde(default)]
186 pub state_id: Option<String>,
187 #[serde(default)]
189 pub parent_id: Option<String>,
190 #[serde(default)]
192 pub label_ids: Vec<String>,
193}
194
195#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
244pub struct AddCommentInput {
245 pub issue: String,
247 pub body: String,
249 #[serde(default)]
251 pub parent_id: Option<String>,
252}
253
254#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
293pub struct ArchiveIssueInput {
294 pub issue: String,
296}
297
298#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
338pub struct GetMetadataInput {
339 pub kind: crate::models::MetadataKind,
341 #[serde(default)]
343 pub search: Option<String>,
344 #[serde(default)]
346 pub team_id: Option<String>,
347 #[serde(default)]
349 pub first: Option<i32>,
350 #[serde(default)]
352 pub after: Option<String>,
353}
354
355#[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
394pub 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
410fn 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}