lib_client_linear/
client.rs1use 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 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 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 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 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 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 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 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 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}