Skip to main content

devboy_linear/
client.rs

1//! Linear GraphQL client implementation.
2
3use 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        // Linear also signals throttling inside a 200 response via the
374        // RATELIMITED error extension, so keep the headers around before the
375        // body is consumed.
376        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                // `Error::RateLimited` carries no message, so keep Linear's
395                // wording in the log instead of dropping it silently.
396                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        // Single exact-identifier lookup — ordering is irrelevant here.
473        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        // An exact state name always wins over the category aliases below.
607        // Linear's own default states are literally called "Done", "Todo",
608        // "Backlog" and "Canceled"; resolving those by category first would
609        // shadow them and could land on a different state of the same type
610        // (a team with both "Done" and "Released" is ordinary), silently
611        // setting a status the caller never asked for.
612        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        // Not a state name on this team — fall back to the shared category
620        // vocabulary. Which state is chosen among several of the same type
621        // follows the order Linear returns them in; callers who need a
622        // specific one should name it exactly, which the branch above honours.
623        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            // "open" means any non-terminal state.
652            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    /// Every workflow state on the team, following the connection cursor.
669    ///
670    /// A single `first: 100` page silently truncates larger teams, and a
671    /// truncated list makes `resolve_workflow_state_id` miss an exact name and
672    /// fall back to the category branch — landing on a different state of the
673    /// same type. That is precisely the mis-set-status bug this resolver
674    /// exists to avoid, so the list has to be complete.
675    async fn list_workflow_states(&self) -> Result<LinearWorkflowStatesData> {
676        // Bounded so a server that always reports another page cannot spin.
677        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                    // A cursor-less "there is more" would restart from the
697                    // beginning and loop; treat it as a protocol error.
698                    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` stays binary open/closed like every other provider, so
799        // cross-provider filters keep working. The rich workflow state name
800        // goes to `status` and its unified bucket to `status_category`.
801        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
846/// Default sort field, matching the `get_issues` tool contract
847/// ("default: updated_at"). Linear's own default is `createdAt`, so it is
848/// always sent explicitly rather than relying on the server default.
849const DEFAULT_SORT_BY: &str = "updated_at";
850
851/// Maps the cross-provider `sort_by` value onto Linear's
852/// `PaginationOrderBy` enum, which only accepts these two fields.
853fn 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
863/// Linear paginates in descending order and exposes no ascending variant on
864/// `PaginationOrderBy`, so `desc` is accepted and `asc` is refused with a
865/// message naming the argument.
866///
867/// Deliberately **not** `ProviderUnsupported`: that variant means "this
868/// provider cannot handle this tool at all" and the MCP layer swallows it to
869/// try the next provider, which would surface the misleading
870/// "No provider supports 'get_issues'".
871fn 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
881/// Unified status categories this provider accepts as a filter and emits as
882/// [`Issue::status_category`]. Single source of truth — the schema enricher
883/// advertises exactly these values.
884pub(crate) const STATE_CATEGORIES: &[&str] =
885    &["backlog", "todo", "in_progress", "done", "cancelled"];
886
887/// Seconds to wait before retrying, from a throttled Linear response.
888///
889/// Prefers the standard `Retry-After` (delta-seconds form); falls back to
890/// Linear's `X-RateLimit-Requests-Reset`, which is an epoch timestamp in
891/// **milliseconds** and therefore has to be converted to a delta.
892fn 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    // Already elapsed (or clock skew) — retry immediately rather than
912    // reporting a bogus wait.
913    Some(reset_ms.saturating_sub(now_ms).div_ceil(1000))
914}
915
916/// Binary open/closed state, matching the cross-provider `Issue::state`
917/// contract. Linear's `completed`/`canceled` workflow types are terminal;
918/// everything else (including an unknown/missing type) is still open.
919fn 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
926/// Linear workflow state type → unified `Issue::status_category`
927/// (`backlog` / `todo` / `in_progress` / `done` / `cancelled`).
928///
929/// Inverse of [`map_state_category`]. `triage` is Linear's pre-backlog
930/// inbox — not yet actionable, so it buckets with `backlog`.
931fn 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
942/// Unified status category → Linear workflow state type, used to translate
943/// caller-supplied filters into Linear's `state.type` predicate.
944fn 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        // Reject instead of ignoring: silently dropping the clause would
1010        // widen the query to every issue in the team, and the caller would
1011        // read that as a genuine result set.
1012        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        // Allow a second of slack for the clock ticking between the two reads.
1502        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        // A reset already in the past must not underflow into a huge wait.
1514        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        // Missing state, or a type Linear may add later, stays open rather
1531        // than silently disappearing from `state:open` queries.
1532        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        // Every unified category the filter layer accepts must map back to
1582        // itself, so `status_category` values are valid filter inputs.
1583        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        // Unset falls back to the documented tool default, sent explicitly
1909        // because Linear's own server-side default is createdAt.
1910        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            // Must be InvalidData, not ProviderUnsupported: the MCP layer
1927            // swallows ProviderUnsupported and reports the misleading
1928            // "No provider supports 'get_issues'" instead.
1929            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        // The team has two `completed` states and the literal name "Done" is
1940        // one of them. Resolving "Done" by category could land on "Released";
1941        // the exact name must win.
1942        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        // A pure category still resolves by type, first state of that type.
1968        assert_eq!(
1969            client.resolve_workflow_state_id("closed").await.unwrap(),
1970            "st-released"
1971        );
1972        // An unknown name fails loudly and lists what is available.
1973        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                // States are now fetched unfiltered and matched by exact name
2178                // locally, so an exact name is never shadowed by a category alias.
2179                .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}