Skip to main content

linear_tools/
lib.rs

1pub mod http;
2pub mod models;
3pub mod tools;
4
5/// Test support utilities (for use in tests)
6#[doc(hidden)]
7pub mod test_support;
8
9use anyhow::{Context, Result};
10use cynic::{MutationBuilder, QueryBuilder};
11use http::LinearClient;
12use linear_queries::scalars::DateTimeOrDuration;
13use linear_queries::*;
14use regex::Regex;
15
16// Re-export agentic-tools types for MCP server usage
17pub use tools::build_registry;
18
19/// Parse identifier "ENG-245" from plain text or URL; normalize to uppercase.
20/// Returns (team_key, number) if a valid identifier is found.
21fn parse_identifier(input: &str) -> Option<(String, i32)> {
22    let upper = input.to_uppercase();
23    let re = Regex::new(r"([A-Z]{2,10})-(\d{1,10})").unwrap();
24    if let Some(caps) = re.captures(&upper) {
25        let key = caps.get(1)?.as_str().to_string();
26        let num_str = caps.get(2)?.as_str();
27        let number: i32 = num_str.parse().ok()?;
28        return Some((key, number));
29    }
30    None
31}
32
33#[derive(Clone)]
34pub struct LinearTools {
35    api_key: Option<String>,
36}
37
38impl LinearTools {
39    pub fn new() -> Self {
40        Self {
41            api_key: std::env::var("LINEAR_API_KEY").ok(),
42        }
43    }
44
45    fn resolve_issue_id(&self, input: &str) -> IssueIdentifier {
46        // Try to parse as identifier (handles lowercase and URLs)
47        if let Some((key, number)) = parse_identifier(input) {
48            return IssueIdentifier::Identifier(format!("{}-{}", key, number));
49        }
50        // Fallback: treat as ID/UUID
51        IssueIdentifier::Id(input.to_string())
52    }
53
54    /// Resolve an issue identifier (UUID, ENG-245, or URL) to a UUID.
55    /// For identifiers, queries Linear to find the matching issue.
56    async fn resolve_to_issue_id(&self, client: &LinearClient, input: &str) -> Result<String> {
57        match self.resolve_issue_id(input) {
58            IssueIdentifier::Id(id) => Ok(id),
59            IssueIdentifier::Identifier(ident) => {
60                let (team_key, number) = parse_identifier(&ident)
61                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?;
62                let filter = IssueFilter {
63                    team: Some(TeamFilter {
64                        key: Some(StringComparator {
65                            eq: Some(team_key),
66                            ..Default::default()
67                        }),
68                        ..Default::default()
69                    }),
70                    number: Some(NumberComparator {
71                        eq: Some(number as f64),
72                        ..Default::default()
73                    }),
74                    ..Default::default()
75                };
76                let op = IssuesQuery::build(IssuesArguments {
77                    first: Some(1),
78                    after: None,
79                    filter: Some(filter),
80                });
81                let resp = client.run(op).await?;
82                let data = http::extract_data(resp)?;
83                let issue = data
84                    .issues
85                    .nodes
86                    .into_iter()
87                    .next()
88                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?;
89                Ok(issue.id.inner().to_string())
90            }
91        }
92    }
93}
94
95impl Default for LinearTools {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101enum IssueIdentifier {
102    Id(String),
103    Identifier(String),
104}
105
106// Note: Error handling moved to tools.rs with map_anyhow_to_tool_error.
107// HTTP error enrichment via summarize_reqwest_error is available for use in tools.rs if needed.
108
109// Removed universal-tool-core macros; Tool impls live in tools.rs
110impl LinearTools {
111    /// Search Linear issues with full-text search or filters
112    #[allow(clippy::too_many_arguments)]
113    pub async fn search_issues(
114        &self,
115        query: Option<String>,
116        include_comments: Option<bool>,
117        priority: Option<i32>,
118        state_id: Option<String>,
119        assignee_id: Option<String>,
120        team_id: Option<String>,
121        project_id: Option<String>,
122        created_after: Option<String>,
123        created_before: Option<String>,
124        updated_after: Option<String>,
125        updated_before: Option<String>,
126        first: Option<i32>,
127        after: Option<String>,
128    ) -> Result<models::SearchResult> {
129        let client = LinearClient::new(self.api_key.clone())
130            .context("internal: failed to create Linear client")?;
131
132        // Build filters (no title filter - full-text search handles query)
133        let mut filter = IssueFilter::default();
134        let mut has_filter = false;
135
136        if let Some(p) = priority {
137            filter.priority = Some(NullableNumberComparator {
138                eq: Some(p as f64),
139                ..Default::default()
140            });
141            has_filter = true;
142        }
143        if let Some(id) = state_id {
144            filter.state = Some(WorkflowStateFilter {
145                id: Some(IdComparator {
146                    eq: Some(cynic::Id::new(id)),
147                }),
148            });
149            has_filter = true;
150        }
151        if let Some(id) = assignee_id {
152            filter.assignee = Some(NullableUserFilter {
153                id: Some(IdComparator {
154                    eq: Some(cynic::Id::new(id)),
155                }),
156            });
157            has_filter = true;
158        }
159        if let Some(id) = team_id {
160            filter.team = Some(TeamFilter {
161                id: Some(IdComparator {
162                    eq: Some(cynic::Id::new(id)),
163                }),
164                ..Default::default()
165            });
166            has_filter = true;
167        }
168        if let Some(id) = project_id {
169            filter.project = Some(NullableProjectFilter {
170                id: Some(IdComparator {
171                    eq: Some(cynic::Id::new(id)),
172                }),
173            });
174            has_filter = true;
175        }
176        if created_after.is_some() || created_before.is_some() {
177            filter.created_at = Some(DateComparator {
178                gte: created_after.map(DateTimeOrDuration),
179                lte: created_before.map(DateTimeOrDuration),
180                ..Default::default()
181            });
182            has_filter = true;
183        }
184        if updated_after.is_some() || updated_before.is_some() {
185            filter.updated_at = Some(DateComparator {
186                gte: updated_after.map(DateTimeOrDuration),
187                lte: updated_before.map(DateTimeOrDuration),
188                ..Default::default()
189            });
190            has_filter = true;
191        }
192
193        let filter_opt = if has_filter { Some(filter) } else { None };
194        let page_size = Some(first.unwrap_or(50).clamp(1, 100));
195        let q_trimmed = query.as_ref().map(|s| s.trim()).unwrap_or("");
196
197        if !q_trimmed.is_empty() {
198            // Full-text search path: searchIssues
199            let op = SearchIssuesQuery::build(SearchIssuesArguments {
200                term: q_trimmed.to_string(),
201                include_comments: Some(include_comments.unwrap_or(true)),
202                first: page_size,
203                after,
204                filter: filter_opt,
205            });
206            let resp = client.run(op).await?;
207            let data = http::extract_data(resp)?;
208
209            let issues = data
210                .search_issues
211                .nodes
212                .into_iter()
213                .map(|i| models::IssueSummary {
214                    id: i.id.inner().to_string(),
215                    identifier: i.identifier,
216                    title: i.title,
217                    state: Some(i.state.name),
218                    assignee: i.assignee.map(|u| {
219                        if u.display_name.is_empty() {
220                            u.name
221                        } else {
222                            u.display_name
223                        }
224                    }),
225                    priority: Some(i.priority as i32),
226                    url: Some(i.url),
227                    team_key: Some(i.team.key),
228                    updated_at: i.updated_at.0,
229                })
230                .collect();
231
232            Ok(models::SearchResult {
233                issues,
234                has_next_page: data.search_issues.page_info.has_next_page,
235                end_cursor: data.search_issues.page_info.end_cursor,
236            })
237        } else {
238            // Filters-only path: issues query
239            let op = IssuesQuery::build(IssuesArguments {
240                first: page_size,
241                after,
242                filter: filter_opt,
243            });
244
245            let resp = client.run(op).await?;
246            let data = http::extract_data(resp)?;
247
248            let issues = data
249                .issues
250                .nodes
251                .into_iter()
252                .map(|i| models::IssueSummary {
253                    id: i.id.inner().to_string(),
254                    identifier: i.identifier,
255                    title: i.title,
256                    state: i.state.map(|s| s.name),
257                    assignee: i.assignee.map(|u| {
258                        if u.display_name.is_empty() {
259                            u.name
260                        } else {
261                            u.display_name
262                        }
263                    }),
264                    priority: Some(i.priority as i32),
265                    url: Some(i.url),
266                    team_key: Some(i.team.key),
267                    updated_at: i.updated_at.0,
268                })
269                .collect();
270
271            Ok(models::SearchResult {
272                issues,
273                has_next_page: data.issues.page_info.has_next_page,
274                end_cursor: data.issues.page_info.end_cursor,
275            })
276        }
277    }
278
279    /// Read a single Linear issue
280    pub async fn read_issue(&self, issue: String) -> Result<models::IssueDetails> {
281        let client = LinearClient::new(self.api_key.clone())
282            .context("internal: failed to create Linear client")?;
283        let resolved = self.resolve_issue_id(&issue);
284
285        let issue_data = match resolved {
286            IssueIdentifier::Id(id) => {
287                let op = IssueByIdQuery::build(IssueByIdArguments { id });
288                let resp = client.run(op).await?;
289                let data = http::extract_data(resp)?;
290                data.issue
291                    .ok_or_else(|| anyhow::anyhow!("not found: Issue not found"))?
292            }
293            IssueIdentifier::Identifier(ident) => {
294                // Use server-side filtering by team.key + number
295                let (team_key, number) = parse_identifier(&ident)
296                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?;
297                let filter = IssueFilter {
298                    team: Some(TeamFilter {
299                        key: Some(StringComparator {
300                            eq: Some(team_key),
301                            ..Default::default()
302                        }),
303                        ..Default::default()
304                    }),
305                    number: Some(NumberComparator {
306                        eq: Some(number as f64),
307                        ..Default::default()
308                    }),
309                    ..Default::default()
310                };
311                let op = IssuesQuery::build(IssuesArguments {
312                    first: Some(1),
313                    after: None,
314                    filter: Some(filter),
315                });
316                let resp = client.run(op).await?;
317                let data = http::extract_data(resp)?;
318                data.issues
319                    .nodes
320                    .into_iter()
321                    .next()
322                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?
323            }
324        };
325
326        let summary = models::IssueSummary {
327            id: issue_data.id.inner().to_string(),
328            identifier: issue_data.identifier.clone(),
329            title: issue_data.title.clone(),
330            state: issue_data.state.map(|s| s.name),
331            assignee: issue_data.assignee.map(|u| {
332                if u.display_name.is_empty() {
333                    u.name
334                } else {
335                    u.display_name
336                }
337            }),
338            priority: Some(issue_data.priority as i32),
339            url: Some(issue_data.url.clone()),
340            team_key: Some(issue_data.team.key),
341            updated_at: issue_data.updated_at.0.clone(),
342        };
343
344        Ok(models::IssueDetails {
345            issue: summary,
346            description: issue_data.description,
347            project: issue_data.project.map(|p| p.name),
348            created_at: issue_data.created_at.0,
349        })
350    }
351
352    /// Create a new Linear issue
353    #[allow(clippy::too_many_arguments)]
354    pub async fn create_issue(
355        &self,
356        team_id: String,
357        title: String,
358        description: Option<String>,
359        priority: Option<i32>,
360        assignee_id: Option<String>,
361        project_id: Option<String>,
362        state_id: Option<String>,
363        parent_id: Option<String>,
364        label_ids: Vec<String>,
365    ) -> Result<models::CreateIssueResult> {
366        let client = LinearClient::new(self.api_key.clone())
367            .context("internal: failed to create Linear client")?;
368
369        // Convert empty Vec to None for the API
370        let label_ids_opt = if label_ids.is_empty() {
371            None
372        } else {
373            Some(label_ids)
374        };
375
376        let input = IssueCreateInput {
377            team_id,
378            title: Some(title),
379            description,
380            priority,
381            assignee_id,
382            project_id,
383            state_id,
384            parent_id,
385            label_ids: label_ids_opt,
386        };
387
388        let op = IssueCreateMutation::build(IssueCreateArguments { input });
389        let resp = client.run(op).await?;
390        let data = http::extract_data(resp)?;
391
392        let payload = data.issue_create;
393        let issue = payload.issue.map(|i| models::IssueSummary {
394            id: i.id.inner().to_string(),
395            identifier: i.identifier,
396            title: i.title,
397            state: i.state.map(|s| s.name),
398            assignee: i.assignee.map(|u| {
399                if u.display_name.is_empty() {
400                    u.name
401                } else {
402                    u.display_name
403                }
404            }),
405            priority: Some(i.priority as i32),
406            url: Some(i.url),
407            team_key: Some(i.team.key),
408            updated_at: i.updated_at.0,
409        });
410
411        Ok(models::CreateIssueResult {
412            success: payload.success,
413            issue,
414        })
415    }
416
417    /// Add a comment to a Linear issue
418    pub async fn add_comment(
419        &self,
420        issue: String,
421        body: String,
422        parent_id: Option<String>,
423    ) -> Result<models::CommentResult> {
424        let client = LinearClient::new(self.api_key.clone())
425            .context("internal: failed to create Linear client")?;
426        let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
427
428        let input = CommentCreateInput {
429            issue_id,
430            body: Some(body),
431            parent_id,
432        };
433
434        let op = CommentCreateMutation::build(CommentCreateArguments { input });
435        let resp = client.run(op).await?;
436        let data = http::extract_data(resp)?;
437
438        let payload = data.comment_create;
439        let (comment_id, body, created_at) = match payload.comment {
440            Some(c) => (
441                Some(c.id.inner().to_string()),
442                Some(c.body),
443                Some(c.created_at.0),
444            ),
445            None => (None, None, None),
446        };
447
448        Ok(models::CommentResult {
449            success: payload.success,
450            comment_id,
451            body,
452            created_at,
453        })
454    }
455}
456
457// Removed universal-tool-core MCP server; use ToolRegistry in tools.rs
458
459#[cfg(test)]
460mod tests {
461    use super::parse_identifier;
462
463    #[test]
464    fn parse_plain_uppercase() {
465        assert_eq!(parse_identifier("ENG-245"), Some(("ENG".into(), 245)));
466    }
467
468    #[test]
469    fn parse_lowercase_normalizes() {
470        assert_eq!(parse_identifier("eng-245"), Some(("ENG".into(), 245)));
471    }
472
473    #[test]
474    fn parse_from_url() {
475        assert_eq!(
476            parse_identifier("https://linear.app/foo/issue/eng-245/slug"),
477            Some(("ENG".into(), 245))
478        );
479    }
480
481    #[test]
482    fn parse_invalid_returns_none() {
483        assert_eq!(parse_identifier("invalid"), None);
484        assert_eq!(parse_identifier("ENG-"), None);
485        assert_eq!(parse_identifier("ENG"), None);
486        assert_eq!(parse_identifier("123-456"), None);
487    }
488}