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 agentic_config::types::LinearServiceConfig;
10use agentic_tools_utils::pagination::PaginationCache;
11use agentic_tools_utils::pagination::paginate_slice;
12use anyhow::Context;
13use anyhow::Result;
14use cynic::MutationBuilder;
15use cynic::QueryBuilder;
16use http::LinearClient;
17use linear_queries::CommentCreateArguments;
18use linear_queries::CommentCreateInput;
19use linear_queries::CommentCreateMutation;
20use linear_queries::DateComparator;
21use linear_queries::IdComparator;
22use linear_queries::IssueArchiveArguments;
23use linear_queries::IssueArchiveMutation;
24use linear_queries::IssueByIdArguments;
25use linear_queries::IssueByIdQuery;
26use linear_queries::IssueCommentsArguments;
27use linear_queries::IssueCommentsQuery;
28use linear_queries::IssueCreateArguments;
29use linear_queries::IssueCreateInput;
30use linear_queries::IssueCreateMutation;
31use linear_queries::IssueFilter;
32use linear_queries::IssueRelationCreateArguments;
33use linear_queries::IssueRelationCreateInput;
34use linear_queries::IssueRelationCreateMutation;
35use linear_queries::IssueRelationDeleteArguments;
36use linear_queries::IssueRelationDeleteMutation;
37use linear_queries::IssueRelationType;
38use linear_queries::IssueRelationsArguments;
39use linear_queries::IssueRelationsQuery;
40use linear_queries::IssueUpdateArguments;
41use linear_queries::IssueUpdateInput;
42use linear_queries::IssueUpdateMutation;
43use linear_queries::IssuesArguments;
44use linear_queries::IssuesQuery;
45use linear_queries::NullableNumberComparator;
46use linear_queries::NullableProjectFilter;
47use linear_queries::NullableUserFilter;
48use linear_queries::NumberComparator;
49use linear_queries::SearchIssuesArguments;
50use linear_queries::SearchIssuesQuery;
51use linear_queries::StringComparator;
52use linear_queries::TeamFilter;
53use linear_queries::WorkflowStateFilter;
54use linear_queries::scalars::DateTimeOrDuration;
55use regex::Regex;
56use std::sync::Arc;
57
58// Re-export agentic-tools types for MCP server usage
59pub use tools::build_registry;
60
61/// Parse identifier "ENG-245" from plain text or URL; normalize to uppercase.
62/// Returns (`team_key`, number) if a valid identifier is found.
63fn parse_identifier(input: &str) -> Option<(String, i32)> {
64    let upper = input.to_uppercase();
65    #[expect(clippy::expect_used, reason = "regex literal is valid by construction")]
66    let re = Regex::new(r"([A-Z]{2,10})-(\d{1,10})").expect("valid issue identifier regex");
67    if let Some(caps) = re.captures(&upper) {
68        let key = caps.get(1)?.as_str().to_string();
69        let num_str = caps.get(2)?.as_str();
70        let number: i32 = num_str.parse().ok()?;
71        return Some((key, number));
72    }
73    None
74}
75
76const COMMENTS_PAGE_SIZE: usize = 10;
77const ISSUE_COMMENTS_FETCH_PAGE_SIZE: i32 = 50;
78const ISSUE_COMMENTS_MAX_PAGES: usize = 100;
79
80#[derive(Clone)]
81pub struct LinearTools {
82    api_key: Option<String>,
83    config: LinearServiceConfig,
84    comments_cache: Arc<PaginationCache<models::CommentSummary, String>>,
85}
86
87impl LinearTools {
88    pub fn new() -> Self {
89        Self::with_config(LinearServiceConfig::default())
90    }
91
92    pub fn with_config(config: LinearServiceConfig) -> Self {
93        Self {
94            api_key: std::env::var("LINEAR_API_KEY").ok(),
95            config,
96            comments_cache: Arc::new(PaginationCache::new()),
97        }
98    }
99
100    pub fn config(&self) -> &LinearServiceConfig {
101        &self.config
102    }
103
104    fn client(&self) -> Result<LinearClient> {
105        LinearClient::new(self.api_key.clone(), &self.config)
106            .context("internal: failed to create Linear client")
107    }
108
109    fn resolve_issue_id(input: &str) -> IssueIdentifier {
110        // Try to parse as identifier (handles lowercase and URLs)
111        if let Some((key, number)) = parse_identifier(input) {
112            return IssueIdentifier::Identifier(format!("{key}-{number}"));
113        }
114        // Fallback: treat as ID/UUID
115        IssueIdentifier::Id(input.to_string())
116    }
117
118    /// Resolve an issue identifier (UUID, ENG-245, or URL) to a UUID.
119    /// For identifiers, queries Linear to find the matching issue.
120    async fn resolve_to_issue_id(&self, client: &LinearClient, input: &str) -> Result<String> {
121        match Self::resolve_issue_id(input) {
122            IssueIdentifier::Id(id) => Ok(id),
123            IssueIdentifier::Identifier(ident) => {
124                let (team_key, number) = parse_identifier(&ident)
125                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {ident} not found"))?;
126                let filter = IssueFilter {
127                    team: Some(TeamFilter {
128                        key: Some(StringComparator {
129                            eq: Some(team_key),
130                            ..Default::default()
131                        }),
132                        ..Default::default()
133                    }),
134                    number: Some(NumberComparator {
135                        eq: Some(f64::from(number)),
136                        ..Default::default()
137                    }),
138                    ..Default::default()
139                };
140                let op = IssuesQuery::build(IssuesArguments {
141                    first: Some(1),
142                    after: None,
143                    filter: Some(filter),
144                });
145                let resp = client.run(op).await?;
146                let data = http::extract_data(resp)?;
147                let issue = data
148                    .issues
149                    .nodes
150                    .into_iter()
151                    .next()
152                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {ident} not found"))?;
153                Ok(issue.id.inner().to_string())
154            }
155        }
156    }
157}
158
159impl Default for LinearTools {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165enum IssueIdentifier {
166    Id(String),
167    Identifier(String),
168}
169
170// Note: Error handling moved to tools.rs with map_anyhow_to_tool_error.
171// HTTP error enrichment via summarize_reqwest_error is available for use in tools.rs if needed.
172
173// ============================================================================
174// From impls: GraphQL types -> tool model types
175// ============================================================================
176
177impl From<linear_queries::User> for models::UserRef {
178    fn from(u: linear_queries::User) -> Self {
179        let name = if u.display_name.is_empty() {
180            u.name
181        } else {
182            u.display_name
183        };
184        Self {
185            id: u.id.inner().to_string(),
186            name,
187            email: u.email,
188        }
189    }
190}
191
192impl From<linear_queries::Team> for models::TeamRef {
193    fn from(t: linear_queries::Team) -> Self {
194        Self {
195            id: t.id.inner().to_string(),
196            key: t.key,
197            name: t.name,
198        }
199    }
200}
201
202impl From<linear_queries::WorkflowState> for models::WorkflowStateRef {
203    fn from(s: linear_queries::WorkflowState) -> Self {
204        Self {
205            id: s.id.inner().to_string(),
206            name: s.name,
207            state_type: s.state_type,
208        }
209    }
210}
211
212impl From<linear_queries::Project> for models::ProjectRef {
213    fn from(p: linear_queries::Project) -> Self {
214        Self {
215            id: p.id.inner().to_string(),
216            name: p.name,
217        }
218    }
219}
220
221impl From<linear_queries::ParentIssue> for models::ParentIssueRef {
222    fn from(p: linear_queries::ParentIssue) -> Self {
223        Self {
224            id: p.id.inner().to_string(),
225            identifier: p.identifier,
226        }
227    }
228}
229
230impl From<linear_queries::Issue> for models::IssueSummary {
231    fn from(i: linear_queries::Issue) -> Self {
232        Self {
233            id: i.id.inner().to_string(),
234            identifier: i.identifier,
235            title: i.title,
236            url: i.url,
237            team: i.team.into(),
238            state: i.state.map(Into::into),
239            assignee: i.assignee.map(Into::into),
240            creator: i.creator.map(Into::into),
241            project: i.project.map(Into::into),
242            priority: i.priority as i32,
243            priority_label: i.priority_label,
244            label_ids: i.label_ids,
245            due_date: i.due_date.map(|d| d.0),
246            created_at: i.created_at.0,
247            updated_at: i.updated_at.0,
248        }
249    }
250}
251
252impl From<linear_queries::IssueSearchResult> for models::IssueSummary {
253    fn from(i: linear_queries::IssueSearchResult) -> Self {
254        Self {
255            id: i.id.inner().to_string(),
256            identifier: i.identifier,
257            title: i.title,
258            url: i.url,
259            team: i.team.into(),
260            state: Some(i.state.into()),
261            assignee: i.assignee.map(Into::into),
262            creator: i.creator.map(Into::into),
263            project: i.project.map(Into::into),
264            priority: i.priority as i32,
265            priority_label: i.priority_label,
266            label_ids: i.label_ids,
267            due_date: i.due_date.map(|d| d.0),
268            created_at: i.created_at.0,
269            updated_at: i.updated_at.0,
270        }
271    }
272}
273
274// Removed universal-tool-core macros; Tool impls live in tools.rs
275impl LinearTools {
276    /// Search Linear issues with full-text search or filters
277    #[expect(clippy::too_many_arguments)]
278    pub async fn search_issues(
279        &self,
280        query: Option<String>,
281        include_comments: Option<bool>,
282        priority: Option<i32>,
283        state_id: Option<String>,
284        assignee_id: Option<String>,
285        creator_id: Option<String>,
286        team_id: Option<String>,
287        project_id: Option<String>,
288        created_after: Option<String>,
289        created_before: Option<String>,
290        updated_after: Option<String>,
291        updated_before: Option<String>,
292        first: Option<i32>,
293        after: Option<String>,
294    ) -> Result<models::SearchResult> {
295        let client = self.client()?;
296
297        // Build filters (no title filter - full-text search handles query)
298        let mut filter = IssueFilter::default();
299        if let Some(p) = priority {
300            filter.priority = Some(NullableNumberComparator {
301                eq: Some(f64::from(p)),
302                ..Default::default()
303            });
304        }
305        if let Some(id) = state_id {
306            filter.state = Some(WorkflowStateFilter {
307                id: Some(IdComparator {
308                    eq: Some(cynic::Id::new(id)),
309                }),
310                ..Default::default()
311            });
312        }
313        if let Some(id) = assignee_id {
314            filter.assignee = Some(NullableUserFilter {
315                id: Some(IdComparator {
316                    eq: Some(cynic::Id::new(id)),
317                }),
318            });
319        }
320        if let Some(id) = creator_id {
321            filter.creator = Some(NullableUserFilter {
322                id: Some(IdComparator {
323                    eq: Some(cynic::Id::new(id)),
324                }),
325            });
326        }
327        if let Some(id) = team_id {
328            filter.team = Some(TeamFilter {
329                id: Some(IdComparator {
330                    eq: Some(cynic::Id::new(id)),
331                }),
332                ..Default::default()
333            });
334        }
335        if let Some(id) = project_id {
336            filter.project = Some(NullableProjectFilter {
337                id: Some(IdComparator {
338                    eq: Some(cynic::Id::new(id)),
339                }),
340            });
341        }
342        if created_after.is_some() || created_before.is_some() {
343            filter.created_at = Some(DateComparator {
344                gte: created_after.map(DateTimeOrDuration),
345                lte: created_before.map(DateTimeOrDuration),
346                ..Default::default()
347            });
348        }
349        if updated_after.is_some() || updated_before.is_some() {
350            filter.updated_at = Some(DateComparator {
351                gte: updated_after.map(DateTimeOrDuration),
352                lte: updated_before.map(DateTimeOrDuration),
353                ..Default::default()
354            });
355        }
356
357        let filter_opt = (filter.priority.is_some()
358            || filter.state.is_some()
359            || filter.assignee.is_some()
360            || filter.creator.is_some()
361            || filter.team.is_some()
362            || filter.project.is_some()
363            || filter.created_at.is_some()
364            || filter.updated_at.is_some())
365        .then_some(filter);
366        let page_size = Some(first.unwrap_or(50).clamp(1, 100));
367        let q_trimmed = query.as_ref().map_or("", |s| s.trim());
368
369        if q_trimmed.is_empty() {
370            // Filters-only path: issues query
371            let op = IssuesQuery::build(IssuesArguments {
372                first: page_size,
373                after,
374                filter: filter_opt,
375            });
376
377            let resp = client.run(op).await?;
378            let data = http::extract_data(resp)?;
379
380            let issues = data.issues.nodes.into_iter().map(Into::into).collect();
381
382            Ok(models::SearchResult {
383                issues,
384                has_next_page: data.issues.page_info.has_next_page,
385                end_cursor: data.issues.page_info.end_cursor,
386            })
387        } else {
388            // Full-text search path: searchIssues
389            let op = SearchIssuesQuery::build(SearchIssuesArguments {
390                term: q_trimmed.to_string(),
391                include_comments: Some(include_comments.unwrap_or(true)),
392                first: page_size,
393                after,
394                filter: filter_opt,
395            });
396            let resp = client.run(op).await?;
397            let data = http::extract_data(resp)?;
398
399            let issues = data
400                .search_issues
401                .nodes
402                .into_iter()
403                .map(Into::into)
404                .collect();
405
406            Ok(models::SearchResult {
407                issues,
408                has_next_page: data.search_issues.page_info.has_next_page,
409                end_cursor: data.search_issues.page_info.end_cursor,
410            })
411        }
412    }
413
414    /// Read a single Linear issue
415    pub async fn read_issue(&self, issue: String) -> Result<models::IssueDetails> {
416        let client = self.client()?;
417        let resolved = Self::resolve_issue_id(&issue);
418
419        let issue_data = match resolved {
420            IssueIdentifier::Id(id) => {
421                let op = IssueByIdQuery::build(IssueByIdArguments { id });
422                let resp = client.run(op).await?;
423                let data = http::extract_data(resp)?;
424                data.issue
425                    .ok_or_else(|| anyhow::anyhow!("not found: Issue not found"))?
426            }
427            IssueIdentifier::Identifier(ident) => {
428                // Use server-side filtering by team.key + number
429                let (team_key, number) = parse_identifier(&ident)
430                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {ident} not found"))?;
431                let filter = IssueFilter {
432                    team: Some(TeamFilter {
433                        key: Some(StringComparator {
434                            eq: Some(team_key),
435                            ..Default::default()
436                        }),
437                        ..Default::default()
438                    }),
439                    number: Some(NumberComparator {
440                        eq: Some(f64::from(number)),
441                        ..Default::default()
442                    }),
443                    ..Default::default()
444                };
445                let op = IssuesQuery::build(IssuesArguments {
446                    first: Some(1),
447                    after: None,
448                    filter: Some(filter),
449                });
450                let resp = client.run(op).await?;
451                let data = http::extract_data(resp)?;
452                data.issues
453                    .nodes
454                    .into_iter()
455                    .next()
456                    .ok_or_else(|| anyhow::anyhow!("not found: Issue {ident} not found"))?
457            }
458        };
459
460        let description = issue_data.description.clone();
461        let estimate = issue_data.estimate;
462        let started_at = issue_data.started_at.as_ref().map(|d| d.0.clone());
463        let completed_at = issue_data.completed_at.as_ref().map(|d| d.0.clone());
464        let canceled_at = issue_data.canceled_at.as_ref().map(|d| d.0.clone());
465        let parent = issue_data.parent.as_ref().map(|p| models::ParentIssueRef {
466            id: p.id.inner().to_string(),
467            identifier: p.identifier.clone(),
468        });
469
470        let summary: models::IssueSummary = issue_data.into();
471
472        Ok(models::IssueDetails {
473            issue: summary,
474            description,
475            estimate,
476            parent,
477            started_at,
478            completed_at,
479            canceled_at,
480        })
481    }
482
483    /// Create a new Linear issue
484    #[expect(clippy::too_many_arguments)]
485    pub async fn create_issue(
486        &self,
487        team_id: String,
488        title: String,
489        description: Option<String>,
490        priority: Option<i32>,
491        assignee_id: Option<String>,
492        project_id: Option<String>,
493        state_id: Option<String>,
494        parent_id: Option<String>,
495        label_ids: Vec<String>,
496    ) -> Result<models::CreateIssueResult> {
497        let client = self.client()?;
498
499        // Convert empty Vec to None for the API
500        let label_ids_opt = if label_ids.is_empty() {
501            None
502        } else {
503            Some(label_ids)
504        };
505
506        let input = IssueCreateInput {
507            team_id,
508            title: Some(title),
509            description,
510            priority,
511            assignee_id,
512            project_id,
513            state_id,
514            parent_id,
515            label_ids: label_ids_opt,
516        };
517
518        let op = IssueCreateMutation::build(IssueCreateArguments { input });
519        let resp = client.run(op).await?;
520        let data = http::extract_data(resp)?;
521
522        let payload = data.issue_create;
523        let issue: Option<models::IssueSummary> = payload.issue.map(Into::into);
524
525        Ok(models::CreateIssueResult {
526            success: payload.success,
527            issue,
528        })
529    }
530
531    /// Update an existing Linear issue
532    #[expect(clippy::too_many_arguments)]
533    pub async fn update_issue(
534        &self,
535        issue: String,
536        title: Option<String>,
537        description: Option<String>,
538        priority: Option<i32>,
539        assignee_id: Option<String>,
540        state_id: Option<String>,
541        project_id: Option<String>,
542        parent_id: Option<String>,
543        label_ids: Option<Vec<String>>,
544        added_label_ids: Option<Vec<String>>,
545        removed_label_ids: Option<Vec<String>>,
546        due_date: Option<String>,
547    ) -> Result<models::IssueResult> {
548        let client = self.client()?;
549        let id = self.resolve_to_issue_id(&client, &issue).await?;
550
551        let input = IssueUpdateInput {
552            title,
553            description,
554            priority,
555            assignee_id,
556            state_id,
557            project_id,
558            parent_id,
559            label_ids,
560            added_label_ids,
561            removed_label_ids,
562            due_date: due_date.map(linear_queries::scalars::TimelessDate),
563        };
564
565        let op = IssueUpdateMutation::build(IssueUpdateArguments { id, input });
566        let resp = client.run(op).await?;
567        let data = http::extract_data(resp)?;
568
569        let payload = data.issue_update;
570        if !payload.success {
571            anyhow::bail!("Update failed: Linear returned success=false");
572        }
573        let issue = payload
574            .issue
575            .ok_or_else(|| anyhow::anyhow!("No issue returned from update"))?;
576
577        Ok(models::IssueResult {
578            issue: issue.into(),
579        })
580    }
581
582    /// Add a comment to a Linear issue
583    pub async fn add_comment(
584        &self,
585        issue: String,
586        body: String,
587        parent_id: Option<String>,
588    ) -> Result<models::CommentResult> {
589        let client = self.client()?;
590        let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
591
592        let input = CommentCreateInput {
593            issue_id,
594            body: Some(body),
595            parent_id,
596        };
597
598        let op = CommentCreateMutation::build(CommentCreateArguments { input });
599        let resp = client.run(op).await?;
600        let data = http::extract_data(resp)?;
601
602        let payload = data.comment_create;
603        let (comment_id, body, created_at) = match payload.comment {
604            Some(c) => (
605                Some(c.id.inner().to_string()),
606                Some(c.body),
607                Some(c.created_at.0),
608            ),
609            None => (None, None, None),
610        };
611
612        Ok(models::CommentResult {
613            success: payload.success,
614            comment_id,
615            body,
616            created_at,
617        })
618    }
619
620    /// Archive a Linear issue
621    pub async fn archive_issue(&self, issue: String) -> Result<models::ArchiveIssueResult> {
622        let client = self.client()?;
623        let id = self.resolve_to_issue_id(&client, &issue).await?;
624        let op = IssueArchiveMutation::build(IssueArchiveArguments { id });
625        let resp = client.run(op).await?;
626        let data = http::extract_data(resp)?;
627        Ok(models::ArchiveIssueResult {
628            success: data.issue_archive.success,
629        })
630    }
631
632    /// Get metadata (users, teams, projects, workflow states, or labels)
633    pub async fn get_metadata(
634        &self,
635        kind: models::MetadataKind,
636        search: Option<String>,
637        team_id: Option<String>,
638        first: Option<i32>,
639        after: Option<String>,
640    ) -> Result<models::GetMetadataResult> {
641        let client = self.client()?;
642        let first = first.or(Some(50));
643
644        match kind {
645            models::MetadataKind::Users => {
646                let filter = search.map(|s| linear_queries::UserFilter {
647                    display_name: Some(StringComparator {
648                        contains_ignore_case: Some(s),
649                        ..Default::default()
650                    }),
651                });
652                let op = linear_queries::UsersQuery::build(linear_queries::UsersArguments {
653                    first,
654                    after,
655                    filter,
656                });
657                let resp = client.run(op).await?;
658                let data = http::extract_data(resp)?;
659                let items = data
660                    .users
661                    .nodes
662                    .into_iter()
663                    .map(|u| {
664                        let name = if u.display_name.is_empty() {
665                            u.name
666                        } else {
667                            u.display_name
668                        };
669                        models::MetadataItem {
670                            id: u.id.inner().to_string(),
671                            name,
672                            email: Some(u.email),
673                            key: None,
674                            state_type: None,
675                            team_id: None,
676                        }
677                    })
678                    .collect();
679                Ok(models::GetMetadataResult {
680                    kind: models::MetadataKind::Users,
681                    items,
682                    has_next_page: data.users.page_info.has_next_page,
683                    end_cursor: data.users.page_info.end_cursor,
684                })
685            }
686            models::MetadataKind::Teams => {
687                let filter = search.map(|s| linear_queries::TeamFilter {
688                    key: Some(StringComparator {
689                        contains_ignore_case: Some(s),
690                        ..Default::default()
691                    }),
692                    ..Default::default()
693                });
694                let op = linear_queries::TeamsQuery::build(linear_queries::TeamsArguments {
695                    first,
696                    after,
697                    filter,
698                });
699                let resp = client.run(op).await?;
700                let data = http::extract_data(resp)?;
701                let items = data
702                    .teams
703                    .nodes
704                    .into_iter()
705                    .map(|t| models::MetadataItem {
706                        id: t.id.inner().to_string(),
707                        name: t.name,
708                        key: Some(t.key),
709                        email: None,
710                        state_type: None,
711                        team_id: None,
712                    })
713                    .collect();
714                Ok(models::GetMetadataResult {
715                    kind: models::MetadataKind::Teams,
716                    items,
717                    has_next_page: data.teams.page_info.has_next_page,
718                    end_cursor: data.teams.page_info.end_cursor,
719                })
720            }
721            models::MetadataKind::Projects => {
722                let filter = search.map(|s| linear_queries::ProjectFilter {
723                    name: Some(StringComparator {
724                        contains_ignore_case: Some(s),
725                        ..Default::default()
726                    }),
727                });
728                let op = linear_queries::ProjectsQuery::build(linear_queries::ProjectsArguments {
729                    first,
730                    after,
731                    filter,
732                });
733                let resp = client.run(op).await?;
734                let data = http::extract_data(resp)?;
735                let items = data
736                    .projects
737                    .nodes
738                    .into_iter()
739                    .map(|p| models::MetadataItem {
740                        id: p.id.inner().to_string(),
741                        name: p.name,
742                        key: None,
743                        email: None,
744                        state_type: None,
745                        team_id: None,
746                    })
747                    .collect();
748                Ok(models::GetMetadataResult {
749                    kind: models::MetadataKind::Projects,
750                    items,
751                    has_next_page: data.projects.page_info.has_next_page,
752                    end_cursor: data.projects.page_info.end_cursor,
753                })
754            }
755            models::MetadataKind::WorkflowStates => {
756                let filter_opt = if let Some(s) = search {
757                    let mut filter = linear_queries::WorkflowStateFilter {
758                        name: Some(StringComparator {
759                            contains_ignore_case: Some(s),
760                            ..Default::default()
761                        }),
762                        ..Default::default()
763                    };
764                    if let Some(tid) = team_id {
765                        filter.team = Some(linear_queries::TeamFilter {
766                            id: Some(linear_queries::IdComparator {
767                                eq: Some(cynic::Id::new(tid)),
768                            }),
769                            ..Default::default()
770                        });
771                    }
772                    Some(filter)
773                } else {
774                    team_id.map(|tid| linear_queries::WorkflowStateFilter {
775                        team: Some(linear_queries::TeamFilter {
776                            id: Some(linear_queries::IdComparator {
777                                eq: Some(cynic::Id::new(tid)),
778                            }),
779                            ..Default::default()
780                        }),
781                        ..Default::default()
782                    })
783                };
784                let op = linear_queries::WorkflowStatesQuery::build(
785                    linear_queries::WorkflowStatesArguments {
786                        first,
787                        after,
788                        filter: filter_opt,
789                    },
790                );
791                let resp = client.run(op).await?;
792                let data = http::extract_data(resp)?;
793                let items = data
794                    .workflow_states
795                    .nodes
796                    .into_iter()
797                    .map(|s| models::MetadataItem {
798                        id: s.id.inner().to_string(),
799                        name: s.name,
800                        state_type: Some(s.state_type),
801                        key: None,
802                        email: None,
803                        team_id: None,
804                    })
805                    .collect();
806                Ok(models::GetMetadataResult {
807                    kind: models::MetadataKind::WorkflowStates,
808                    items,
809                    has_next_page: data.workflow_states.page_info.has_next_page,
810                    end_cursor: data.workflow_states.page_info.end_cursor,
811                })
812            }
813            models::MetadataKind::Labels => {
814                let filter_opt = if let Some(s) = search {
815                    let mut filter = linear_queries::IssueLabelFilter {
816                        name: Some(StringComparator {
817                            contains_ignore_case: Some(s),
818                            ..Default::default()
819                        }),
820                        ..Default::default()
821                    };
822                    if let Some(tid) = team_id {
823                        filter.team = Some(linear_queries::NullableTeamFilter {
824                            id: Some(linear_queries::IdComparator {
825                                eq: Some(cynic::Id::new(tid)),
826                            }),
827                            ..Default::default()
828                        });
829                    }
830                    Some(filter)
831                } else {
832                    team_id.map(|tid| linear_queries::IssueLabelFilter {
833                        team: Some(linear_queries::NullableTeamFilter {
834                            id: Some(linear_queries::IdComparator {
835                                eq: Some(cynic::Id::new(tid)),
836                            }),
837                            ..Default::default()
838                        }),
839                        ..Default::default()
840                    })
841                };
842                let op =
843                    linear_queries::IssueLabelsQuery::build(linear_queries::IssueLabelsArguments {
844                        first,
845                        after,
846                        filter: filter_opt,
847                    });
848                let resp = client.run(op).await?;
849                let data = http::extract_data(resp)?;
850                let items = data
851                    .issue_labels
852                    .nodes
853                    .into_iter()
854                    .map(|l| models::MetadataItem {
855                        id: l.id.inner().to_string(),
856                        name: l.name,
857                        team_id: l.team.map(|t| t.id.inner().to_string()),
858                        key: None,
859                        email: None,
860                        state_type: None,
861                    })
862                    .collect();
863                Ok(models::GetMetadataResult {
864                    kind: models::MetadataKind::Labels,
865                    items,
866                    has_next_page: data.issue_labels.page_info.has_next_page,
867                    end_cursor: data.issue_labels.page_info.end_cursor,
868                })
869            }
870        }
871    }
872
873    /// Set or remove a relation between two issues
874    pub async fn set_relation(
875        &self,
876        issue: String,
877        related_issue: String,
878        relation_type: Option<String>,
879    ) -> Result<models::SetRelationResult> {
880        let client = self.client()?;
881        let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
882        let related_issue_id = self.resolve_to_issue_id(&client, &related_issue).await?;
883
884        if let Some(rel_type) = relation_type {
885            // Create relation
886            let relation_type = match rel_type.to_lowercase().as_str() {
887                "blocks" => IssueRelationType::Blocks,
888                "duplicate" => IssueRelationType::Duplicate,
889                "related" => IssueRelationType::Related,
890                other => anyhow::bail!(
891                    "Invalid relation type: {other}. Must be one of: blocks, duplicate, related"
892                ),
893            };
894
895            let input = IssueRelationCreateInput {
896                issue_id,
897                related_issue_id,
898                relation_type,
899            };
900
901            let op = IssueRelationCreateMutation::build(IssueRelationCreateArguments { input });
902            let resp = client.run(op).await?;
903            let data = http::extract_data(resp)?;
904
905            Ok(models::SetRelationResult {
906                success: data.issue_relation_create.success,
907                action: "created".to_string(),
908            })
909        } else {
910            // Remove relation - need to find it first
911            let op = IssueRelationsQuery::build(IssueRelationsArguments { id: issue_id });
912            let resp = client.run(op).await?;
913            let data = http::extract_data(resp)?;
914
915            let issue_with_relations = data
916                .issue
917                .ok_or_else(|| anyhow::anyhow!("not found: Issue not found"))?;
918
919            // Search in both relations and inverse_relations
920            let relation_id = issue_with_relations
921                .relations
922                .nodes
923                .iter()
924                .find(|r| r.related_issue.id.inner() == related_issue_id)
925                .map(|r| r.id.inner().to_string())
926                .or_else(|| {
927                    issue_with_relations
928                        .inverse_relations
929                        .nodes
930                        .iter()
931                        .find(|r| r.related_issue.id.inner() == related_issue_id)
932                        .map(|r| r.id.inner().to_string())
933                });
934
935            match relation_id {
936                Some(id) => {
937                    let op =
938                        IssueRelationDeleteMutation::build(IssueRelationDeleteArguments { id });
939                    let resp = client.run(op).await?;
940                    let data = http::extract_data(resp)?;
941
942                    Ok(models::SetRelationResult {
943                        success: data.issue_relation_delete.success,
944                        action: "removed".to_string(),
945                    })
946                }
947                None => {
948                    // No relation found - idempotent success
949                    Ok(models::SetRelationResult {
950                        success: true,
951                        action: "no_change".to_string(),
952                    })
953                }
954            }
955        }
956    }
957
958    /// Get comments on a Linear issue with implicit pagination
959    pub async fn get_issue_comments(&self, issue: String) -> Result<models::CommentsResult> {
960        let client = self.client()?;
961
962        // Resolve issue identifier to UUID
963        let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
964
965        // Cache key includes page size for correctness
966        let cache_key = format!("{issue_id}|{COMMENTS_PAGE_SIZE}");
967
968        // Sweep expired entries
969        self.comments_cache.sweep_expired();
970
971        // Get or create cache entry
972        let query_lock = self.comments_cache.get_or_create(&cache_key);
973
974        // Check if we need to fetch
975        let needs_fetch = {
976            let state = query_lock.lock_state();
977            state.is_empty() || state.is_expired()
978        };
979
980        // Store the issue identifier for display
981        let issue_identifier: String;
982
983        if needs_fetch {
984            // Fetch all comments from Linear API
985            let (identifier, all_comments) = Self::fetch_all_comments(&client, &issue_id).await?;
986            issue_identifier = identifier.clone();
987
988            // Reset cache with fresh data (stores canonical identifier)
989            let mut state = query_lock.lock_state();
990            if state.is_empty() || state.is_expired() {
991                state.reset(all_comments, identifier, COMMENTS_PAGE_SIZE);
992            }
993        } else {
994            // Get canonical identifier from cache
995            let state = query_lock.lock_state();
996            issue_identifier = state.meta.clone();
997        }
998
999        // Paginate from cache
1000        let (page_comments, total, shown, has_more) = {
1001            let mut state = query_lock.lock_state();
1002            let (page, has_more) =
1003                paginate_slice(&state.results, state.next_offset, state.page_size);
1004            let total = state.results.len();
1005            state.next_offset += page.len();
1006            let shown = state.next_offset;
1007            (page, total, shown, has_more)
1008        };
1009
1010        // If exhausted, remove cache entry so next call restarts
1011        if !has_more {
1012            self.comments_cache.remove_if_same(&cache_key, &query_lock);
1013        }
1014
1015        Ok(models::CommentsResult {
1016            issue_identifier,
1017            comments: page_comments,
1018            shown_comments: shown,
1019            total_comments: total,
1020            has_more,
1021        })
1022    }
1023
1024    async fn fetch_all_comments(
1025        client: &LinearClient,
1026        issue_id: &str,
1027    ) -> Result<(String, Vec<models::CommentSummary>)> {
1028        let mut cursor: Option<String> = None;
1029        let mut all_comments = Vec::new();
1030        let mut identifier: Option<String> = None;
1031
1032        for page in 0..ISSUE_COMMENTS_MAX_PAGES {
1033            let args = IssueCommentsArguments {
1034                id: issue_id.to_string(),
1035                first: Some(ISSUE_COMMENTS_FETCH_PAGE_SIZE),
1036                after: cursor.clone(),
1037            };
1038            let op = IssueCommentsQuery::build(args);
1039            let resp = client.run(op).await?;
1040            let data = http::extract_data(resp)?;
1041
1042            let issue = data
1043                .issue
1044                .ok_or_else(|| anyhow::anyhow!("Issue not found: {issue_id}"))?;
1045
1046            if identifier.is_none() {
1047                identifier = Some(issue.identifier.clone());
1048            }
1049
1050            all_comments.extend(
1051                issue
1052                    .comments
1053                    .nodes
1054                    .into_iter()
1055                    .map(|c| models::CommentSummary {
1056                        id: c.id.inner().to_string(),
1057                        body: c.body,
1058                        url: c.url,
1059                        created_at: c.created_at.0,
1060                        updated_at: c.updated_at.0,
1061                        parent_id: c.parent_id,
1062                        author_name: c.user.as_ref().map(|u| u.name.clone()),
1063                        author_email: c.user.as_ref().map(|u| u.email.clone()),
1064                    }),
1065            );
1066
1067            if !issue.comments.page_info.has_next_page {
1068                all_comments.sort_by(|a, b| a.created_at.cmp(&b.created_at));
1069                return Ok((identifier.unwrap_or_default(), all_comments));
1070            }
1071
1072            cursor.clone_from(&issue.comments.page_info.end_cursor);
1073            if cursor.is_none() {
1074                return Err(anyhow::anyhow!(
1075                    "Issue comments pagination for {issue_id} reported has_next_page=true without end_cursor"
1076                ));
1077            }
1078
1079            if page + 1 == ISSUE_COMMENTS_MAX_PAGES {
1080                return Err(anyhow::anyhow!(
1081                    "Issue comments pagination for {issue_id} exceeded {ISSUE_COMMENTS_MAX_PAGES} pages"
1082                ));
1083            }
1084        }
1085
1086        unreachable!("issue comments pagination loop must return or error")
1087    }
1088}
1089
1090// Removed universal-tool-core MCP server; use ToolRegistry in tools.rs
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::parse_identifier;
1095
1096    #[test]
1097    fn parse_plain_uppercase() {
1098        assert_eq!(parse_identifier("ENG-245"), Some(("ENG".into(), 245)));
1099    }
1100
1101    #[test]
1102    fn parse_lowercase_normalizes() {
1103        assert_eq!(parse_identifier("eng-245"), Some(("ENG".into(), 245)));
1104    }
1105
1106    #[test]
1107    fn parse_from_url() {
1108        assert_eq!(
1109            parse_identifier("https://linear.app/foo/issue/eng-245/slug"),
1110            Some(("ENG".into(), 245))
1111        );
1112    }
1113
1114    #[test]
1115    fn parse_invalid_returns_none() {
1116        assert_eq!(parse_identifier("invalid"), None);
1117        assert_eq!(parse_identifier("ENG-"), None);
1118        assert_eq!(parse_identifier("ENG"), None);
1119        assert_eq!(parse_identifier("123-456"), None);
1120    }
1121}