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