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