1use async_trait::async_trait;
4use devboy_core::{
5 Comment, CreateIssueInput, Error, Issue, IssueFilter, IssueProvider, IssueStatus,
6 MergeRequestProvider, Pagination, PipelineProvider, Provider, ProviderResult, Result, SortInfo,
7 SortOrder, UpdateIssueInput, User,
8};
9use secrecy::{ExposeSecret, SecretString};
10use serde_json::{Map, Value, json};
11use tracing::debug;
12
13use crate::DEFAULT_LINEAR_URL;
14use crate::types::{
15 GraphQlResponse, LinearComment, LinearCommentCreateData, LinearIssue, LinearIssueCommentsData,
16 LinearIssueCreateData, LinearIssueData, LinearIssueLabelsData, LinearIssueUpdateData,
17 LinearIssuesData, LinearUser, LinearUsersData, LinearWorkflowState,
18 LinearWorkflowStateConnection, LinearWorkflowStatesData, Viewer, ViewerData,
19};
20
21const VIEWER_QUERY: &str = r#"
22query Viewer {
23 viewer {
24 id
25 name
26 displayName
27 email
28 }
29}
30"#;
31
32const ISSUE_BY_ID_QUERY: &str = r#"
33query IssueById($id: String!) {
34 issue(id: $id) {
35 id
36 identifier
37 title
38 description
39 priority
40 url
41 createdAt
42 updatedAt
43 state {
44 name
45 type
46 }
47 labels {
48 nodes {
49 name
50 }
51 }
52 assignee {
53 id
54 name
55 displayName
56 email
57 avatarUrl
58 }
59 parent {
60 identifier
61 }
62 team {
63 id
64 key
65 }
66 }
67}
68"#;
69
70const ISSUES_QUERY: &str = r#"
71query Issues($first: Int!, $after: String, $filter: IssueFilter, $orderBy: PaginationOrderBy) {
72 issues(first: $first, after: $after, filter: $filter, orderBy: $orderBy) {
73 nodes {
74 id
75 identifier
76 title
77 description
78 priority
79 url
80 createdAt
81 updatedAt
82 state {
83 name
84 type
85 }
86 labels {
87 nodes {
88 name
89 }
90 }
91 assignee {
92 id
93 name
94 displayName
95 email
96 avatarUrl
97 }
98 parent {
99 identifier
100 }
101 team {
102 id
103 key
104 }
105 }
106 pageInfo {
107 hasNextPage
108 endCursor
109 }
110 }
111}
112"#;
113
114const USERS_QUERY: &str = r#"
115query Users($first: Int!, $filter: UserFilter) {
116 users(first: $first, filter: $filter) {
117 nodes {
118 id
119 name
120 displayName
121 email
122 avatarUrl
123 }
124 }
125}
126"#;
127
128const ISSUE_LABELS_QUERY: &str = r#"
129query IssueLabels($first: Int!, $filter: IssueLabelFilter) {
130 issueLabels(first: $first, filter: $filter) {
131 nodes {
132 id
133 name
134 }
135 }
136}
137"#;
138
139const ISSUE_CREATE_MUTATION: &str = r#"
140mutation IssueCreate($input: IssueCreateInput!) {
141 issueCreate(input: $input) {
142 success
143 issue {
144 id
145 identifier
146 title
147 description
148 priority
149 url
150 createdAt
151 updatedAt
152 state {
153 name
154 type
155 }
156 labels {
157 nodes {
158 name
159 }
160 }
161 assignee {
162 id
163 name
164 displayName
165 email
166 avatarUrl
167 }
168 parent {
169 identifier
170 }
171 team {
172 id
173 key
174 }
175 }
176 }
177}
178"#;
179
180const WORKFLOW_STATES_QUERY: &str = r#"
181query WorkflowStates($first: Int!, $after: String, $filter: WorkflowStateFilter) {
182 workflowStates(first: $first, after: $after, filter: $filter) {
183 nodes {
184 id
185 name
186 type
187 }
188 pageInfo {
189 hasNextPage
190 endCursor
191 }
192 }
193}
194"#;
195
196const ISSUE_UPDATE_MUTATION: &str = r#"
197mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
198 issueUpdate(id: $id, input: $input) {
199 success
200 issue {
201 id
202 identifier
203 title
204 description
205 priority
206 url
207 createdAt
208 updatedAt
209 state {
210 name
211 type
212 }
213 labels {
214 nodes {
215 name
216 }
217 }
218 assignee {
219 id
220 name
221 displayName
222 email
223 avatarUrl
224 }
225 parent {
226 identifier
227 }
228 team {
229 id
230 key
231 }
232 }
233 }
234}
235"#;
236
237const ISSUE_COMMENTS_QUERY: &str = r#"
238query IssueComments($id: String!, $first: Int!, $after: String) {
239 issue(id: $id) {
240 comments(first: $first, after: $after) {
241 nodes {
242 id
243 body
244 createdAt
245 updatedAt
246 user {
247 id
248 name
249 displayName
250 email
251 avatarUrl
252 }
253 }
254 pageInfo {
255 hasNextPage
256 endCursor
257 }
258 }
259 }
260}
261"#;
262
263const COMMENT_CREATE_MUTATION: &str = r#"
264mutation CommentCreate($input: CommentCreateInput!) {
265 commentCreate(input: $input) {
266 success
267 comment {
268 id
269 body
270 createdAt
271 updatedAt
272 user {
273 id
274 name
275 displayName
276 email
277 avatarUrl
278 }
279 }
280 }
281}
282"#;
283
284pub struct LinearClient {
285 base_url: String,
286 team_id: String,
287 team_key: Option<String>,
288 token: SecretString,
289 http: reqwest::Client,
290}
291
292impl LinearClient {
293 pub fn new(team_id: impl Into<String>, token: SecretString) -> Self {
294 Self::with_base_url(DEFAULT_LINEAR_URL, team_id, token)
295 }
296
297 pub fn with_base_url(
298 base_url: impl Into<String>,
299 team_id: impl Into<String>,
300 token: SecretString,
301 ) -> Self {
302 Self {
303 base_url: base_url.into().trim_end_matches('/').to_string(),
304 team_id: team_id.into(),
305 team_key: None,
306 token,
307 http: reqwest::Client::builder()
308 .user_agent("devboy-tools")
309 .build()
310 .expect("Failed to create HTTP client"),
311 }
312 }
313
314 pub fn with_team_key(mut self, team_key: impl Into<String>) -> Self {
315 self.team_key = Some(team_key.into());
316 self
317 }
318
319 pub fn team_id(&self) -> &str {
320 &self.team_id
321 }
322
323 pub fn team_key(&self) -> Option<&str> {
324 self.team_key.as_deref()
325 }
326
327 pub(crate) async fn viewer_with_token(&self, token: &SecretString) -> Result<Viewer> {
328 let data: ViewerData = self.graphql(VIEWER_QUERY, json!({}), token).await?;
329 Ok(data.viewer)
330 }
331
332 async fn graphql<T: serde::de::DeserializeOwned>(
333 &self,
334 query: &str,
335 variables: Value,
336 token: &SecretString,
337 ) -> Result<T> {
338 let body = json!({
339 "query": query,
340 "variables": variables,
341 });
342
343 debug!(url = %self.base_url, "linear graphql request");
344
345 let response = self
346 .http
347 .post(&self.base_url)
348 .header("Authorization", token.expose_secret())
349 .header("Content-Type", "application/json")
350 .header("Accept", "application/json")
351 .json(&body)
352 .send()
353 .await
354 .map_err(|e| Error::Network(e.to_string()))?;
355
356 let status = response.status();
357 if status == reqwest::StatusCode::UNAUTHORIZED {
358 return Err(Error::Unauthorized("Invalid Linear API token".to_string()));
359 }
360 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
361 return Err(Error::RateLimited {
362 retry_after: parse_retry_after(response.headers()),
363 });
364 }
365 if !status.is_success() {
366 let text = response.text().await.unwrap_or_default();
367 return Err(Error::Api {
368 status: status.as_u16(),
369 message: text,
370 });
371 }
372
373 let retry_after = parse_retry_after(response.headers());
377
378 let gql_response: GraphQlResponse<T> = response
379 .json()
380 .await
381 .map_err(|e| Error::InvalidData(e.to_string()))?;
382
383 if !gql_response.errors.is_empty() {
384 let rate_limited = gql_response.errors.iter().any(|e| {
385 e.extensions.as_ref().and_then(|x| x.code.as_deref()) == Some("RATELIMITED")
386 });
387 let message = gql_response
388 .errors
389 .into_iter()
390 .map(|e| e.message)
391 .collect::<Vec<_>>()
392 .join("; ");
393 return if rate_limited {
394 debug!(%message, "linear graphql rate limited");
397 Err(Error::RateLimited { retry_after })
398 } else {
399 Err(Error::Api {
400 status: 200,
401 message,
402 })
403 };
404 }
405
406 gql_response
407 .data
408 .ok_or_else(|| Error::InvalidData("Linear API returned no data".to_string()))
409 }
410
411 async fn list_issues_page(
412 &self,
413 first: u32,
414 after: Option<&str>,
415 filter: Value,
416 order_by: &str,
417 ) -> Result<LinearIssuesData> {
418 let variables = json!({
419 "first": first,
420 "after": after,
421 "filter": filter,
422 "orderBy": order_by,
423 });
424 self.graphql(ISSUES_QUERY, variables, &self.token).await
425 }
426
427 async fn get_linear_issue_by_native_id(&self, id: &str) -> Result<Option<LinearIssue>> {
428 let data: LinearIssueData = self
429 .graphql(ISSUE_BY_ID_QUERY, json!({ "id": id }), &self.token)
430 .await?;
431 Ok(data.issue.filter(|issue| {
432 issue
433 .team
434 .as_ref()
435 .is_some_and(|team| team.id == self.team_id)
436 }))
437 }
438
439 async fn get_linear_issue_by_identifier(
440 &self,
441 identifier: &str,
442 ) -> Result<Option<LinearIssue>> {
443 let (prefix, number) = parse_linear_identifier(identifier).ok_or_else(|| {
444 Error::InvalidData(format!(
445 "Linear issue key '{identifier}' must be a UUID or team-key identifier like ENG-123"
446 ))
447 })?;
448
449 if let Some(team_key) = self.team_key()
450 && !prefix.eq_ignore_ascii_case(team_key)
451 {
452 return Ok(None);
453 }
454
455 let filter = json!({
456 "and": [
457 {
458 "team": {
459 "id": {
460 "eq": self.team_id
461 }
462 }
463 },
464 {
465 "number": {
466 "eq": number
467 }
468 }
469 ]
470 });
471
472 let data = self.list_issues_page(1, None, filter, "updatedAt").await?;
474 Ok(data.issues.nodes.into_iter().next())
475 }
476
477 async fn resolve_scoped_issue(&self, key: &str) -> Result<LinearIssue> {
478 let issue = if looks_like_uuid(key) {
479 self.get_linear_issue_by_native_id(key).await?
480 } else {
481 self.get_linear_issue_by_identifier(key).await?
482 };
483
484 issue.ok_or_else(|| Error::NotFound(format!("Linear issue not found: {key}")))
485 }
486
487 async fn resolve_assignee_id(&self, assignee: &str) -> Result<String> {
488 if looks_like_uuid(assignee) {
489 return Ok(assignee.to_string());
490 }
491
492 let filter = json!({
493 "or": [
494 {
495 "name": {
496 "eqIgnoreCase": assignee
497 }
498 },
499 {
500 "displayName": {
501 "eqIgnoreCase": assignee
502 }
503 },
504 {
505 "email": {
506 "eqIgnoreCase": assignee
507 }
508 }
509 ]
510 });
511 let variables = json!({
512 "first": 10,
513 "filter": filter,
514 });
515 let data: LinearUsersData = self.graphql(USERS_QUERY, variables, &self.token).await?;
516 let user = data
517 .users
518 .nodes
519 .into_iter()
520 .find(|user| {
521 user.name.eq_ignore_ascii_case(assignee)
522 || user
523 .display_name
524 .as_deref()
525 .is_some_and(|display| display.eq_ignore_ascii_case(assignee))
526 || user
527 .email
528 .as_deref()
529 .is_some_and(|email| email.eq_ignore_ascii_case(assignee))
530 })
531 .ok_or_else(|| Error::NotFound(format!("Linear user not found: {assignee}")))?;
532 Ok(user.id)
533 }
534
535 async fn resolve_label_ids(&self, labels: &[String]) -> Result<Vec<String>> {
536 if labels.is_empty() {
537 return Ok(Vec::new());
538 }
539
540 let variables = json!({
541 "first": 100,
542 "filter": {
543 "team": {
544 "id": {
545 "eq": self.team_id
546 }
547 },
548 "name": {
549 "in": labels
550 }
551 }
552 });
553 let data: LinearIssueLabelsData = self
554 .graphql(ISSUE_LABELS_QUERY, variables, &self.token)
555 .await?;
556
557 let mut ids = Vec::with_capacity(labels.len());
558 let mut missing = Vec::new();
559 for wanted in labels {
560 if let Some(label) = data
561 .issue_labels
562 .nodes
563 .iter()
564 .find(|label| label.name.eq_ignore_ascii_case(wanted))
565 {
566 ids.push(label.id.clone());
567 } else {
568 missing.push(wanted.clone());
569 }
570 }
571
572 if !missing.is_empty() {
573 return Err(Error::NotFound(format!(
574 "Linear labels not found for team {}: {}",
575 self.team_id,
576 missing.join(", ")
577 )));
578 }
579
580 Ok(ids)
581 }
582
583 async fn resolve_parent_id(&self, parent: &str) -> Result<String> {
584 if looks_like_uuid(parent) {
585 return Ok(parent.to_string());
586 }
587
588 let issue = self
589 .get_linear_issue_by_identifier(parent)
590 .await?
591 .ok_or_else(|| Error::NotFound(format!("Linear parent issue not found: {parent}")))?;
592 Ok(issue.id)
593 }
594
595 async fn resolve_workflow_state_id(&self, state: &str) -> Result<String> {
596 let state = state.trim();
597 if state.is_empty() {
598 return Err(Error::InvalidData(
599 "Linear state/status must not be empty".to_string(),
600 ));
601 }
602
603 let data = self.list_workflow_states().await?;
604 let nodes = data.workflow_states.nodes;
605
606 if let Some(node) = nodes
613 .iter()
614 .find(|node| node.name.eq_ignore_ascii_case(state))
615 {
616 return Ok(node.id.clone());
617 }
618
619 let target_type = match state.to_ascii_lowercase().as_str() {
624 "open" | "opened" => None,
625 "closed" => Some("completed"),
626 "cancelled" | "canceled" => Some("canceled"),
627 "backlog" => Some("backlog"),
628 "todo" => Some("unstarted"),
629 "in_progress" | "in progress" | "started" => Some("started"),
630 "done" | "completed" => Some("completed"),
631 _ => {
632 return Err(Error::NotFound(format!(
633 "Linear workflow state not found by name '{}' in team {}; \
634 available: {}",
635 state,
636 self.team_id,
637 nodes
638 .iter()
639 .map(|node| node.name.as_str())
640 .collect::<Vec<_>>()
641 .join(", ")
642 )));
643 }
644 };
645
646 let state_id = match target_type {
647 Some(target_type) => nodes
648 .into_iter()
649 .find(|node| node.r#type.as_deref() == Some(target_type))
650 .map(|node| node.id),
651 None => nodes
653 .into_iter()
654 .find(|node| {
655 !matches!(node.r#type.as_deref(), Some("completed") | Some("canceled"))
656 })
657 .map(|node| node.id),
658 };
659
660 state_id.ok_or_else(|| {
661 Error::NotFound(format!(
662 "Linear workflow state not found for '{}' in team {}",
663 state, self.team_id
664 ))
665 })
666 }
667
668 async fn list_workflow_states(&self) -> Result<LinearWorkflowStatesData> {
676 const MAX_PAGES: usize = 50;
678
679 let mut nodes = Vec::new();
680 let mut after: Option<String> = None;
681
682 for _ in 0..MAX_PAGES {
683 let variables = json!({
684 "first": 100,
685 "after": after,
686 "filter": { "team": { "id": { "eq": self.team_id } } }
687 });
688 let page: LinearWorkflowStatesData = self
689 .graphql(WORKFLOW_STATES_QUERY, variables, &self.token)
690 .await?;
691 let connection = page.workflow_states;
692 nodes.extend(connection.nodes);
693
694 match connection.page_info {
695 Some(info) if info.has_next_page => match info.end_cursor {
696 Some(cursor) => after = Some(cursor),
699 None => {
700 return Err(Error::InvalidData(
701 "Linear reported more workflow states but returned no cursor"
702 .to_string(),
703 ));
704 }
705 },
706 _ => {
707 return Ok(LinearWorkflowStatesData {
708 workflow_states: LinearWorkflowStateConnection {
709 nodes,
710 page_info: None,
711 },
712 });
713 }
714 }
715 }
716
717 Err(Error::InvalidData(format!(
718 "Linear still reported more workflow states after {MAX_PAGES} pages"
719 )))
720 }
721
722 fn map_create_priority(priority: Option<&str>) -> Result<Option<i32>> {
723 let Some(priority) = priority.map(str::trim).filter(|p| !p.is_empty()) else {
724 return Ok(None);
725 };
726
727 match priority.to_ascii_lowercase().as_str() {
728 "none" | "no priority" => Ok(Some(0)),
729 "urgent" => Ok(Some(1)),
730 "high" => Ok(Some(2)),
731 "normal" | "medium" => Ok(Some(3)),
732 "low" => Ok(Some(4)),
733 other => match other.parse::<i32>() {
734 Ok(value @ 0..=4) => Ok(Some(value)),
735 _ => Err(Error::InvalidData(format!(
736 "Unsupported Linear priority '{priority}'. Expected urgent/high/normal/low or 0-4"
737 ))),
738 },
739 }
740 }
741}
742
743fn parse_linear_identifier(key: &str) -> Option<(&str, i64)> {
744 let (prefix, number) = key.rsplit_once('-')?;
745 let number = number.parse().ok()?;
746 if prefix.is_empty() {
747 return None;
748 }
749 Some((prefix, number))
750}
751
752fn looks_like_uuid(key: &str) -> bool {
753 let mut hex_count = 0usize;
754 let mut hyphen_count = 0usize;
755 for ch in key.chars() {
756 if ch == '-' {
757 hyphen_count += 1;
758 } else if ch.is_ascii_hexdigit() {
759 hex_count += 1;
760 } else {
761 return false;
762 }
763 }
764 hyphen_count == 4 && hex_count >= 32
765}
766
767fn map_user(user: Option<&LinearUser>) -> Option<User> {
768 user.map(|u| User {
769 id: u.id.clone(),
770 username: u.display_name.clone().unwrap_or_else(|| u.name.clone()),
771 name: Some(u.name.clone()),
772 email: u.email.clone(),
773 avatar_url: u.avatar_url.clone(),
774 })
775}
776
777fn map_priority(priority: Option<i32>) -> Option<String> {
778 priority.and_then(|p| match p {
779 0 => None,
780 1 => Some("urgent".to_string()),
781 2 => Some("high".to_string()),
782 3 => Some("normal".to_string()),
783 4 => Some("low".to_string()),
784 other => Some(other.to_string()),
785 })
786}
787
788fn map_issue(issue: &LinearIssue) -> Issue {
789 Issue {
790 custom_fields: std::collections::HashMap::new(),
791 key: if issue.identifier.is_empty() {
792 format!("linear#{}", issue.id)
793 } else {
794 issue.identifier.clone()
795 },
796 title: issue.title.clone(),
797 description: issue.description.clone(),
798 state: map_state(issue.state.as_ref()),
802 status: issue
803 .state
804 .as_ref()
805 .map(|state| state.name.clone())
806 .filter(|name| !name.is_empty()),
807 status_category: issue
808 .state
809 .as_ref()
810 .and_then(|state| state.r#type.as_deref())
811 .and_then(map_status_category)
812 .map(str::to_string),
813 source: "linear".to_string(),
814 priority: map_priority(issue.priority),
815 labels: issue
816 .labels
817 .nodes
818 .iter()
819 .map(|label| label.name.clone())
820 .collect(),
821 author: None,
822 assignees: map_user(issue.assignee.as_ref()).into_iter().collect(),
823 url: issue.url.clone(),
824 created_at: issue.created_at.clone(),
825 updated_at: issue.updated_at.clone(),
826 attachments_count: None,
827 parent: issue
828 .parent
829 .as_ref()
830 .map(|parent| parent.identifier.clone()),
831 subtasks: Vec::new(),
832 }
833}
834
835fn map_comment(comment: &LinearComment) -> Comment {
836 Comment {
837 id: comment.id.clone(),
838 body: comment.body.clone().unwrap_or_default(),
839 author: map_user(comment.user.as_ref()),
840 created_at: comment.created_at.clone(),
841 updated_at: comment.updated_at.clone(),
842 position: None,
843 }
844}
845
846const DEFAULT_SORT_BY: &str = "updated_at";
850
851fn map_order_by(sort_by: Option<&str>) -> Result<&'static str> {
854 match sort_by.map(str::trim).unwrap_or(DEFAULT_SORT_BY) {
855 "created_at" => Ok("createdAt"),
856 "updated_at" => Ok("updatedAt"),
857 other => Err(Error::InvalidData(format!(
858 "unsupported sort_by '{other}' for Linear; expected one of: created_at, updated_at"
859 ))),
860 }
861}
862
863fn validate_sort_order(sort_order: Option<&str>) -> Result<()> {
872 match sort_order.map(str::trim) {
873 None | Some("") | Some("desc") => Ok(()),
874 Some(other) => Err(Error::InvalidData(format!(
875 "unsupported sort_order '{other}' for Linear; only 'desc' is available \
876 because Linear's pagination has no ascending order"
877 ))),
878 }
879}
880
881pub(crate) const STATE_CATEGORIES: &[&str] =
885 &["backlog", "todo", "in_progress", "done", "cancelled"];
886
887fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64> {
893 if let Some(seconds) = headers
894 .get(reqwest::header::RETRY_AFTER)
895 .and_then(|value| value.to_str().ok())
896 .and_then(|value| value.trim().parse::<u64>().ok())
897 {
898 return Some(seconds);
899 }
900
901 let reset_ms = headers
902 .get("x-ratelimit-requests-reset")
903 .and_then(|value| value.to_str().ok())
904 .and_then(|value| value.trim().parse::<u64>().ok())?;
905
906 let now_ms = std::time::SystemTime::now()
907 .duration_since(std::time::UNIX_EPOCH)
908 .ok()?
909 .as_millis() as u64;
910
911 Some(reset_ms.saturating_sub(now_ms).div_ceil(1000))
914}
915
916fn map_state(state: Option<&LinearWorkflowState>) -> String {
920 match state.and_then(|s| s.r#type.as_deref()) {
921 Some("completed") | Some("canceled") => "closed".to_string(),
922 _ => "open".to_string(),
923 }
924}
925
926fn map_status_category(state_type: &str) -> Option<&'static str> {
932 match state_type {
933 "triage" | "backlog" => Some("backlog"),
934 "unstarted" => Some("todo"),
935 "started" => Some("in_progress"),
936 "completed" => Some("done"),
937 "canceled" => Some("cancelled"),
938 _ => None,
939 }
940}
941
942fn map_state_category(category: &str) -> Option<&'static str> {
945 match category {
946 "backlog" => Some("backlog"),
947 "todo" => Some("unstarted"),
948 "in_progress" => Some("started"),
949 "done" => Some("completed"),
950 "cancelled" => Some("canceled"),
951 _ => None,
952 }
953}
954
955fn build_issue_filter(team_id: &str, filter: &IssueFilter) -> Result<Value> {
956 if filter.native_query.is_some() {
957 return Err(Error::ProviderUnsupported {
958 provider: "linear".to_string(),
959 operation: "get_issues(native_query)".to_string(),
960 });
961 }
962
963 let mut clauses = vec![json!({
964 "team": {
965 "id": {
966 "eq": team_id
967 }
968 }
969 })];
970
971 if let Some(state) = filter
972 .state
973 .as_deref()
974 .map(str::trim)
975 .filter(|s| !s.is_empty())
976 {
977 match state.to_ascii_lowercase().as_str() {
978 "open" | "opened" => clauses.push(json!({
979 "state": {
980 "type": {
981 "nin": ["completed", "canceled"]
982 }
983 }
984 })),
985 "closed" => clauses.push(json!({
986 "state": {
987 "type": {
988 "in": ["completed", "canceled"]
989 }
990 }
991 })),
992 "all" => {}
993 _ => clauses.push(json!({
994 "state": {
995 "name": {
996 "eqIgnoreCase": state
997 }
998 }
999 })),
1000 }
1001 }
1002
1003 if let Some(requested) = filter
1004 .state_category
1005 .as_deref()
1006 .map(str::trim)
1007 .filter(|s| !s.is_empty())
1008 {
1009 let category = map_state_category(requested).ok_or_else(|| {
1013 Error::InvalidData(format!(
1014 "unknown state category '{requested}' for Linear; expected one of: {}",
1015 STATE_CATEGORIES.join(", ")
1016 ))
1017 })?;
1018 clauses.push(json!({
1019 "state": {
1020 "type": {
1021 "eq": category
1022 }
1023 }
1024 }));
1025 }
1026
1027 if let Some(search) = filter
1028 .search
1029 .as_deref()
1030 .map(str::trim)
1031 .filter(|s| !s.is_empty())
1032 {
1033 clauses.push(json!({
1034 "or": [
1035 {
1036 "title": {
1037 "containsIgnoreCase": search
1038 }
1039 },
1040 {
1041 "description": {
1042 "containsIgnoreCase": search
1043 }
1044 }
1045 ]
1046 }));
1047 }
1048
1049 if let Some(labels) = filter.labels.as_ref().filter(|labels| !labels.is_empty()) {
1050 if matches!(filter.labels_operator.as_deref(), Some("and")) {
1051 clauses.push(json!({
1052 "and": labels.iter().map(|label| json!({
1053 "labels": {
1054 "name": {
1055 "eq": label
1056 }
1057 }
1058 })).collect::<Vec<_>>()
1059 }));
1060 } else {
1061 clauses.push(json!({
1062 "labels": {
1063 "name": {
1064 "in": labels
1065 }
1066 }
1067 }));
1068 }
1069 }
1070
1071 if let Some(assignee) = filter
1072 .assignee
1073 .as_deref()
1074 .map(str::trim)
1075 .filter(|s| !s.is_empty())
1076 {
1077 clauses.push(json!({
1078 "or": [
1079 {
1080 "assignee": {
1081 "name": {
1082 "eqIgnoreCase": assignee
1083 }
1084 }
1085 },
1086 {
1087 "assignee": {
1088 "displayName": {
1089 "eqIgnoreCase": assignee
1090 }
1091 }
1092 },
1093 {
1094 "assignee": {
1095 "email": {
1096 "eqIgnoreCase": assignee
1097 }
1098 }
1099 }
1100 ]
1101 }));
1102 }
1103
1104 if clauses.len() == 1 {
1105 return Ok(clauses.remove(0));
1106 }
1107
1108 let mut root = Map::new();
1109 root.insert("and".to_string(), Value::Array(clauses));
1110 Ok(Value::Object(root))
1111}
1112
1113#[async_trait]
1114impl IssueProvider for LinearClient {
1115 async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
1116 let offset = filter.offset.unwrap_or(0);
1117 let limit = filter.limit.unwrap_or(50).max(1);
1118 let total_needed = offset.saturating_add(limit);
1119 validate_sort_order(filter.sort_order.as_deref())?;
1120 let order_by = map_order_by(filter.sort_by.as_deref())?;
1121 let gql_filter = build_issue_filter(&self.team_id, &filter)?;
1122
1123 let mut after: Option<String> = None;
1124 let mut fetched = 0u32;
1125 let mut issues = Vec::new();
1126 let mut has_more = false;
1127 let mut next_cursor = None;
1128
1129 while fetched < total_needed {
1130 let remaining = total_needed.saturating_sub(fetched).max(1);
1131 let page_size = remaining.min(100);
1132 let data = self
1133 .list_issues_page(page_size, after.as_deref(), gql_filter.clone(), order_by)
1134 .await?;
1135
1136 let page_info = data.issues.page_info;
1137 let page_nodes = data.issues.nodes;
1138 if page_nodes.is_empty() {
1139 has_more = false;
1140 next_cursor = None;
1141 break;
1142 }
1143
1144 for issue in page_nodes {
1145 if fetched >= offset && issues.len() < limit as usize {
1146 issues.push(map_issue(&issue));
1147 }
1148 fetched = fetched.saturating_add(1);
1149 if fetched >= total_needed {
1150 break;
1151 }
1152 }
1153
1154 has_more = page_info.has_next_page;
1155 next_cursor = page_info.end_cursor.clone();
1156 if !has_more {
1157 break;
1158 }
1159 after = page_info.end_cursor;
1160 }
1161
1162 Ok(ProviderResult {
1163 items: issues,
1164 pagination: Some(Pagination {
1165 offset,
1166 limit,
1167 total: None,
1168 has_more,
1169 next_cursor,
1170 }),
1171 sort_info: Some(SortInfo {
1172 sort_by: Some(
1173 filter
1174 .sort_by
1175 .as_deref()
1176 .unwrap_or(DEFAULT_SORT_BY)
1177 .to_string(),
1178 ),
1179 sort_order: SortOrder::Desc,
1180 available_sorts: vec!["created_at".to_string(), "updated_at".to_string()],
1181 }),
1182 })
1183 }
1184
1185 async fn get_issue(&self, key: &str) -> Result<Issue> {
1186 Ok(map_issue(&self.resolve_scoped_issue(key).await?))
1187 }
1188
1189 async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
1190 let assignee_id = match input.assignees.first() {
1191 Some(assignee) => Some(self.resolve_assignee_id(assignee).await?),
1192 None => None,
1193 };
1194 let label_ids = self.resolve_label_ids(&input.labels).await?;
1195 let parent_id = match input.parent.as_deref() {
1196 Some(parent) => Some(self.resolve_parent_id(parent).await?),
1197 None => None,
1198 };
1199 let priority = Self::map_create_priority(input.priority.as_deref())?;
1200
1201 let mut payload = Map::new();
1202 payload.insert("teamId".to_string(), Value::String(self.team_id.clone()));
1203 payload.insert("title".to_string(), Value::String(input.title));
1204
1205 if let Some(description) = input.description {
1206 payload.insert("description".to_string(), Value::String(description));
1207 }
1208 if let Some(priority) = priority {
1209 payload.insert("priority".to_string(), Value::Number(priority.into()));
1210 }
1211 if let Some(assignee_id) = assignee_id {
1212 payload.insert("assigneeId".to_string(), Value::String(assignee_id));
1213 }
1214 if let Some(parent_id) = parent_id {
1215 payload.insert("parentId".to_string(), Value::String(parent_id));
1216 }
1217 if let Some(project_id) = input.project_id {
1218 payload.insert("projectId".to_string(), Value::String(project_id));
1219 }
1220 if !label_ids.is_empty() {
1221 payload.insert(
1222 "labelIds".to_string(),
1223 Value::Array(label_ids.into_iter().map(Value::String).collect()),
1224 );
1225 }
1226
1227 let data: LinearIssueCreateData = self
1228 .graphql(
1229 ISSUE_CREATE_MUTATION,
1230 json!({
1231 "input": Value::Object(payload),
1232 }),
1233 &self.token,
1234 )
1235 .await?;
1236 if !data.issue_create.success {
1237 return Err(Error::Api {
1238 status: 200,
1239 message: "Linear issueCreate returned success=false".to_string(),
1240 });
1241 }
1242
1243 let issue = data.issue_create.issue.ok_or_else(|| {
1244 Error::InvalidData("Linear issueCreate returned no issue payload".to_string())
1245 })?;
1246 Ok(map_issue(&issue))
1247 }
1248
1249 async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
1250 let issue_id = self.resolve_scoped_issue(key).await?.id;
1251
1252 let assignee_id = match input.assignees.as_ref() {
1253 Some(assignees) if assignees.is_empty() => Some(Value::Null),
1254 Some(assignees) => Some(Value::String(
1255 self.resolve_assignee_id(&assignees[0]).await?,
1256 )),
1257 None => None,
1258 };
1259 let label_ids = match input.labels.as_ref() {
1260 Some(labels) => Some(
1261 self.resolve_label_ids(labels)
1262 .await?
1263 .into_iter()
1264 .map(Value::String)
1265 .collect::<Vec<_>>(),
1266 ),
1267 None => None,
1268 };
1269 let parent_id = match input.parent_id.as_deref() {
1270 Some("none") | Some("") => Some(Value::Null),
1271 Some(parent) => Some(Value::String(self.resolve_parent_id(parent).await?)),
1272 None => None,
1273 };
1274 let priority = Self::map_create_priority(input.priority.as_deref())?;
1275 let state_id = match input.status.as_deref().or(input.state.as_deref()) {
1276 Some(state) => Some(self.resolve_workflow_state_id(state).await?),
1277 None => None,
1278 };
1279
1280 let mut payload = Map::new();
1281 if let Some(title) = input.title {
1282 payload.insert("title".to_string(), Value::String(title));
1283 }
1284 if let Some(description) = input.description {
1285 payload.insert("description".to_string(), Value::String(description));
1286 }
1287 if let Some(priority) = priority {
1288 payload.insert("priority".to_string(), Value::Number(priority.into()));
1289 }
1290 if let Some(assignee_id) = assignee_id {
1291 payload.insert("assigneeId".to_string(), assignee_id);
1292 }
1293 if let Some(parent_id) = parent_id {
1294 payload.insert("parentId".to_string(), parent_id);
1295 }
1296 if let Some(label_ids) = label_ids {
1297 payload.insert("labelIds".to_string(), Value::Array(label_ids));
1298 }
1299 if let Some(state_id) = state_id {
1300 payload.insert("stateId".to_string(), Value::String(state_id));
1301 }
1302
1303 if payload.is_empty() {
1304 return self.get_issue(key).await;
1305 }
1306
1307 let data: LinearIssueUpdateData = self
1308 .graphql(
1309 ISSUE_UPDATE_MUTATION,
1310 json!({
1311 "id": issue_id,
1312 "input": Value::Object(payload),
1313 }),
1314 &self.token,
1315 )
1316 .await?;
1317 if !data.issue_update.success {
1318 return Err(Error::Api {
1319 status: 200,
1320 message: "Linear issueUpdate returned success=false".to_string(),
1321 });
1322 }
1323
1324 let issue = data.issue_update.issue.ok_or_else(|| {
1325 Error::InvalidData("Linear issueUpdate returned no issue payload".to_string())
1326 })?;
1327 Ok(map_issue(&issue))
1328 }
1329
1330 async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
1331 let issue_id = self.resolve_scoped_issue(issue_key).await?.id;
1332
1333 let mut after: Option<String> = None;
1334 let mut comments = Vec::new();
1335
1336 loop {
1337 let data: LinearIssueCommentsData = self
1338 .graphql(
1339 ISSUE_COMMENTS_QUERY,
1340 json!({
1341 "id": issue_id,
1342 "first": 100,
1343 "after": after,
1344 }),
1345 &self.token,
1346 )
1347 .await?;
1348 let issue = data.issue.ok_or_else(|| {
1349 Error::NotFound(format!(
1350 "Linear issue not found when fetching comments: {issue_key}"
1351 ))
1352 })?;
1353
1354 let page_info = issue.comments.page_info;
1355 comments.extend(issue.comments.nodes.iter().map(map_comment));
1356
1357 if !page_info.has_next_page {
1358 break;
1359 }
1360 after = page_info.end_cursor;
1361 if after.is_none() {
1362 break;
1363 }
1364 }
1365
1366 Ok(comments.into())
1367 }
1368
1369 async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1370 let issue_id = self.resolve_scoped_issue(issue_key).await?.id;
1371
1372 let data: LinearCommentCreateData = self
1373 .graphql(
1374 COMMENT_CREATE_MUTATION,
1375 json!({
1376 "input": {
1377 "issueId": issue_id,
1378 "body": body,
1379 }
1380 }),
1381 &self.token,
1382 )
1383 .await?;
1384 if !data.comment_create.success {
1385 return Err(Error::Api {
1386 status: 200,
1387 message: "Linear commentCreate returned success=false".to_string(),
1388 });
1389 }
1390
1391 let comment = data.comment_create.comment.ok_or_else(|| {
1392 Error::InvalidData("Linear commentCreate returned no comment payload".to_string())
1393 })?;
1394 Ok(map_comment(&comment))
1395 }
1396
1397 async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
1398 let data = self.list_workflow_states().await?;
1399 let statuses = data
1400 .workflow_states
1401 .nodes
1402 .into_iter()
1403 .enumerate()
1404 .map(|(idx, state)| IssueStatus {
1405 id: state.id,
1406 name: state.name,
1407 category: match state.r#type.as_deref() {
1408 Some("backlog") => "backlog".to_string(),
1409 Some("unstarted") => "todo".to_string(),
1410 Some("started") => "in_progress".to_string(),
1411 Some("completed") => "done".to_string(),
1412 Some("canceled") => "cancelled".to_string(),
1413 Some(other) => other.to_string(),
1414 None => "custom".to_string(),
1415 },
1416 color: None,
1417 order: Some(idx as u32),
1418 })
1419 .collect::<Vec<_>>();
1420 Ok(statuses.into())
1421 }
1422
1423 fn provider_name(&self) -> &'static str {
1424 "linear"
1425 }
1426}
1427
1428#[async_trait]
1429impl MergeRequestProvider for LinearClient {
1430 fn provider_name(&self) -> &'static str {
1431 "linear"
1432 }
1433}
1434
1435#[async_trait]
1436impl PipelineProvider for LinearClient {
1437 fn provider_name(&self) -> &'static str {
1438 "linear"
1439 }
1440}
1441
1442#[async_trait]
1443impl Provider for LinearClient {
1444 async fn get_current_user(&self) -> Result<User> {
1445 let viewer = self.viewer_with_token(&self.token).await?;
1446 Ok(User {
1447 id: viewer.id,
1448 username: viewer
1449 .display_name
1450 .clone()
1451 .unwrap_or_else(|| viewer.name.clone()),
1452 name: Some(viewer.name),
1453 email: viewer.email,
1454 avatar_url: None,
1455 })
1456 }
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461 use super::*;
1462 use httpmock::Method::POST;
1463 use httpmock::MockServer;
1464 use serde_json::json;
1465
1466 fn workflow_state(name: &str, r#type: Option<&str>) -> LinearWorkflowState {
1467 LinearWorkflowState {
1468 name: name.to_string(),
1469 r#type: r#type.map(str::to_string),
1470 }
1471 }
1472
1473 fn headers_from(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
1474 let mut headers = reqwest::header::HeaderMap::new();
1475 for (name, value) in pairs {
1476 headers.insert(
1477 reqwest::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
1478 reqwest::header::HeaderValue::from_str(value).unwrap(),
1479 );
1480 }
1481 headers
1482 }
1483
1484 #[test]
1485 fn parse_retry_after_prefers_standard_header() {
1486 let headers = headers_from(&[
1487 ("retry-after", "42"),
1488 ("x-ratelimit-requests-reset", "99999999999999"),
1489 ]);
1490 assert_eq!(parse_retry_after(&headers), Some(42));
1491 }
1492
1493 #[test]
1494 fn parse_retry_after_converts_linear_reset_epoch_millis_to_delta() {
1495 let now_ms = std::time::SystemTime::now()
1496 .duration_since(std::time::UNIX_EPOCH)
1497 .unwrap()
1498 .as_millis() as u64;
1499 let headers =
1500 headers_from(&[("x-ratelimit-requests-reset", &(now_ms + 30_000).to_string())]);
1501 let seconds = parse_retry_after(&headers).expect("delta from reset header");
1503 assert!((29..=31).contains(&seconds), "unexpected delta: {seconds}");
1504 }
1505
1506 #[test]
1507 fn parse_retry_after_handles_missing_and_elapsed_values() {
1508 assert_eq!(parse_retry_after(&headers_from(&[])), None);
1509 assert_eq!(
1510 parse_retry_after(&headers_from(&[("retry-after", "not-a-number")])),
1511 None
1512 );
1513 assert_eq!(
1515 parse_retry_after(&headers_from(&[("x-ratelimit-requests-reset", "1")])),
1516 Some(0)
1517 );
1518 }
1519
1520 #[test]
1521 fn map_state_is_binary_and_only_terminal_types_close() {
1522 for open_type in ["triage", "backlog", "unstarted", "started"] {
1523 let state = workflow_state("Whatever", Some(open_type));
1524 assert_eq!(map_state(Some(&state)), "open", "type={open_type}");
1525 }
1526 for closed_type in ["completed", "canceled"] {
1527 let state = workflow_state("Whatever", Some(closed_type));
1528 assert_eq!(map_state(Some(&state)), "closed", "type={closed_type}");
1529 }
1530 assert_eq!(map_state(None), "open");
1533 assert_eq!(map_state(Some(&workflow_state("New", None))), "open");
1534 assert_eq!(
1535 map_state(Some(&workflow_state("New", Some("something_new")))),
1536 "open"
1537 );
1538 }
1539
1540 #[test]
1541 fn build_issue_filter_rejects_unknown_state_category() {
1542 let filter = IssueFilter {
1543 state_category: Some("in-progress".to_string()),
1544 ..Default::default()
1545 };
1546 let err = build_issue_filter("team-1", &filter).unwrap_err();
1547 match err {
1548 Error::InvalidData(message) => {
1549 assert!(message.contains("in-progress"), "message: {message}");
1550 assert!(message.contains("in_progress"), "message: {message}");
1551 }
1552 other => panic!("expected InvalidData, got {other:?}"),
1553 }
1554 }
1555
1556 #[test]
1557 fn build_issue_filter_accepts_every_advertised_state_category() {
1558 for category in STATE_CATEGORIES {
1559 let filter = IssueFilter {
1560 state_category: Some((*category).to_string()),
1561 ..Default::default()
1562 };
1563 build_issue_filter("team-1", &filter)
1564 .unwrap_or_else(|e| panic!("category {category} rejected: {e:?}"));
1565 }
1566 }
1567
1568 #[test]
1569 fn map_status_category_covers_every_linear_state_type() {
1570 assert_eq!(map_status_category("triage"), Some("backlog"));
1571 assert_eq!(map_status_category("backlog"), Some("backlog"));
1572 assert_eq!(map_status_category("unstarted"), Some("todo"));
1573 assert_eq!(map_status_category("started"), Some("in_progress"));
1574 assert_eq!(map_status_category("completed"), Some("done"));
1575 assert_eq!(map_status_category("canceled"), Some("cancelled"));
1576 assert_eq!(map_status_category("something_new"), None);
1577 }
1578
1579 #[test]
1580 fn map_status_category_round_trips_through_map_state_category() {
1581 for category in ["backlog", "todo", "in_progress", "done", "cancelled"] {
1584 let linear_type = map_state_category(category)
1585 .unwrap_or_else(|| panic!("no linear type for {category}"));
1586 assert_eq!(map_status_category(linear_type), Some(category));
1587 }
1588 }
1589
1590 fn linear_issue(identifier: &str, title: &str, state: &str) -> Value {
1591 json!({
1592 "id": format!("id-{identifier}"),
1593 "identifier": identifier,
1594 "title": title,
1595 "description": format!("Description for {identifier}"),
1596 "priority": 2,
1597 "url": format!("https://linear.app/acme/issue/{identifier}/{}", title.replace(' ', "-").to_lowercase()),
1598 "createdAt": "2026-05-01T10:00:00.000Z",
1599 "updatedAt": "2026-05-02T10:00:00.000Z",
1600 "state": {
1601 "name": state,
1602 "type": "started"
1603 },
1604 "labels": {
1605 "nodes": [
1606 { "name": "bug" }
1607 ]
1608 },
1609 "assignee": {
1610 "id": "u1",
1611 "name": "Alice Doe",
1612 "displayName": "alice",
1613 "email": "alice@example.com",
1614 "avatarUrl": "https://example.com/alice.png"
1615 },
1616 "parent": {
1617 "identifier": "ENG-1"
1618 },
1619 "team": {
1620 "id": "team-1",
1621 "key": "ENG"
1622 }
1623 })
1624 }
1625
1626 fn linear_comment(id: &str, body: &str) -> Value {
1627 json!({
1628 "id": id,
1629 "body": body,
1630 "createdAt": "2026-05-04T10:00:00.000Z",
1631 "updatedAt": "2026-05-05T10:00:00.000Z",
1632 "user": {
1633 "id": "u1",
1634 "name": "Alice Doe",
1635 "displayName": "alice",
1636 "email": "alice@example.com",
1637 "avatarUrl": "https://example.com/alice.png"
1638 }
1639 })
1640 }
1641
1642 #[tokio::test]
1643 async fn get_current_user_reads_viewer() {
1644 let server = MockServer::start();
1645 let mock = server.mock(|when, then| {
1646 when.method(POST)
1647 .path("/graphql")
1648 .body_includes("query Viewer");
1649 then.status(200)
1650 .header("content-type", "application/json")
1651 .json_body(json!({
1652 "data": {
1653 "viewer": {
1654 "id": "u1",
1655 "name": "Alice",
1656 "displayName": "alice",
1657 "email": "alice@example.com"
1658 }
1659 }
1660 }));
1661 });
1662
1663 let client = LinearClient::with_base_url(
1664 format!("{}/graphql", server.base_url()),
1665 "team-1",
1666 SecretString::from("lin_api_test".to_owned()),
1667 );
1668
1669 let user = client.get_current_user().await.unwrap();
1670 assert_eq!(user.id, "u1");
1671 assert_eq!(user.username, "alice");
1672 assert_eq!(user.name.as_deref(), Some("Alice"));
1673 assert_eq!(user.email.as_deref(), Some("alice@example.com"));
1674 mock.assert();
1675 }
1676
1677 #[tokio::test]
1678 async fn get_issue_by_identifier_uses_team_scoped_issue_filter() {
1679 let server = MockServer::start();
1680 let mock = server.mock(|when, then| {
1681 when.method(POST)
1682 .path("/graphql")
1683 .body_includes("query Issues")
1684 .body_includes(r#""first":1"#)
1685 .body_includes(r#""team":{"id":{"eq":"team-1"}}"#)
1686 .body_includes(r#""number":{"eq":42}"#);
1687 then.status(200)
1688 .header("content-type", "application/json")
1689 .json_body(json!({
1690 "data": {
1691 "issues": {
1692 "nodes": [
1693 linear_issue("ENG-42", "Fix login", "In Progress")
1694 ],
1695 "pageInfo": {
1696 "hasNextPage": false,
1697 "endCursor": null
1698 }
1699 }
1700 }
1701 }));
1702 });
1703
1704 let client = LinearClient::with_base_url(
1705 format!("{}/graphql", server.base_url()),
1706 "team-1",
1707 SecretString::from("lin_api_test".to_owned()),
1708 );
1709
1710 let issue = client.get_issue("ENG-42").await.unwrap();
1711 assert_eq!(issue.key, "ENG-42");
1712 assert_eq!(issue.title, "Fix login");
1713 assert_eq!(issue.state, "open");
1714 assert_eq!(issue.status.as_deref(), Some("In Progress"));
1715 assert_eq!(issue.status_category.as_deref(), Some("in_progress"));
1716 assert_eq!(issue.priority.as_deref(), Some("high"));
1717 assert_eq!(issue.labels, vec!["bug".to_string()]);
1718 assert_eq!(issue.parent.as_deref(), Some("ENG-1"));
1719 assert_eq!(issue.assignees.len(), 1);
1720 assert_eq!(issue.assignees[0].username, "alice");
1721
1722 mock.assert();
1723 }
1724
1725 #[tokio::test]
1726 async fn get_issue_by_identifier_rejects_mismatched_team_prefix() {
1727 let server = MockServer::start();
1728 let client = LinearClient::with_base_url(
1729 format!("{}/graphql", server.base_url()),
1730 "team-1",
1731 SecretString::from("lin_api_test".to_owned()),
1732 )
1733 .with_team_key("ENG");
1734
1735 let result = client.get_issue("OPS-42").await;
1736 assert!(matches!(result, Err(Error::NotFound(msg)) if msg.contains("OPS-42")));
1737 }
1738
1739 #[tokio::test]
1740 async fn get_issue_by_native_id_uses_issue_query() {
1741 let server = MockServer::start();
1742 let mock = server.mock(|when, then| {
1743 when.method(POST)
1744 .path("/graphql")
1745 .body_includes("query IssueById")
1746 .body_includes(r#""id":"3d1b0f7a-8f3a-4b2a-9c1a-2b6a0c4b9a11""#);
1747 then.status(200)
1748 .header("content-type", "application/json")
1749 .json_body(json!({
1750 "data": {
1751 "issue": linear_issue("ENG-7", "Fetch by uuid", "Backlog")
1752 }
1753 }));
1754 });
1755
1756 let client = LinearClient::with_base_url(
1757 format!("{}/graphql", server.base_url()),
1758 "team-1",
1759 SecretString::from("lin_api_test".to_owned()),
1760 );
1761
1762 let uuid = "3d1b0f7a-8f3a-4b2a-9c1a-2b6a0c4b9a11";
1763 let issue = client.get_issue(uuid).await.unwrap();
1764 assert_eq!(issue.key, "ENG-7");
1765
1766 mock.assert();
1767 }
1768
1769 #[tokio::test]
1770 async fn get_issue_by_native_id_rejects_issue_from_other_team() {
1771 let server = MockServer::start();
1772 let mock = server.mock(|when, then| {
1773 when.method(POST)
1774 .path("/graphql")
1775 .body_includes("query IssueById")
1776 .body_includes(r#""id":"3d1b0f7a-8f3a-4b2a-9c1a-2b6a0c4b9a11""#);
1777 then.status(200)
1778 .header("content-type", "application/json")
1779 .json_body(json!({
1780 "data": {
1781 "issue": {
1782 "id": "3d1b0f7a-8f3a-4b2a-9c1a-2b6a0c4b9a11",
1783 "identifier": "OPS-7",
1784 "title": "Foreign team issue",
1785 "description": "Should not resolve",
1786 "priority": 2,
1787 "url": "https://linear.app/acme/issue/OPS-7/foreign-team-issue",
1788 "createdAt": "2026-05-01T10:00:00.000Z",
1789 "updatedAt": "2026-05-02T10:00:00.000Z",
1790 "state": {
1791 "name": "Backlog",
1792 "type": "backlog"
1793 },
1794 "labels": { "nodes": [] },
1795 "assignee": null,
1796 "parent": null,
1797 "team": {
1798 "id": "team-2",
1799 "key": "OPS"
1800 }
1801 }
1802 }
1803 }));
1804 });
1805
1806 let client = LinearClient::with_base_url(
1807 format!("{}/graphql", server.base_url()),
1808 "team-1",
1809 SecretString::from("lin_api_test".to_owned()),
1810 );
1811
1812 let uuid = "3d1b0f7a-8f3a-4b2a-9c1a-2b6a0c4b9a11";
1813 let result = client.get_issue(uuid).await;
1814 assert!(matches!(result, Err(Error::NotFound(msg)) if msg.contains(uuid)));
1815
1816 mock.assert();
1817 }
1818
1819 #[tokio::test]
1820 async fn get_issues_applies_filters_and_reports_pagination() {
1821 let server = MockServer::start();
1822 let page_1 = server.mock(|when, then| {
1823 when.method(POST)
1824 .path("/graphql")
1825 .body_includes("query Issues")
1826 .body_includes(r#""first":3"#)
1827 .body_includes(r#""state":{"type":{"nin":["completed","canceled"]}}"#)
1828 .body_includes(r#""title":{"containsIgnoreCase":"login"}"#)
1829 .body_includes(r#""labels":{"name":{"eq":"bug"}}"#)
1830 .body_includes(r#""displayName":{"eqIgnoreCase":"alice"}"#);
1831 then.status(200)
1832 .header("content-type", "application/json")
1833 .json_body(json!({
1834 "data": {
1835 "issues": {
1836 "nodes": [
1837 linear_issue("ENG-1", "One", "Backlog"),
1838 linear_issue("ENG-2", "Two", "In Progress"),
1839 linear_issue("ENG-3", "Three", "In Progress")
1840 ],
1841 "pageInfo": {
1842 "hasNextPage": true,
1843 "endCursor": "cursor-1"
1844 }
1845 }
1846 }
1847 }));
1848 });
1849 let page_2 = server.mock(|when, then| {
1850 when.method(POST)
1851 .path("/graphql")
1852 .body_includes(r#""after":"cursor-1""#);
1853 then.status(200)
1854 .header("content-type", "application/json")
1855 .json_body(json!({
1856 "data": {
1857 "issues": {
1858 "nodes": [
1859 linear_issue("ENG-4", "Four", "Done")
1860 ],
1861 "pageInfo": {
1862 "hasNextPage": false,
1863 "endCursor": "cursor-2"
1864 }
1865 }
1866 }
1867 }));
1868 });
1869
1870 let client = LinearClient::with_base_url(
1871 format!("{}/graphql", server.base_url()),
1872 "team-1",
1873 SecretString::from("lin_api_test".to_owned()),
1874 );
1875
1876 let result = client
1877 .get_issues(IssueFilter {
1878 state: Some("open".to_string()),
1879 search: Some("login".to_string()),
1880 labels: Some(vec!["bug".to_string(), "api".to_string()]),
1881 labels_operator: Some("and".to_string()),
1882 assignee: Some("alice".to_string()),
1883 limit: Some(2),
1884 offset: Some(1),
1885 ..Default::default()
1886 })
1887 .await
1888 .unwrap();
1889
1890 assert_eq!(result.items.len(), 2);
1891 assert_eq!(result.items[0].key, "ENG-2");
1892 assert_eq!(result.items[1].key, "ENG-3");
1893
1894 let pagination = result.pagination.unwrap();
1895 assert_eq!(pagination.offset, 1);
1896 assert_eq!(pagination.limit, 2);
1897 assert!(pagination.has_more);
1898 assert_eq!(pagination.next_cursor.as_deref(), Some("cursor-1"));
1899
1900 page_1.assert();
1901 assert_eq!(page_2.calls(), 0);
1902 }
1903
1904 #[test]
1905 fn map_order_by_covers_the_advertised_sort_fields() {
1906 assert_eq!(map_order_by(Some("created_at")).unwrap(), "createdAt");
1907 assert_eq!(map_order_by(Some("updated_at")).unwrap(), "updatedAt");
1908 assert_eq!(map_order_by(None).unwrap(), "updatedAt");
1911
1912 let err = map_order_by(Some("priority")).unwrap_err();
1913 assert!(
1914 matches!(&err, Error::InvalidData(m) if m.contains("priority") && m.contains("updated_at")),
1915 "unexpected error: {err:?}"
1916 );
1917 }
1918
1919 #[test]
1920 fn validate_sort_order_accepts_desc_and_names_the_argument_on_asc() {
1921 validate_sort_order(None).unwrap();
1922 validate_sort_order(Some("desc")).unwrap();
1923
1924 let err = validate_sort_order(Some("asc")).unwrap_err();
1925 match &err {
1926 Error::InvalidData(message) => {
1930 assert!(message.contains("sort_order"), "message: {message}");
1931 assert!(message.contains("asc"), "message: {message}");
1932 }
1933 other => panic!("unexpected error: {other:?}"),
1934 }
1935 }
1936
1937 #[tokio::test]
1938 async fn resolve_workflow_state_prefers_an_exact_name_over_the_category_alias() {
1939 let server = MockServer::start();
1943 let states = server.mock(|when, then| {
1944 when.method(POST)
1945 .path("/graphql")
1946 .body_includes("query WorkflowStates");
1947 then.status(200)
1948 .header("content-type", "application/json")
1949 .json_body(json!({
1950 "data": { "workflowStates": { "nodes": [
1951 { "id": "st-released", "name": "Released", "type": "completed" },
1952 { "id": "st-done", "name": "Done", "type": "completed" }
1953 ] } }
1954 }));
1955 });
1956
1957 let client = LinearClient::with_base_url(
1958 format!("{}/graphql", server.base_url()),
1959 "team-1",
1960 SecretString::from("lin_api_test".to_owned()),
1961 );
1962
1963 assert_eq!(
1964 client.resolve_workflow_state_id("Done").await.unwrap(),
1965 "st-done"
1966 );
1967 assert_eq!(
1969 client.resolve_workflow_state_id("closed").await.unwrap(),
1970 "st-released"
1971 );
1972 let err = client
1974 .resolve_workflow_state_id("Shipped")
1975 .await
1976 .unwrap_err();
1977 assert!(
1978 matches!(&err, Error::NotFound(m) if m.contains("Shipped") && m.contains("Released")),
1979 "unexpected error: {err:?}"
1980 );
1981 states.assert_calls(3);
1982 }
1983
1984 #[tokio::test]
1985 async fn get_issues_sends_order_by_and_reports_sort_info() {
1986 let server = MockServer::start();
1987 let page = server.mock(|when, then| {
1988 when.method(POST)
1989 .path("/graphql")
1990 .body_includes("query Issues")
1991 .body_includes(r#""orderBy":"createdAt""#);
1992 then.status(200)
1993 .header("content-type", "application/json")
1994 .json_body(json!({
1995 "data": {
1996 "issues": {
1997 "nodes": [linear_issue("ENG-1", "One", "In Progress")],
1998 "pageInfo": { "hasNextPage": false, "endCursor": null }
1999 }
2000 }
2001 }));
2002 });
2003
2004 let client = LinearClient::with_base_url(
2005 format!("{}/graphql", server.base_url()),
2006 "team-1",
2007 SecretString::from("lin_api_test".to_owned()),
2008 );
2009
2010 let result = client
2011 .get_issues(IssueFilter {
2012 sort_by: Some("created_at".to_string()),
2013 sort_order: Some("desc".to_string()),
2014 ..Default::default()
2015 })
2016 .await
2017 .unwrap();
2018
2019 let sort = result.sort_info.expect("sort_info reported");
2020 assert_eq!(sort.sort_by.as_deref(), Some("created_at"));
2021 assert_eq!(sort.sort_order, SortOrder::Desc);
2022 assert_eq!(sort.available_sorts, vec!["created_at", "updated_at"]);
2023 page.assert();
2024 }
2025
2026 #[tokio::test]
2027 async fn create_issue_resolves_inputs_and_returns_created_issue() {
2028 let server = MockServer::start();
2029 let users = server.mock(|when, then| {
2030 when.method(POST)
2031 .path("/graphql")
2032 .body_includes("query Users")
2033 .body_includes(r#""displayName":{"eqIgnoreCase":"alice"}"#);
2034 then.status(200)
2035 .header("content-type", "application/json")
2036 .json_body(json!({
2037 "data": {
2038 "users": {
2039 "nodes": [
2040 {
2041 "id": "user-1",
2042 "name": "Alice Doe",
2043 "displayName": "alice",
2044 "email": "alice@example.com",
2045 "avatarUrl": "https://example.com/alice.png"
2046 }
2047 ]
2048 }
2049 }
2050 }));
2051 });
2052 let labels = server.mock(|when, then| {
2053 when.method(POST)
2054 .path("/graphql")
2055 .body_includes("query IssueLabels")
2056 .body_includes(r#""team":{"id":{"eq":"team-1"}}"#)
2057 .body_includes(r#""name":{"in":["bug","backend"]}"#);
2058 then.status(200)
2059 .header("content-type", "application/json")
2060 .json_body(json!({
2061 "data": {
2062 "issueLabels": {
2063 "nodes": [
2064 { "id": "label-1", "name": "bug" },
2065 { "id": "label-2", "name": "backend" }
2066 ]
2067 }
2068 }
2069 }));
2070 });
2071 let parent = server.mock(|when, then| {
2072 when.method(POST)
2073 .path("/graphql")
2074 .body_includes("query Issues")
2075 .body_includes(r#""number":{"eq":1}"#);
2076 then.status(200)
2077 .header("content-type", "application/json")
2078 .json_body(json!({
2079 "data": {
2080 "issues": {
2081 "nodes": [
2082 linear_issue("ENG-1", "Parent issue", "Backlog")
2083 ],
2084 "pageInfo": {
2085 "hasNextPage": false,
2086 "endCursor": null
2087 }
2088 }
2089 }
2090 }));
2091 });
2092 let create = server.mock(|when, then| {
2093 when.method(POST)
2094 .path("/graphql")
2095 .body_includes("mutation IssueCreate")
2096 .body_includes(r#""teamId":"team-1""#)
2097 .body_includes(r#""title":"Create login flow""#)
2098 .body_includes(r#""description":"Ship the first pass""#)
2099 .body_includes(r#""priority":1"#)
2100 .body_includes(r#""assigneeId":"user-1""#)
2101 .body_includes(r#""parentId":"id-ENG-1""#)
2102 .body_includes(r#""projectId":"project-1""#)
2103 .body_includes(r#""labelIds":["label-1","label-2"]"#);
2104 then.status(200)
2105 .header("content-type", "application/json")
2106 .json_body(json!({
2107 "data": {
2108 "issueCreate": {
2109 "success": true,
2110 "issue": linear_issue("ENG-99", "Create login flow", "Backlog")
2111 }
2112 }
2113 }));
2114 });
2115
2116 let client = LinearClient::with_base_url(
2117 format!("{}/graphql", server.base_url()),
2118 "team-1",
2119 SecretString::from("lin_api_test".to_owned()),
2120 );
2121
2122 let issue = client
2123 .create_issue(CreateIssueInput {
2124 title: "Create login flow".to_string(),
2125 description: Some("Ship the first pass".to_string()),
2126 labels: vec!["bug".to_string(), "backend".to_string()],
2127 assignees: vec!["alice".to_string()],
2128 priority: Some("urgent".to_string()),
2129 parent: Some("ENG-1".to_string()),
2130 project_id: Some("project-1".to_string()),
2131 ..Default::default()
2132 })
2133 .await
2134 .unwrap();
2135
2136 assert_eq!(issue.key, "ENG-99");
2137 assert_eq!(issue.title, "Create login flow");
2138 assert_eq!(issue.priority.as_deref(), Some("high"));
2139 assert_eq!(issue.parent.as_deref(), Some("ENG-1"));
2140 assert_eq!(issue.assignees.len(), 1);
2141 assert_eq!(issue.assignees[0].username, "alice");
2142 assert_eq!(issue.labels, vec!["bug".to_string()]);
2143
2144 users.assert();
2145 labels.assert();
2146 parent.assert();
2147 create.assert();
2148 }
2149
2150 #[tokio::test]
2151 async fn update_issue_resolves_status_and_clears_optional_fields() {
2152 let server = MockServer::start();
2153 let issue_lookup = server.mock(|when, then| {
2154 when.method(POST)
2155 .path("/graphql")
2156 .body_includes("query Issues")
2157 .body_includes(r#""number":{"eq":42}"#);
2158 then.status(200)
2159 .header("content-type", "application/json")
2160 .json_body(json!({
2161 "data": {
2162 "issues": {
2163 "nodes": [
2164 linear_issue("ENG-42", "Original issue", "Backlog")
2165 ],
2166 "pageInfo": {
2167 "hasNextPage": false,
2168 "endCursor": null
2169 }
2170 }
2171 }
2172 }));
2173 });
2174 let workflow_states = server.mock(|when, then| {
2175 when.method(POST)
2176 .path("/graphql")
2177 .body_includes("query WorkflowStates");
2180 then.status(200)
2181 .header("content-type", "application/json")
2182 .json_body(json!({
2183 "data": {
2184 "workflowStates": {
2185 "nodes": [
2186 {
2187 "id": "workflow-2",
2188 "name": "In Review",
2189 "type": "started"
2190 }
2191 ]
2192 }
2193 }
2194 }));
2195 });
2196 let update = server.mock(|when, then| {
2197 when.method(POST)
2198 .path("/graphql")
2199 .body_includes("mutation IssueUpdate")
2200 .body_includes(r#""id":"id-ENG-42""#)
2201 .body_includes(r#""title":"Updated issue title""#)
2202 .body_includes(r#""description":"Updated description""#)
2203 .body_includes(r#""priority":4"#)
2204 .body_includes(r#""assigneeId":null"#)
2205 .body_includes(r#""parentId":null"#)
2206 .body_includes(r#""labelIds":[]"#)
2207 .body_includes(r#""stateId":"workflow-2""#);
2208 then.status(200)
2209 .header("content-type", "application/json")
2210 .json_body(json!({
2211 "data": {
2212 "issueUpdate": {
2213 "success": true,
2214 "issue": {
2215 "id": "id-ENG-42",
2216 "identifier": "ENG-42",
2217 "title": "Updated issue title",
2218 "description": "Updated description",
2219 "priority": 4,
2220 "url": "https://linear.app/acme/issue/ENG-42/updated-issue-title",
2221 "createdAt": "2026-05-01T10:00:00.000Z",
2222 "updatedAt": "2026-05-03T10:00:00.000Z",
2223 "state": {
2224 "name": "In Review",
2225 "type": "started"
2226 },
2227 "labels": {
2228 "nodes": []
2229 },
2230 "assignee": null,
2231 "parent": null
2232 }
2233 }
2234 }
2235 }));
2236 });
2237
2238 let client = LinearClient::with_base_url(
2239 format!("{}/graphql", server.base_url()),
2240 "team-1",
2241 SecretString::from("lin_api_test".to_owned()),
2242 );
2243
2244 let issue = client
2245 .update_issue(
2246 "ENG-42",
2247 UpdateIssueInput {
2248 title: Some("Updated issue title".to_string()),
2249 description: Some("Updated description".to_string()),
2250 state: Some("closed".to_string()),
2251 status: Some("In Review".to_string()),
2252 labels: Some(vec![]),
2253 assignees: Some(vec![]),
2254 priority: Some("low".to_string()),
2255 parent_id: Some("none".to_string()),
2256 ..Default::default()
2257 },
2258 )
2259 .await
2260 .unwrap();
2261
2262 assert_eq!(issue.key, "ENG-42");
2263 assert_eq!(issue.title, "Updated issue title");
2264 assert_eq!(issue.description.as_deref(), Some("Updated description"));
2265 assert_eq!(issue.state, "open");
2266 assert_eq!(issue.status.as_deref(), Some("In Review"));
2267 assert_eq!(issue.status_category.as_deref(), Some("in_progress"));
2268 assert_eq!(issue.priority.as_deref(), Some("low"));
2269 assert!(issue.labels.is_empty());
2270 assert!(issue.assignees.is_empty());
2271 assert_eq!(issue.parent, None);
2272
2273 issue_lookup.assert();
2274 workflow_states.assert();
2275 update.assert();
2276 }
2277
2278 #[tokio::test]
2279 async fn get_comments_paginates_and_maps_authors() {
2280 let server = MockServer::start();
2281 let issue_lookup = server.mock(|when, then| {
2282 when.method(POST)
2283 .path("/graphql")
2284 .body_includes("query Issues")
2285 .body_includes(r#""number":{"eq":42}"#);
2286 then.status(200)
2287 .header("content-type", "application/json")
2288 .json_body(json!({
2289 "data": {
2290 "issues": {
2291 "nodes": [
2292 linear_issue("ENG-42", "Issue for comments", "Backlog")
2293 ],
2294 "pageInfo": {
2295 "hasNextPage": false,
2296 "endCursor": null
2297 }
2298 }
2299 }
2300 }));
2301 });
2302 let comments_page_1 = server.mock(|when, then| {
2303 when.method(POST)
2304 .path("/graphql")
2305 .body_includes("query IssueComments")
2306 .body_includes(r#""id":"id-ENG-42""#)
2307 .body_includes(r#""first":100"#)
2308 .body_includes(r#""after":null"#);
2309 then.status(200)
2310 .header("content-type", "application/json")
2311 .json_body(json!({
2312 "data": {
2313 "issue": {
2314 "comments": {
2315 "nodes": [
2316 linear_comment("comment-1", "First comment")
2317 ],
2318 "pageInfo": {
2319 "hasNextPage": true,
2320 "endCursor": "cursor-1"
2321 }
2322 }
2323 }
2324 }
2325 }));
2326 });
2327 let comments_page_2 = server.mock(|when, then| {
2328 when.method(POST)
2329 .path("/graphql")
2330 .body_includes(r#""after":"cursor-1""#);
2331 then.status(200)
2332 .header("content-type", "application/json")
2333 .json_body(json!({
2334 "data": {
2335 "issue": {
2336 "comments": {
2337 "nodes": [
2338 linear_comment("comment-2", "Second comment")
2339 ],
2340 "pageInfo": {
2341 "hasNextPage": false,
2342 "endCursor": "cursor-2"
2343 }
2344 }
2345 }
2346 }
2347 }));
2348 });
2349
2350 let client = LinearClient::with_base_url(
2351 format!("{}/graphql", server.base_url()),
2352 "team-1",
2353 SecretString::from("lin_api_test".to_owned()),
2354 );
2355
2356 let comments = client.get_comments("ENG-42").await.unwrap();
2357 assert_eq!(comments.items.len(), 2);
2358 assert_eq!(comments.items[0].id, "comment-1");
2359 assert_eq!(comments.items[0].body, "First comment");
2360 assert_eq!(
2361 comments.items[0]
2362 .author
2363 .as_ref()
2364 .map(|u| u.username.as_str()),
2365 Some("alice")
2366 );
2367 assert_eq!(comments.items[1].id, "comment-2");
2368
2369 issue_lookup.assert();
2370 comments_page_1.assert();
2371 comments_page_2.assert();
2372 }
2373
2374 #[tokio::test]
2375 async fn add_comment_resolves_issue_and_returns_comment() {
2376 let server = MockServer::start();
2377 let issue_lookup = server.mock(|when, then| {
2378 when.method(POST)
2379 .path("/graphql")
2380 .body_includes("query Issues")
2381 .body_includes(r#""number":{"eq":42}"#);
2382 then.status(200)
2383 .header("content-type", "application/json")
2384 .json_body(json!({
2385 "data": {
2386 "issues": {
2387 "nodes": [
2388 linear_issue("ENG-42", "Issue for add comment", "Backlog")
2389 ],
2390 "pageInfo": {
2391 "hasNextPage": false,
2392 "endCursor": null
2393 }
2394 }
2395 }
2396 }));
2397 });
2398 let create = server.mock(|when, then| {
2399 when.method(POST)
2400 .path("/graphql")
2401 .body_includes("mutation CommentCreate")
2402 .body_includes(r#""issueId":"id-ENG-42""#)
2403 .body_includes(r#""body":"Need a follow-up here.""#);
2404 then.status(200)
2405 .header("content-type", "application/json")
2406 .json_body(json!({
2407 "data": {
2408 "commentCreate": {
2409 "success": true,
2410 "comment": linear_comment("comment-3", "Need a follow-up here.")
2411 }
2412 }
2413 }));
2414 });
2415
2416 let client = LinearClient::with_base_url(
2417 format!("{}/graphql", server.base_url()),
2418 "team-1",
2419 SecretString::from("lin_api_test".to_owned()),
2420 );
2421
2422 let comment =
2423 devboy_core::IssueProvider::add_comment(&client, "ENG-42", "Need a follow-up here.")
2424 .await
2425 .unwrap();
2426 assert_eq!(comment.id, "comment-3");
2427 assert_eq!(comment.body, "Need a follow-up here.");
2428 assert_eq!(
2429 comment.author.as_ref().map(|u| u.username.as_str()),
2430 Some("alice")
2431 );
2432
2433 issue_lookup.assert();
2434 create.assert();
2435 }
2436
2437 #[tokio::test]
2438 async fn get_statuses_returns_team_workflow_states() {
2439 let server = MockServer::start();
2440 let states = server.mock(|when, then| {
2441 when.method(POST)
2442 .path("/graphql")
2443 .body_includes("query WorkflowStates")
2444 .body_includes(r#""team":{"id":{"eq":"team-1"}}"#);
2445 then.status(200)
2446 .header("content-type", "application/json")
2447 .json_body(json!({
2448 "data": {
2449 "workflowStates": {
2450 "nodes": [
2451 { "id": "s1", "name": "Backlog", "type": "backlog" },
2452 { "id": "s2", "name": "In Review", "type": "started" },
2453 { "id": "s3", "name": "Done", "type": "completed" }
2454 ]
2455 }
2456 }
2457 }));
2458 });
2459
2460 let client = LinearClient::with_base_url(
2461 format!("{}/graphql", server.base_url()),
2462 "team-1",
2463 SecretString::from("lin_api_test".to_owned()),
2464 );
2465
2466 let statuses = client.get_statuses().await.unwrap();
2467 assert_eq!(statuses.items.len(), 3);
2468 assert_eq!(statuses.items[0].name, "Backlog");
2469 assert_eq!(statuses.items[0].category, "backlog");
2470 assert_eq!(statuses.items[1].category, "in_progress");
2471 assert_eq!(statuses.items[2].category, "done");
2472
2473 states.assert();
2474 }
2475
2476 #[tokio::test]
2477 async fn get_issues_rejects_native_query_and_supports_closed_state_category() {
2478 let client = LinearClient::with_base_url(
2479 "https://api.linear.app/graphql",
2480 "team-1",
2481 SecretString::from("lin_api_test".to_owned()),
2482 );
2483
2484 let unsupported = client
2485 .get_issues(IssueFilter {
2486 native_query: Some("project = ENG".to_string()),
2487 ..Default::default()
2488 })
2489 .await;
2490 assert!(matches!(
2491 unsupported,
2492 Err(Error::ProviderUnsupported { provider, operation })
2493 if provider == "linear" && operation == "get_issues(native_query)"
2494 ));
2495
2496 let server = MockServer::start();
2497 let mock = server.mock(|when, then| {
2498 when.method(POST)
2499 .path("/graphql")
2500 .body_includes(r#""state":{"type":{"in":["completed","canceled"]}}"#)
2501 .body_includes(r#""state":{"type":{"eq":"completed"}}"#)
2502 .body_includes(r#""labels":{"name":{"in":["ops","api"]}}"#);
2503 then.status(200)
2504 .header("content-type", "application/json")
2505 .json_body(json!({
2506 "data": {
2507 "issues": {
2508 "nodes": [],
2509 "pageInfo": {
2510 "hasNextPage": false,
2511 "endCursor": null
2512 }
2513 }
2514 }
2515 }));
2516 });
2517
2518 let client = LinearClient::with_base_url(
2519 format!("{}/graphql", server.base_url()),
2520 "team-1",
2521 SecretString::from("lin_api_test".to_owned()),
2522 );
2523
2524 let result = client
2525 .get_issues(IssueFilter {
2526 state: Some("closed".to_string()),
2527 state_category: Some("done".to_string()),
2528 labels: Some(vec!["ops".to_string(), "api".to_string()]),
2529 labels_operator: Some("or".to_string()),
2530 limit: Some(5),
2531 ..Default::default()
2532 })
2533 .await
2534 .unwrap();
2535
2536 assert!(result.items.is_empty());
2537 assert!(!result.pagination.unwrap().has_more);
2538 mock.assert();
2539 }
2540
2541 #[tokio::test]
2542 async fn get_current_user_surfaces_graphql_transport_errors() {
2543 let unauthorized = MockServer::start();
2544 let unauthorized_mock = unauthorized.mock(|when, then| {
2545 when.method(POST).path("/graphql");
2546 then.status(401).header("content-type", "application/json");
2547 });
2548 let unauthorized_client = LinearClient::with_base_url(
2549 format!("{}/graphql", unauthorized.base_url()),
2550 "team-1",
2551 SecretString::from("lin_api_bad".to_owned()),
2552 );
2553 let unauthorized_err = unauthorized_client.get_current_user().await.unwrap_err();
2554 assert!(matches!(unauthorized_err, Error::Unauthorized(_)));
2555 unauthorized_mock.assert();
2556
2557 let throttled = MockServer::start();
2558 let throttled_mock = throttled.mock(|when, then| {
2559 when.method(POST).path("/graphql");
2560 then.status(429).header("content-type", "application/json");
2561 });
2562 let throttled_client = LinearClient::with_base_url(
2563 format!("{}/graphql", throttled.base_url()),
2564 "team-1",
2565 SecretString::from("lin_api_throttled".to_owned()),
2566 );
2567 let throttled_err = throttled_client.get_current_user().await.unwrap_err();
2568 assert!(matches!(
2569 throttled_err,
2570 Error::RateLimited { retry_after: None }
2571 ));
2572 throttled_mock.assert();
2573
2574 let gql_error = MockServer::start();
2575 let gql_error_mock = gql_error.mock(|when, then| {
2576 when.method(POST).path("/graphql");
2577 then.status(200)
2578 .header("content-type", "application/json")
2579 .json_body(json!({
2580 "errors": [
2581 {
2582 "message": "viewer is unavailable"
2583 }
2584 ]
2585 }));
2586 });
2587 let gql_error_client = LinearClient::with_base_url(
2588 format!("{}/graphql", gql_error.base_url()),
2589 "team-1",
2590 SecretString::from("lin_api_gql_error".to_owned()),
2591 );
2592 let gql_error_result = gql_error_client.get_current_user().await.unwrap_err();
2593 assert!(matches!(
2594 gql_error_result,
2595 Error::Api { status: 200, message } if message.contains("viewer is unavailable")
2596 ));
2597 gql_error_mock.assert();
2598 }
2599
2600 #[tokio::test]
2601 async fn get_current_user_maps_graphql_rate_limit_and_missing_data() {
2602 let rate_limited = MockServer::start();
2603 let rate_limited_mock = rate_limited.mock(|when, then| {
2604 when.method(POST).path("/graphql");
2605 then.status(200)
2606 .header("content-type", "application/json")
2607 .json_body(json!({
2608 "errors": [
2609 {
2610 "message": "Too many requests",
2611 "extensions": {
2612 "code": "RATELIMITED"
2613 }
2614 }
2615 ]
2616 }));
2617 });
2618 let rate_limited_client = LinearClient::with_base_url(
2619 format!("{}/graphql", rate_limited.base_url()),
2620 "team-1",
2621 SecretString::from("lin_api_rate_limited".to_owned()),
2622 );
2623 let rate_limited_err = rate_limited_client.get_current_user().await.unwrap_err();
2624 assert!(matches!(
2625 rate_limited_err,
2626 Error::RateLimited { retry_after: None }
2627 ));
2628 rate_limited_mock.assert();
2629
2630 let missing_data = MockServer::start();
2631 let missing_data_mock = missing_data.mock(|when, then| {
2632 when.method(POST).path("/graphql");
2633 then.status(200)
2634 .header("content-type", "application/json")
2635 .json_body(json!({
2636 "data": null
2637 }));
2638 });
2639 let missing_data_client = LinearClient::with_base_url(
2640 format!("{}/graphql", missing_data.base_url()),
2641 "team-1",
2642 SecretString::from("lin_api_missing_data".to_owned()),
2643 );
2644 let missing_data_err = missing_data_client.get_current_user().await.unwrap_err();
2645 assert!(matches!(
2646 missing_data_err,
2647 Error::InvalidData(message) if message.contains("no data")
2648 ));
2649 missing_data_mock.assert();
2650 }
2651
2652 #[tokio::test]
2653 async fn create_and_update_issue_cover_validation_and_noop_paths() {
2654 let client = LinearClient::with_base_url(
2655 "https://api.linear.app/graphql",
2656 "team-1",
2657 SecretString::from("lin_api_test".to_owned()),
2658 );
2659
2660 let invalid_priority = client
2661 .create_issue(CreateIssueInput {
2662 title: "Broken".to_string(),
2663 priority: Some("critical".to_string()),
2664 ..Default::default()
2665 })
2666 .await;
2667 assert!(matches!(
2668 invalid_priority,
2669 Err(Error::InvalidData(message)) if message.contains("Unsupported Linear priority")
2670 ));
2671
2672 let invalid_identifier = client
2673 .update_issue("ENG", UpdateIssueInput::default())
2674 .await;
2675 assert!(matches!(
2676 invalid_identifier,
2677 Err(Error::InvalidData(message)) if message.contains("must be a UUID or team-key identifier")
2678 ));
2679
2680 let server = MockServer::start();
2681 let issue_lookup = server.mock(|when, then| {
2682 when.method(POST)
2683 .path("/graphql")
2684 .body_includes("query Issues")
2685 .body_includes(r#""number":{"eq":77}"#);
2686 then.status(200)
2687 .header("content-type", "application/json")
2688 .json_body(json!({
2689 "data": {
2690 "issues": {
2691 "nodes": [
2692 linear_issue("ENG-77", "No-op issue", "Backlog")
2693 ],
2694 "pageInfo": {
2695 "hasNextPage": false,
2696 "endCursor": null
2697 }
2698 }
2699 }
2700 }));
2701 });
2702
2703 let client = LinearClient::with_base_url(
2704 format!("{}/graphql", server.base_url()),
2705 "team-1",
2706 SecretString::from("lin_api_test".to_owned()),
2707 );
2708 let issue = client
2709 .update_issue("ENG-77", UpdateIssueInput::default())
2710 .await
2711 .unwrap();
2712 assert_eq!(issue.key, "ENG-77");
2713 assert_eq!(issue_lookup.calls(), 2);
2714 }
2715}