1use 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#[derive(Debug, Clone, Deserialize, JsonSchema)]
22pub struct SearchIssuesInput {
23 #[serde(default)]
25 pub query: Option<String>,
26 #[serde(default)]
28 pub include_comments: Option<bool>,
29 #[serde(default)]
31 pub priority: Option<i32>,
32 #[serde(default)]
34 pub state_id: Option<String>,
35 #[serde(default)]
37 pub assignee_id: Option<String>,
38 #[serde(default)]
40 pub team_id: Option<String>,
41 #[serde(default)]
43 pub project_id: Option<String>,
44 #[serde(default)]
46 pub created_after: Option<String>,
47 #[serde(default)]
49 pub created_before: Option<String>,
50 #[serde(default)]
52 pub updated_after: Option<String>,
53 #[serde(default)]
55 pub updated_before: Option<String>,
56 #[serde(default)]
58 pub first: Option<i32>,
59 #[serde(default)]
61 pub after: Option<String>,
62}
63
64#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
117pub struct ReadIssueInput {
118 pub issue: String,
120}
121
122#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
162pub struct CreateIssueInput {
163 pub team_id: String,
165 pub title: String,
167 #[serde(default)]
169 pub description: Option<String>,
170 #[serde(default)]
172 pub priority: Option<i32>,
173 #[serde(default)]
175 pub assignee_id: Option<String>,
176 #[serde(default)]
178 pub project_id: Option<String>,
179 #[serde(default)]
181 pub state_id: Option<String>,
182 #[serde(default)]
184 pub parent_id: Option<String>,
185 #[serde(default)]
187 pub label_ids: Vec<String>,
188}
189
190#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
239pub struct AddCommentInput {
240 pub issue: String,
242 pub body: String,
244 #[serde(default)]
246 pub parent_id: Option<String>,
247}
248
249#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
288pub struct ArchiveIssueInput {
289 pub issue: String,
291}
292
293#[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#[derive(Debug, Clone, Deserialize, JsonSchema)]
333pub struct GetMetadataInput {
334 pub kind: crate::models::MetadataKind,
336 #[serde(default)]
338 pub search: Option<String>,
339 #[serde(default)]
341 pub team_id: Option<String>,
342 #[serde(default)]
344 pub first: Option<i32>,
345 #[serde(default)]
347 pub after: Option<String>,
348}
349
350#[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
389pub 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
405fn 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}