lib_client_linear/
client.rs

1use reqwest::header::HeaderMap;
2use serde::de::DeserializeOwned;
3use std::sync::Arc;
4use tracing::{debug, warn};
5
6use crate::auth::AuthStrategy;
7use crate::error::{Error, Result};
8use crate::graphql::{GraphQLRequest, GraphQLResponse};
9use crate::types::*;
10
11const DEFAULT_BASE_URL: &str = "https://api.linear.app/graphql";
12
13pub struct ClientBuilder<A> {
14    auth: A,
15    base_url: String,
16}
17
18impl ClientBuilder<()> {
19    pub fn new() -> Self {
20        Self {
21            auth: (),
22            base_url: DEFAULT_BASE_URL.to_string(),
23        }
24    }
25
26    pub fn auth<S: AuthStrategy + 'static>(self, auth: S) -> ClientBuilder<S> {
27        ClientBuilder {
28            auth,
29            base_url: self.base_url,
30        }
31    }
32}
33
34impl Default for ClientBuilder<()> {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl<A: AuthStrategy + 'static> ClientBuilder<A> {
41    pub fn base_url(mut self, url: impl Into<String>) -> Self {
42        self.base_url = url.into();
43        self
44    }
45
46    pub fn build(self) -> Client {
47        Client {
48            http: reqwest::Client::new(),
49            auth: Arc::new(self.auth),
50            base_url: self.base_url,
51        }
52    }
53}
54
55#[derive(Clone)]
56pub struct Client {
57    http: reqwest::Client,
58    auth: Arc<dyn AuthStrategy>,
59    base_url: String,
60}
61
62impl Client {
63    pub fn builder() -> ClientBuilder<()> {
64        ClientBuilder::new()
65    }
66
67    /// Execute a GraphQL query.
68    pub async fn query<T: DeserializeOwned>(&self, request: GraphQLRequest) -> Result<T> {
69        debug!("Linear GraphQL query");
70
71        let mut headers = HeaderMap::new();
72        self.auth.apply(&mut headers).await?;
73        headers.insert("Content-Type", "application/json".parse().unwrap());
74
75        let response = self
76            .http
77            .post(&self.base_url)
78            .headers(headers)
79            .json(&request)
80            .send()
81            .await?;
82
83        let status = response.status();
84
85        if status.is_success() {
86            let result: GraphQLResponse<T> = response.json().await?;
87
88            if let Some(errors) = result.errors {
89                let messages: Vec<String> = errors.into_iter().map(|e| e.message).collect();
90                return Err(Error::GraphQL(messages.join("; ")));
91            }
92
93            result.data.ok_or_else(|| Error::GraphQL("No data returned".to_string()))
94        } else {
95            let status_code = status.as_u16();
96            let body = response.text().await.unwrap_or_default();
97            warn!("Linear API error ({}): {}", status_code, body);
98
99            match status_code {
100                401 => Err(Error::Unauthorized),
101                429 => Err(Error::RateLimited { retry_after: 60 }),
102                _ => Err(Error::Api {
103                    status: status_code,
104                    message: body,
105                }),
106            }
107        }
108    }
109
110    /// Get an issue by ID.
111    pub async fn get_issue(&self, id: &str) -> Result<Issue> {
112        let query = r#"
113            query GetIssue($id: String!) {
114                issue(id: $id) {
115                    id identifier title description priority url
116                    createdAt updatedAt completedAt
117                    state { id name color type }
118                    assignee { id name email displayName avatarUrl }
119                    project { id name description state url createdAt updatedAt }
120                    team { id name key description }
121                    labels { nodes { id name color } }
122                }
123            }
124        "#;
125
126        #[derive(serde::Deserialize)]
127        struct Response {
128            issue: Issue,
129        }
130
131        let request = GraphQLRequest::new(query)
132            .with_variables(serde_json::json!({ "id": id }));
133
134        let response: Response = self.query(request).await?;
135        Ok(response.issue)
136    }
137
138    /// Create an issue.
139    pub async fn create_issue(&self, input: IssueCreateInput) -> Result<Issue> {
140        let query = r#"
141            mutation CreateIssue($input: IssueCreateInput!) {
142                issueCreate(input: $input) {
143                    success
144                    issue {
145                        id identifier title description priority url
146                        createdAt updatedAt completedAt
147                        state { id name color type }
148                        assignee { id name email displayName avatarUrl }
149                        team { id name key description }
150                    }
151                }
152            }
153        "#;
154
155        #[derive(serde::Deserialize)]
156        #[serde(rename_all = "camelCase")]
157        struct Response {
158            issue_create: IssuePayload,
159        }
160
161        let request = GraphQLRequest::new(query)
162            .with_variables(serde_json::json!({ "input": input }));
163
164        let response: Response = self.query(request).await?;
165        response
166            .issue_create
167            .issue
168            .ok_or_else(|| Error::GraphQL("Issue creation failed".to_string()))
169    }
170
171    /// Update an issue.
172    pub async fn update_issue(&self, id: &str, input: IssueUpdateInput) -> Result<Issue> {
173        let query = r#"
174            mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
175                issueUpdate(id: $id, input: $input) {
176                    success
177                    issue {
178                        id identifier title description priority url
179                        createdAt updatedAt completedAt
180                        state { id name color type }
181                        assignee { id name email displayName avatarUrl }
182                        team { id name key description }
183                    }
184                }
185            }
186        "#;
187
188        #[derive(serde::Deserialize)]
189        #[serde(rename_all = "camelCase")]
190        struct Response {
191            issue_update: IssuePayload,
192        }
193
194        let request = GraphQLRequest::new(query)
195            .with_variables(serde_json::json!({ "id": id, "input": input }));
196
197        let response: Response = self.query(request).await?;
198        response
199            .issue_update
200            .issue
201            .ok_or_else(|| Error::GraphQL("Issue update failed".to_string()))
202    }
203
204    /// List issues with optional filter.
205    pub async fn list_issues(
206        &self,
207        filter: Option<IssueFilter>,
208        first: Option<i32>,
209        after: Option<&str>,
210    ) -> Result<IssueConnection> {
211        let query = r#"
212            query ListIssues($filter: IssueFilter, $first: Int, $after: String) {
213                issues(filter: $filter, first: $first, after: $after) {
214                    nodes {
215                        id identifier title description priority url
216                        createdAt updatedAt completedAt
217                        state { id name color type }
218                        assignee { id name email displayName avatarUrl }
219                        team { id name key description }
220                    }
221                    pageInfo {
222                        hasNextPage hasPreviousPage startCursor endCursor
223                    }
224                }
225            }
226        "#;
227
228        #[derive(serde::Deserialize)]
229        struct Response {
230            issues: IssueConnection,
231        }
232
233        let variables = serde_json::json!({
234            "filter": filter,
235            "first": first.unwrap_or(50),
236            "after": after,
237        });
238
239        let request = GraphQLRequest::new(query).with_variables(variables);
240        let response: Response = self.query(request).await?;
241        Ok(response.issues)
242    }
243
244    /// List all teams.
245    pub async fn list_teams(&self) -> Result<Vec<Team>> {
246        let query = r#"
247            query ListTeams {
248                teams {
249                    nodes { id name key description }
250                }
251            }
252        "#;
253
254        #[derive(serde::Deserialize)]
255        struct TeamsConnection {
256            nodes: Vec<Team>,
257        }
258
259        #[derive(serde::Deserialize)]
260        struct Response {
261            teams: TeamsConnection,
262        }
263
264        let request = GraphQLRequest::new(query);
265        let response: Response = self.query(request).await?;
266        Ok(response.teams.nodes)
267    }
268
269    /// List all projects.
270    pub async fn list_projects(&self) -> Result<Vec<Project>> {
271        let query = r#"
272            query ListProjects {
273                projects {
274                    nodes { id name description state url createdAt updatedAt }
275                }
276            }
277        "#;
278
279        #[derive(serde::Deserialize)]
280        struct ProjectsConnection {
281            nodes: Vec<Project>,
282        }
283
284        #[derive(serde::Deserialize)]
285        struct Response {
286            projects: ProjectsConnection,
287        }
288
289        let request = GraphQLRequest::new(query);
290        let response: Response = self.query(request).await?;
291        Ok(response.projects.nodes)
292    }
293
294    /// Get the authenticated user.
295    pub async fn viewer(&self) -> Result<User> {
296        let query = r#"
297            query Viewer {
298                viewer { id name email displayName avatarUrl }
299            }
300        "#;
301
302        #[derive(serde::Deserialize)]
303        struct Response {
304            viewer: User,
305        }
306
307        let request = GraphQLRequest::new(query);
308        let response: Response = self.query(request).await?;
309        Ok(response.viewer)
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::auth::ApiKeyAuth;
317
318    #[test]
319    fn test_builder() {
320        let client = Client::builder()
321            .auth(ApiKeyAuth::new("lin_api_key"))
322            .build();
323        assert_eq!(client.base_url, DEFAULT_BASE_URL);
324    }
325}