1pub mod http;
2pub mod models;
3pub mod tools;
4
5#[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
57pub use tools::build_registry;
59
60fn 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 if let Some((key, number)) = parse_identifier(input) {
96 return IssueIdentifier::Identifier(format!("{key}-{number}"));
97 }
98 IssueIdentifier::Id(input.to_string())
100 }
101
102 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
154impl 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
258impl LinearTools {
260 #[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 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 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 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 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 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 #[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 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 #[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 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 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 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 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 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 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 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 Ok(models::SetRelationResult {
942 success: true,
943 action: "no_change".to_string(),
944 })
945 }
946 }
947 }
948 }
949
950 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 let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
957
958 let cache_key = format!("{issue_id}|{COMMENTS_PAGE_SIZE}");
960
961 self.comments_cache.sweep_expired();
963
964 let query_lock = self.comments_cache.get_or_create(&cache_key);
966
967 let needs_fetch = {
969 let state = query_lock.lock_state();
970 state.is_empty() || state.is_expired()
971 };
972
973 let issue_identifier: String;
975
976 if needs_fetch {
977 let (identifier, all_comments) = Self::fetch_all_comments(&client, &issue_id).await?;
979 issue_identifier = identifier.clone();
980
981 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 let state = query_lock.lock_state();
989 issue_identifier = state.meta.clone();
990 }
991
992 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 !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#[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}