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// ============================================================================
110// From impls: GraphQL types -> tool model types
111// ============================================================================
112
113impl From<linear_queries::User> for models::UserRef {
114    fn from(u: linear_queries::User) -> Self {
115        let name = if u.display_name.is_empty() {
116            u.name
117        } else {
118            u.display_name
119        };
120        Self {
121            id: u.id.inner().to_string(),
122            name,
123            email: u.email,
124        }
125    }
126}
127
128impl From<linear_queries::Team> for models::TeamRef {
129    fn from(t: linear_queries::Team) -> Self {
130        Self {
131            id: t.id.inner().to_string(),
132            key: t.key,
133            name: t.name,
134        }
135    }
136}
137
138impl From<linear_queries::WorkflowState> for models::WorkflowStateRef {
139    fn from(s: linear_queries::WorkflowState) -> Self {
140        Self {
141            id: s.id.inner().to_string(),
142            name: s.name,
143            state_type: s.state_type,
144        }
145    }
146}
147
148impl From<linear_queries::Project> for models::ProjectRef {
149    fn from(p: linear_queries::Project) -> Self {
150        Self {
151            id: p.id.inner().to_string(),
152            name: p.name,
153        }
154    }
155}
156
157impl From<linear_queries::ParentIssue> for models::ParentIssueRef {
158    fn from(p: linear_queries::ParentIssue) -> Self {
159        Self {
160            id: p.id.inner().to_string(),
161            identifier: p.identifier,
162        }
163    }
164}
165
166impl From<linear_queries::Issue> for models::IssueSummary {
167    fn from(i: linear_queries::Issue) -> Self {
168        Self {
169            id: i.id.inner().to_string(),
170            identifier: i.identifier,
171            title: i.title,
172            url: i.url,
173            team: i.team.into(),
174            state: i.state.map(Into::into),
175            assignee: i.assignee.map(Into::into),
176            creator: i.creator.map(Into::into),
177            project: i.project.map(Into::into),
178            priority: i.priority as i32,
179            priority_label: i.priority_label,
180            label_ids: i.label_ids,
181            due_date: i.due_date.map(|d| d.0),
182            created_at: i.created_at.0,
183            updated_at: i.updated_at.0,
184        }
185    }
186}
187
188impl From<linear_queries::IssueSearchResult> for models::IssueSummary {
189    fn from(i: linear_queries::IssueSearchResult) -> Self {
190        Self {
191            id: i.id.inner().to_string(),
192            identifier: i.identifier,
193            title: i.title,
194            url: i.url,
195            team: i.team.into(),
196            state: Some(i.state.into()),
197            assignee: i.assignee.map(Into::into),
198            creator: i.creator.map(Into::into),
199            project: i.project.map(Into::into),
200            priority: i.priority as i32,
201            priority_label: i.priority_label,
202            label_ids: i.label_ids,
203            due_date: i.due_date.map(|d| d.0),
204            created_at: i.created_at.0,
205            updated_at: i.updated_at.0,
206        }
207    }
208}
209
210// Removed universal-tool-core macros; Tool impls live in tools.rs
211impl LinearTools {
212    /// Search Linear issues with full-text search or filters
213    #[allow(clippy::too_many_arguments)]
214    pub async fn search_issues(
215        &self,
216        query: Option<String>,
217        include_comments: Option<bool>,
218        priority: Option<i32>,
219        state_id: Option<String>,
220        assignee_id: Option<String>,
221        team_id: Option<String>,
222        project_id: Option<String>,
223        created_after: Option<String>,
224        created_before: Option<String>,
225        updated_after: Option<String>,
226        updated_before: Option<String>,
227        first: Option<i32>,
228        after: Option<String>,
229    ) -> Result<models::SearchResult> {
230        let client = LinearClient::new(self.api_key.clone())
231            .context("internal: failed to create Linear client")?;
232
233        // Build filters (no title filter - full-text search handles query)
234        let mut filter = IssueFilter::default();
235        let mut has_filter = false;
236
237        if let Some(p) = priority {
238            filter.priority = Some(NullableNumberComparator {
239                eq: Some(p as f64),
240                ..Default::default()
241            });
242            has_filter = true;
243        }
244        if let Some(id) = state_id {
245            filter.state = Some(WorkflowStateFilter {
246                id: Some(IdComparator {
247                    eq: Some(cynic::Id::new(id)),
248                }),
249                ..Default::default()
250            });
251            has_filter = true;
252        }
253        if let Some(id) = assignee_id {
254            filter.assignee = Some(NullableUserFilter {
255                id: Some(IdComparator {
256                    eq: Some(cynic::Id::new(id)),
257                }),
258            });
259            has_filter = true;
260        }
261        if let Some(id) = team_id {
262            filter.team = Some(TeamFilter {
263                id: Some(IdComparator {
264                    eq: Some(cynic::Id::new(id)),
265                }),
266                ..Default::default()
267            });
268            has_filter = true;
269        }
270        if let Some(id) = project_id {
271            filter.project = Some(NullableProjectFilter {
272                id: Some(IdComparator {
273                    eq: Some(cynic::Id::new(id)),
274                }),
275            });
276            has_filter = true;
277        }
278        if created_after.is_some() || created_before.is_some() {
279            filter.created_at = Some(DateComparator {
280                gte: created_after.map(DateTimeOrDuration),
281                lte: created_before.map(DateTimeOrDuration),
282                ..Default::default()
283            });
284            has_filter = true;
285        }
286        if updated_after.is_some() || updated_before.is_some() {
287            filter.updated_at = Some(DateComparator {
288                gte: updated_after.map(DateTimeOrDuration),
289                lte: updated_before.map(DateTimeOrDuration),
290                ..Default::default()
291            });
292            has_filter = true;
293        }
294
295        let filter_opt = if has_filter { Some(filter) } else { None };
296        let page_size = Some(first.unwrap_or(50).clamp(1, 100));
297        let q_trimmed = query.as_ref().map(|s| s.trim()).unwrap_or("");
298
299        if !q_trimmed.is_empty() {
300            // Full-text search path: searchIssues
301            let op = SearchIssuesQuery::build(SearchIssuesArguments {
302                term: q_trimmed.to_string(),
303                include_comments: Some(include_comments.unwrap_or(true)),
304                first: page_size,
305                after,
306                filter: filter_opt,
307            });
308            let resp = client.run(op).await?;
309            let data = http::extract_data(resp)?;
310
311            let issues = data
312                .search_issues
313                .nodes
314                .into_iter()
315                .map(Into::into)
316                .collect();
317
318            Ok(models::SearchResult {
319                issues,
320                has_next_page: data.search_issues.page_info.has_next_page,
321                end_cursor: data.search_issues.page_info.end_cursor,
322            })
323        } else {
324            // Filters-only path: issues query
325            let op = IssuesQuery::build(IssuesArguments {
326                first: page_size,
327                after,
328                filter: filter_opt,
329            });
330
331            let resp = client.run(op).await?;
332            let data = http::extract_data(resp)?;
333
334            let issues = data.issues.nodes.into_iter().map(Into::into).collect();
335
336            Ok(models::SearchResult {
337                issues,
338                has_next_page: data.issues.page_info.has_next_page,
339                end_cursor: data.issues.page_info.end_cursor,
340            })
341        }
342    }
343
344    /// Read a single Linear issue
345    pub async fn read_issue(&self, issue: String) -> Result<models::IssueDetails> {
346        let client = LinearClient::new(self.api_key.clone())
347            .context("internal: failed to create Linear client")?;
348        let resolved = self.resolve_issue_id(&issue);
349
350        let issue_data = match resolved {
351            IssueIdentifier::Id(id) => {
352                let op = IssueByIdQuery::build(IssueByIdArguments { id });
353                let resp = client.run(op).await?;
354                let data = http::extract_data(resp)?;
355                data.issue
356                    .ok_or_else(|| anyhow::anyhow!("not found: Issue not found"))?
357            }
358            IssueIdentifier::Identifier(ident) => {
359                // Use server-side filtering by team.key + number
360                let (team_key, number) = parse_identifier(&ident)
361                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?;
362                let filter = IssueFilter {
363                    team: Some(TeamFilter {
364                        key: Some(StringComparator {
365                            eq: Some(team_key),
366                            ..Default::default()
367                        }),
368                        ..Default::default()
369                    }),
370                    number: Some(NumberComparator {
371                        eq: Some(number as f64),
372                        ..Default::default()
373                    }),
374                    ..Default::default()
375                };
376                let op = IssuesQuery::build(IssuesArguments {
377                    first: Some(1),
378                    after: None,
379                    filter: Some(filter),
380                });
381                let resp = client.run(op).await?;
382                let data = http::extract_data(resp)?;
383                data.issues
384                    .nodes
385                    .into_iter()
386                    .next()
387                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {} not found", ident))?
388            }
389        };
390
391        let description = issue_data.description.clone();
392        let estimate = issue_data.estimate;
393        let started_at = issue_data.started_at.as_ref().map(|d| d.0.clone());
394        let completed_at = issue_data.completed_at.as_ref().map(|d| d.0.clone());
395        let canceled_at = issue_data.canceled_at.as_ref().map(|d| d.0.clone());
396        let parent = issue_data.parent.as_ref().map(|p| models::ParentIssueRef {
397            id: p.id.inner().to_string(),
398            identifier: p.identifier.clone(),
399        });
400
401        let summary: models::IssueSummary = issue_data.into();
402
403        Ok(models::IssueDetails {
404            issue: summary,
405            description,
406            estimate,
407            parent,
408            started_at,
409            completed_at,
410            canceled_at,
411        })
412    }
413
414    /// Create a new Linear issue
415    #[allow(clippy::too_many_arguments)]
416    pub async fn create_issue(
417        &self,
418        team_id: String,
419        title: String,
420        description: Option<String>,
421        priority: Option<i32>,
422        assignee_id: Option<String>,
423        project_id: Option<String>,
424        state_id: Option<String>,
425        parent_id: Option<String>,
426        label_ids: Vec<String>,
427    ) -> Result<models::CreateIssueResult> {
428        let client = LinearClient::new(self.api_key.clone())
429            .context("internal: failed to create Linear client")?;
430
431        // Convert empty Vec to None for the API
432        let label_ids_opt = if label_ids.is_empty() {
433            None
434        } else {
435            Some(label_ids)
436        };
437
438        let input = IssueCreateInput {
439            team_id,
440            title: Some(title),
441            description,
442            priority,
443            assignee_id,
444            project_id,
445            state_id,
446            parent_id,
447            label_ids: label_ids_opt,
448        };
449
450        let op = IssueCreateMutation::build(IssueCreateArguments { input });
451        let resp = client.run(op).await?;
452        let data = http::extract_data(resp)?;
453
454        let payload = data.issue_create;
455        let issue: Option<models::IssueSummary> = payload.issue.map(Into::into);
456
457        Ok(models::CreateIssueResult {
458            success: payload.success,
459            issue,
460        })
461    }
462
463    /// Add a comment to a Linear issue
464    pub async fn add_comment(
465        &self,
466        issue: String,
467        body: String,
468        parent_id: Option<String>,
469    ) -> Result<models::CommentResult> {
470        let client = LinearClient::new(self.api_key.clone())
471            .context("internal: failed to create Linear client")?;
472        let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
473
474        let input = CommentCreateInput {
475            issue_id,
476            body: Some(body),
477            parent_id,
478        };
479
480        let op = CommentCreateMutation::build(CommentCreateArguments { input });
481        let resp = client.run(op).await?;
482        let data = http::extract_data(resp)?;
483
484        let payload = data.comment_create;
485        let (comment_id, body, created_at) = match payload.comment {
486            Some(c) => (
487                Some(c.id.inner().to_string()),
488                Some(c.body),
489                Some(c.created_at.0),
490            ),
491            None => (None, None, None),
492        };
493
494        Ok(models::CommentResult {
495            success: payload.success,
496            comment_id,
497            body,
498            created_at,
499        })
500    }
501
502    /// Archive a Linear issue
503    pub async fn archive_issue(&self, issue: String) -> Result<models::ArchiveIssueResult> {
504        let client = LinearClient::new(self.api_key.clone())
505            .context("internal: failed to create Linear client")?;
506        let id = self.resolve_to_issue_id(&client, &issue).await?;
507        let op = IssueArchiveMutation::build(IssueArchiveArguments { id });
508        let resp = client.run(op).await?;
509        let data = http::extract_data(resp)?;
510        Ok(models::ArchiveIssueResult {
511            success: data.issue_archive.success,
512        })
513    }
514
515    /// Get metadata (users, teams, projects, workflow states, or labels)
516    pub async fn get_metadata(
517        &self,
518        kind: models::MetadataKind,
519        search: Option<String>,
520        team_id: Option<String>,
521        first: Option<i32>,
522        after: Option<String>,
523    ) -> Result<models::GetMetadataResult> {
524        let client = LinearClient::new(self.api_key.clone())
525            .context("internal: failed to create Linear client")?;
526        let first = first.or(Some(50));
527
528        match kind {
529            models::MetadataKind::Users => {
530                let filter = search.map(|s| linear_queries::UserFilter {
531                    display_name: Some(StringComparator {
532                        contains_ignore_case: Some(s),
533                        ..Default::default()
534                    }),
535                });
536                let op = linear_queries::UsersQuery::build(linear_queries::UsersArguments {
537                    first,
538                    after,
539                    filter,
540                });
541                let resp = client.run(op).await?;
542                let data = http::extract_data(resp)?;
543                let items = data
544                    .users
545                    .nodes
546                    .into_iter()
547                    .map(|u| {
548                        let name = if u.display_name.is_empty() {
549                            u.name
550                        } else {
551                            u.display_name
552                        };
553                        models::MetadataItem {
554                            id: u.id.inner().to_string(),
555                            name,
556                            email: Some(u.email),
557                            key: None,
558                            state_type: None,
559                            team_id: None,
560                        }
561                    })
562                    .collect();
563                Ok(models::GetMetadataResult {
564                    kind: models::MetadataKind::Users,
565                    items,
566                    has_next_page: data.users.page_info.has_next_page,
567                    end_cursor: data.users.page_info.end_cursor,
568                })
569            }
570            models::MetadataKind::Teams => {
571                let filter = search.map(|s| linear_queries::TeamFilter {
572                    key: Some(StringComparator {
573                        contains_ignore_case: Some(s),
574                        ..Default::default()
575                    }),
576                    ..Default::default()
577                });
578                let op = linear_queries::TeamsQuery::build(linear_queries::TeamsArguments {
579                    first,
580                    after,
581                    filter,
582                });
583                let resp = client.run(op).await?;
584                let data = http::extract_data(resp)?;
585                let items = data
586                    .teams
587                    .nodes
588                    .into_iter()
589                    .map(|t| models::MetadataItem {
590                        id: t.id.inner().to_string(),
591                        name: t.name,
592                        key: Some(t.key),
593                        email: None,
594                        state_type: None,
595                        team_id: None,
596                    })
597                    .collect();
598                Ok(models::GetMetadataResult {
599                    kind: models::MetadataKind::Teams,
600                    items,
601                    has_next_page: data.teams.page_info.has_next_page,
602                    end_cursor: data.teams.page_info.end_cursor,
603                })
604            }
605            models::MetadataKind::Projects => {
606                let filter = search.map(|s| linear_queries::ProjectFilter {
607                    name: Some(StringComparator {
608                        contains_ignore_case: Some(s),
609                        ..Default::default()
610                    }),
611                });
612                let op = linear_queries::ProjectsQuery::build(linear_queries::ProjectsArguments {
613                    first,
614                    after,
615                    filter,
616                });
617                let resp = client.run(op).await?;
618                let data = http::extract_data(resp)?;
619                let items = data
620                    .projects
621                    .nodes
622                    .into_iter()
623                    .map(|p| models::MetadataItem {
624                        id: p.id.inner().to_string(),
625                        name: p.name,
626                        key: None,
627                        email: None,
628                        state_type: None,
629                        team_id: None,
630                    })
631                    .collect();
632                Ok(models::GetMetadataResult {
633                    kind: models::MetadataKind::Projects,
634                    items,
635                    has_next_page: data.projects.page_info.has_next_page,
636                    end_cursor: data.projects.page_info.end_cursor,
637                })
638            }
639            models::MetadataKind::WorkflowStates => {
640                let mut filter = linear_queries::WorkflowStateFilter::default();
641                let mut has_filter = false;
642                if let Some(s) = search {
643                    filter.name = Some(StringComparator {
644                        contains_ignore_case: Some(s),
645                        ..Default::default()
646                    });
647                    has_filter = true;
648                }
649                if let Some(tid) = team_id {
650                    filter.team = Some(linear_queries::TeamFilter {
651                        id: Some(linear_queries::IdComparator {
652                            eq: Some(cynic::Id::new(tid)),
653                        }),
654                        ..Default::default()
655                    });
656                    has_filter = true;
657                }
658                let filter_opt = if has_filter { Some(filter) } else { None };
659                let op = linear_queries::WorkflowStatesQuery::build(
660                    linear_queries::WorkflowStatesArguments {
661                        first,
662                        after,
663                        filter: filter_opt,
664                    },
665                );
666                let resp = client.run(op).await?;
667                let data = http::extract_data(resp)?;
668                let items = data
669                    .workflow_states
670                    .nodes
671                    .into_iter()
672                    .map(|s| models::MetadataItem {
673                        id: s.id.inner().to_string(),
674                        name: s.name,
675                        state_type: Some(s.state_type),
676                        key: None,
677                        email: None,
678                        team_id: None,
679                    })
680                    .collect();
681                Ok(models::GetMetadataResult {
682                    kind: models::MetadataKind::WorkflowStates,
683                    items,
684                    has_next_page: data.workflow_states.page_info.has_next_page,
685                    end_cursor: data.workflow_states.page_info.end_cursor,
686                })
687            }
688            models::MetadataKind::Labels => {
689                let mut filter = linear_queries::IssueLabelFilter::default();
690                let mut has_filter = false;
691                if let Some(s) = search {
692                    filter.name = Some(StringComparator {
693                        contains_ignore_case: Some(s),
694                        ..Default::default()
695                    });
696                    has_filter = true;
697                }
698                if let Some(tid) = team_id {
699                    filter.team = Some(linear_queries::NullableTeamFilter {
700                        id: Some(linear_queries::IdComparator {
701                            eq: Some(cynic::Id::new(tid)),
702                        }),
703                        ..Default::default()
704                    });
705                    has_filter = true;
706                }
707                let filter_opt = if has_filter { Some(filter) } else { None };
708                let op =
709                    linear_queries::IssueLabelsQuery::build(linear_queries::IssueLabelsArguments {
710                        first,
711                        after,
712                        filter: filter_opt,
713                    });
714                let resp = client.run(op).await?;
715                let data = http::extract_data(resp)?;
716                let items = data
717                    .issue_labels
718                    .nodes
719                    .into_iter()
720                    .map(|l| models::MetadataItem {
721                        id: l.id.inner().to_string(),
722                        name: l.name,
723                        team_id: l.team.map(|t| t.id.inner().to_string()),
724                        key: None,
725                        email: None,
726                        state_type: None,
727                    })
728                    .collect();
729                Ok(models::GetMetadataResult {
730                    kind: models::MetadataKind::Labels,
731                    items,
732                    has_next_page: data.issue_labels.page_info.has_next_page,
733                    end_cursor: data.issue_labels.page_info.end_cursor,
734                })
735            }
736        }
737    }
738}
739
740// Removed universal-tool-core MCP server; use ToolRegistry in tools.rs
741
742#[cfg(test)]
743mod tests {
744    use super::parse_identifier;
745
746    #[test]
747    fn parse_plain_uppercase() {
748        assert_eq!(parse_identifier("ENG-245"), Some(("ENG".into(), 245)));
749    }
750
751    #[test]
752    fn parse_lowercase_normalizes() {
753        assert_eq!(parse_identifier("eng-245"), Some(("ENG".into(), 245)));
754    }
755
756    #[test]
757    fn parse_from_url() {
758        assert_eq!(
759            parse_identifier("https://linear.app/foo/issue/eng-245/slug"),
760            Some(("ENG".into(), 245))
761        );
762    }
763
764    #[test]
765    fn parse_invalid_returns_none() {
766        assert_eq!(parse_identifier("invalid"), None);
767        assert_eq!(parse_identifier("ENG-"), None);
768        assert_eq!(parse_identifier("ENG"), None);
769        assert_eq!(parse_identifier("123-456"), None);
770    }
771}