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