1pub mod http;
2pub mod models;
3pub mod tools;
4
5#[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
58pub use tools::build_registry;
60
61fn 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 if let Some((key, number)) = parse_identifier(input) {
112 return IssueIdentifier::Identifier(format!("{key}-{number}"));
113 }
114 IssueIdentifier::Id(input.to_string())
116 }
117
118 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
170impl 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
274impl LinearTools {
276 #[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 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 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 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 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 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 #[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 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 #[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 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 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 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 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 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 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 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 Ok(models::SetRelationResult {
950 success: true,
951 action: "no_change".to_string(),
952 })
953 }
954 }
955 }
956 }
957
958 pub async fn get_issue_comments(&self, issue: String) -> Result<models::CommentsResult> {
960 let client = self.client()?;
961
962 let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
964
965 let cache_key = format!("{issue_id}|{COMMENTS_PAGE_SIZE}");
967
968 self.comments_cache.sweep_expired();
970
971 let query_lock = self.comments_cache.get_or_create(&cache_key);
973
974 let needs_fetch = {
976 let state = query_lock.lock_state();
977 state.is_empty() || state.is_expired()
978 };
979
980 let issue_identifier: String;
982
983 if needs_fetch {
984 let (identifier, all_comments) = Self::fetch_all_comments(&client, &issue_id).await?;
986 issue_identifier = identifier.clone();
987
988 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 let state = query_lock.lock_state();
996 issue_identifier = state.meta.clone();
997 }
998
999 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 !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#[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}