Skip to main content

leash_sdk/integrations/
linear.rs

1//! Linear provider.
2//!
3//! Wire action names are underscored (`list_issues`, etc.) — preserved here.
4//! Tolerant of envelope vs. bare-array response shapes (the platform sometimes
5//! returns `[...]`, sometimes `{ issues: [...] }`).
6
7use serde::Serialize;
8use serde_json::json;
9
10use crate::errors::{LeashError, Result};
11use crate::transport::Transport;
12
13use super::types::{
14    LinearComment, LinearCreateIssueInput, LinearIssue, LinearListIssuesFilter,
15    LinearListIssuesResult, LinearListProjectsFilter, LinearProject, LinearTeam,
16    LinearUpdateIssuePatch,
17};
18
19const PROVIDER: &str = "linear";
20
21/// Typed Linear provider client.
22#[derive(Debug, Clone)]
23pub struct Linear {
24    transport: Transport,
25}
26
27impl Linear {
28    pub(crate) fn new(transport: Transport) -> Self {
29        Self { transport }
30    }
31
32    /// List issues, optionally filtered. Tolerates envelope `{issues}` and bare `[]` responses.
33    pub async fn list_issues(
34        &self,
35        filter: LinearListIssuesFilter,
36    ) -> Result<LinearListIssuesResult> {
37        let raw = self
38            .transport
39            .integrations_call(PROVIDER, "list_issues", &filter)
40            .await?;
41        if raw.is_null() {
42            return Ok(LinearListIssuesResult::default());
43        }
44        if raw.is_array() {
45            let issues: Vec<LinearIssue> =
46                serde_json::from_value(raw).map_err(|e| LeashError::MalformedResponse {
47                    message: format!("Linear list_issues: failed to decode array: {e}"),
48                })?;
49            return Ok(LinearListIssuesResult {
50                issues,
51                cursor: None,
52            });
53        }
54        serde_json::from_value(raw).map_err(|e| LeashError::MalformedResponse {
55            message: format!("Linear list_issues: failed to decode envelope: {e}"),
56        })
57    }
58
59    /// Get a single issue by Linear UUID.
60    pub async fn get_issue(&self, id: &str) -> Result<LinearIssue> {
61        let raw = self
62            .transport
63            .integrations_call(PROVIDER, "get_issue", &json!({ "id": id }))
64            .await?;
65        decode(raw, "get_issue")
66    }
67
68    /// Create a new Linear issue.
69    pub async fn create_issue(&self, input: LinearCreateIssueInput) -> Result<LinearIssue> {
70        let raw = self
71            .transport
72            .integrations_call(PROVIDER, "create_issue", &input)
73            .await?;
74        decode(raw, "create_issue")
75    }
76
77    /// Patch an issue. Only fields set on the patch are forwarded (`Option<T>` fields skipped when `None`).
78    pub async fn update_issue(
79        &self,
80        id: &str,
81        patch: LinearUpdateIssuePatch,
82    ) -> Result<LinearIssue> {
83        #[derive(Serialize)]
84        struct Body<'a> {
85            id: &'a str,
86            #[serde(flatten)]
87            patch: LinearUpdateIssuePatch,
88        }
89        let raw = self
90            .transport
91            .integrations_call(PROVIDER, "update_issue", &Body { id, patch })
92            .await?;
93        decode(raw, "update_issue")
94    }
95
96    /// Post a comment to an existing issue.
97    pub async fn add_comment(&self, issue_id: &str, body: &str) -> Result<LinearComment> {
98        let raw = self
99            .transport
100            .integrations_call(
101                PROVIDER,
102                "add_comment",
103                &json!({ "issueId": issue_id, "body": body }),
104            )
105            .await?;
106        decode(raw, "add_comment")
107    }
108
109    /// List all teams visible to the authenticated user.
110    pub async fn list_teams(&self) -> Result<Vec<LinearTeam>> {
111        let raw = self
112            .transport
113            .integrations_call(PROVIDER, "list_teams", &json!({}))
114            .await?;
115        if raw.is_null() {
116            return Ok(vec![]);
117        }
118        if raw.is_array() {
119            return serde_json::from_value(raw).map_err(|e| LeashError::MalformedResponse {
120                message: format!("Linear list_teams: failed to decode array: {e}"),
121            });
122        }
123        let envelope: serde_json::Value = raw;
124        if let Some(teams) = envelope.get("teams").cloned() {
125            return serde_json::from_value(teams).map_err(|e| LeashError::MalformedResponse {
126                message: format!("Linear list_teams: failed to decode teams: {e}"),
127            });
128        }
129        Ok(vec![])
130    }
131
132    /// List Linear projects, optionally filtered by team.
133    pub async fn list_projects(
134        &self,
135        filter: LinearListProjectsFilter,
136    ) -> Result<Vec<LinearProject>> {
137        let raw = self
138            .transport
139            .integrations_call(PROVIDER, "list_projects", &filter)
140            .await?;
141        if raw.is_null() {
142            return Ok(vec![]);
143        }
144        if raw.is_array() {
145            return serde_json::from_value(raw).map_err(|e| LeashError::MalformedResponse {
146                message: format!("Linear list_projects: failed to decode array: {e}"),
147            });
148        }
149        let envelope: serde_json::Value = raw;
150        if let Some(projects) = envelope.get("projects").cloned() {
151            return serde_json::from_value(projects).map_err(|e| LeashError::MalformedResponse {
152                message: format!("Linear list_projects: failed to decode projects: {e}"),
153            });
154        }
155        Ok(vec![])
156    }
157}
158
159fn decode<T: serde::de::DeserializeOwned + Default>(
160    raw: serde_json::Value,
161    action: &str,
162) -> Result<T> {
163    if raw.is_null() {
164        return Ok(T::default());
165    }
166    serde_json::from_value(raw).map_err(|e| LeashError::MalformedResponse {
167        message: format!("Linear {action}: failed to decode: {e}"),
168    })
169}