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