linear_motion/clients/
linear.rs

1use crate::{Error, Result};
2use chrono::{DateTime, Utc};
3use reqwest::{
4    header::{HeaderMap, HeaderValue, AUTHORIZATION},
5    Client,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use tracing::{debug, error, info, warn};
11
12#[derive(Debug, Clone)]
13pub struct LinearClient {
14    client: Client,
15    api_key: String,
16    base_url: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct LinearIssue {
21    pub id: String,
22    pub identifier: String,
23    pub title: String,
24    pub description: Option<String>,
25    pub state: WorkflowState,
26    pub assignee: Option<User>,
27    pub team: Team,
28    pub project: Option<Project>,
29    pub priority: Option<u32>,
30    pub estimate: Option<f64>,
31    pub created_at: DateTime<Utc>,
32    pub updated_at: DateTime<Utc>,
33    pub due_date: Option<String>,
34    pub completed_at: Option<DateTime<Utc>>,
35    pub labels: Vec<IssueLabel>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct WorkflowState {
40    pub id: String,
41    pub name: String,
42    pub state_type: String, // "backlog", "unstarted", "started", "completed", "canceled"
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct User {
47    pub id: String,
48    pub name: String,
49    pub email: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Team {
54    pub id: String,
55    pub name: String,
56    pub key: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Project {
61    pub id: String,
62    pub name: String,
63    pub description: Option<String>,
64    pub state: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct IssueLabel {
69    pub id: Option<String>,
70    pub name: String,
71    pub color: Option<String>,
72}
73
74#[derive(Debug, Serialize)]
75struct GraphQLRequest {
76    query: String,
77    variables: Option<Value>,
78}
79
80#[derive(Debug, Deserialize)]
81struct GraphQLResponse<T> {
82    data: Option<T>,
83    errors: Option<Vec<GraphQLError>>,
84}
85
86#[derive(Debug, Deserialize)]
87struct GraphQLError {
88    message: String,
89    locations: Option<Vec<GraphQLLocation>>,
90    path: Option<Vec<Value>>,
91}
92
93#[derive(Debug, Deserialize)]
94struct GraphQLLocation {
95    line: u32,
96    column: u32,
97}
98
99#[derive(Deserialize)]
100struct LabelsConnection {
101    nodes: Vec<IssueLabel>,
102}
103
104impl LinearClient {
105    pub fn new(api_key: String) -> Result<Self> {
106        let mut headers = HeaderMap::new();
107        headers.insert(AUTHORIZATION, HeaderValue::from_str(&api_key)?);
108
109        let client = Client::builder().default_headers(headers).build()?;
110
111        Ok(Self {
112            client,
113            api_key,
114            base_url: "https://api.linear.app/graphql".to_string(),
115        })
116    }
117
118    async fn execute_query<T: for<'de> Deserialize<'de>>(
119        &self,
120        query: &str,
121        variables: Option<Value>,
122    ) -> Result<T> {
123        let request = GraphQLRequest {
124            query: query.to_string(),
125            variables,
126        };
127
128        debug!("Executing Linear GraphQL query: {}", query);
129
130        let response = self
131            .client
132            .post(&self.base_url)
133            .json(&request)
134            .send()
135            .await?;
136
137        if !response.status().is_success() {
138            let status = response.status();
139            let text = response.text().await?;
140            error!("Linear API error: {} - {}", status, text);
141            return Err(Error::LinearApi {
142                message: format!("HTTP {}: {}", status, text),
143            });
144        }
145
146        let response_text = response.text().await?;
147        debug!("Linear GraphQL response: {}", response_text);
148        let response_json: GraphQLResponse<T> = match serde_json::from_str(&response_text) {
149            Ok(json) => json,
150            Err(e) => {
151                error!("Failed to parse Linear GraphQL response: {}", e);
152                error!("Full response text: {}", response_text);
153                return Err(Error::Json(e));
154            }
155        };
156
157        if let Some(errors) = response_json.errors {
158            let error_messages: Vec<String> = errors.into_iter().map(|e| e.message).collect();
159            error!("Linear GraphQL errors: {:?}", error_messages);
160            return Err(Error::LinearApi {
161                message: error_messages.join(", "),
162            });
163        }
164
165        response_json.data.ok_or_else(|| Error::LinearApi {
166            message: "No data in response".to_string(),
167        })
168    }
169
170    pub async fn get_viewer(&self) -> Result<User> {
171        let query = r#"
172            query {
173                viewer {
174                    id
175                    name
176                    email
177                }
178            }
179        "#;
180
181        #[derive(Deserialize)]
182        struct ViewerResponse {
183            viewer: User,
184        }
185
186        let response: ViewerResponse = self.execute_query(query, None).await?;
187        debug!(
188            "connected to Linear as: {} ({})",
189            response.viewer.name, response.viewer.email
190        );
191
192        Ok(response.viewer)
193    }
194
195    pub async fn get_assigned_issues(
196        &self,
197        project_ids: Option<Vec<String>>,
198    ) -> Result<Vec<LinearIssue>> {
199        let viewer = self.get_viewer().await?;
200
201        let (query, variables) = if let Some(project_ids) = project_ids {
202            let query = r#"
203                query GetAssignedIssues($assigneeId: ID!, $projectIds: [String!]) {
204                    issues(
205                        filter: {
206                            assignee: { id: { eq: $assigneeId } }
207                            project: { id: { in: $projectIds } }
208                            state: { type: { nin: ["completed", "canceled"] } }
209                        }
210                        first: 100
211                    ) {"#;
212
213            let variables = Some(serde_json::json!({
214                "assigneeId": viewer.id,
215                "projectIds": project_ids
216            }));
217            (query, variables)
218        } else {
219            let query = r#"
220                query GetAssignedIssues($assigneeId: ID!) {
221                    issues(
222                        filter: {
223                            assignee: { id: { eq: $assigneeId } }
224                            state: { type: { nin: ["completed", "canceled"] } }
225                        }
226                        first: 100
227                    ) {"#;
228
229            let variables = Some(serde_json::json!({
230                "assigneeId": viewer.id
231            }));
232            (query, variables)
233        };
234
235        let full_query = format!(
236            "{}{}",
237            query,
238            r#"
239                        nodes {
240                            id
241                            identifier
242                            title
243                            description
244                            state {
245                                id
246                                name
247                                type
248                            }
249                            assignee {
250                                id
251                                name
252                                email
253                            }
254                            team {
255                                id
256                                name
257                                key
258                            }
259                            project {
260                                id
261                                name
262                                description
263                                state
264                            }
265                            priority
266                            estimate
267                            createdAt
268                            updatedAt
269                            dueDate
270                            completedAt
271                            labels {
272                                nodes {
273                                    id
274                                    name
275                                    color
276                                }
277                            }
278                        }
279                    }
280                }
281            "#
282        );
283
284        #[derive(Deserialize)]
285        struct IssuesResponse {
286            issues: IssuesConnection,
287        }
288
289        #[derive(Deserialize)]
290        struct IssuesConnection {
291            nodes: Vec<LinearIssueRaw>,
292        }
293
294        #[derive(Deserialize)]
295        struct LinearIssueRaw {
296            id: String,
297            identifier: String,
298            title: String,
299            description: Option<String>,
300            state: WorkflowStateRaw,
301            assignee: Option<User>,
302            team: Team,
303            project: Option<Project>,
304            priority: Option<u32>,
305            estimate: Option<f64>,
306            #[serde(rename = "createdAt")]
307            created_at: DateTime<Utc>,
308            #[serde(rename = "updatedAt")]
309            updated_at: DateTime<Utc>,
310            #[serde(rename = "dueDate")]
311            due_date: Option<String>,
312            #[serde(rename = "completedAt")]
313            completed_at: Option<DateTime<Utc>>,
314            labels: LabelsConnection,
315        }
316
317        #[derive(Deserialize)]
318        struct WorkflowStateRaw {
319            id: String,
320            name: String,
321            #[serde(rename = "type")]
322            state_type: String,
323        }
324
325        let response: IssuesResponse = self.execute_query(&full_query, variables).await?;
326
327        let issues: Vec<LinearIssue> = response
328            .issues
329            .nodes
330            .into_iter()
331            .map(|raw| LinearIssue {
332                id: raw.id,
333                identifier: raw.identifier,
334                title: raw.title,
335                description: raw.description,
336                state: WorkflowState {
337                    id: raw.state.id,
338                    name: raw.state.name,
339                    state_type: raw.state.state_type,
340                },
341                assignee: raw.assignee,
342                team: raw.team,
343                project: raw.project,
344                priority: raw.priority,
345                estimate: raw.estimate,
346                created_at: raw.created_at,
347                updated_at: raw.updated_at,
348                due_date: raw.due_date,
349                completed_at: raw.completed_at,
350                labels: raw.labels.nodes,
351            })
352            .collect();
353
354        debug!("Found {} assigned issues", issues.len());
355        Ok(issues)
356    }
357
358    pub async fn add_label_to_issue(&self, issue_id: &str, label_name: &str) -> Result<()> {
359        // First, we need to find the label ID
360        let label_id = self.get_or_create_label(label_name).await?;
361
362        let query = r#"
363            mutation AddLabelToIssue($issueId: String!, $labelId: String!) {
364                issueAddLabel(id: $issueId, labelId: $labelId) {
365                    success
366                }
367            }
368        "#;
369
370        let mut variables = HashMap::new();
371        variables.insert("issueId", Value::String(issue_id.to_string()));
372        variables.insert("labelId", Value::String(label_id));
373
374        #[derive(Deserialize)]
375        struct AddLabelResponse {
376            #[serde(rename = "issueAddLabel")]
377            issue_add_label: MutationResponse,
378        }
379
380        #[derive(Deserialize)]
381        struct MutationResponse {
382            success: bool,
383        }
384
385        let response: AddLabelResponse = self
386            .execute_query(query, Some(serde_json::to_value(variables)?))
387            .await?;
388
389        if !response.issue_add_label.success {
390            warn!("Failed to add label '{}' to issue {}", label_name, issue_id);
391            return Err(Error::LinearApi {
392                message: format!("Failed to add label '{}' to issue", label_name),
393            });
394        }
395
396        debug!("Added label '{}' to issue {}", label_name, issue_id);
397        Ok(())
398    }
399
400    pub async fn get_or_create_label(&self, label_name: &str) -> Result<String> {
401        // First try to find existing label
402        if let Some(label_id) = self.find_label(label_name).await? {
403            return Ok(label_id);
404        }
405
406        // If not found, create it
407        self.create_label(label_name).await
408    }
409
410    async fn find_label(&self, label_name: &str) -> Result<Option<String>> {
411        let query = r#"
412            query FindLabel($name: String!) {
413                issueLabels(filter: { name: { eq: $name } }, first: 1) {
414                    nodes {
415                        id
416                        name
417                    }
418                }
419            }
420        "#;
421
422        let mut variables = HashMap::new();
423        variables.insert("name", Value::String(label_name.to_string()));
424
425        #[derive(Deserialize)]
426        struct FindLabelResponse {
427            #[serde(rename = "issueLabels")]
428            issue_labels: LabelsConnection,
429        }
430
431        let response: FindLabelResponse = self
432            .execute_query(query, Some(serde_json::to_value(variables)?))
433            .await?;
434
435        Ok(response
436            .issue_labels
437            .nodes
438            .first()
439            .map(|label| label.id.as_ref().expect("should be here").to_owned()))
440    }
441
442    async fn create_label(&self, label_name: &str) -> Result<String> {
443        let query = r#"
444            mutation CreateLabel($name: String!, $color: String!) {
445                issueLabelCreate(input: {
446                    name: $name,
447                    color: $color
448                }) {
449                    success
450                    issueLabel {
451                        id
452                        name
453                    }
454                }
455            }
456        "#;
457
458        let mut variables = HashMap::new();
459        variables.insert("name", Value::String(label_name.to_string()));
460        variables.insert("color", Value::String("#3B82F6".to_string())); // Default blue color
461
462        #[derive(Deserialize)]
463        struct CreateLabelResponse {
464            #[serde(rename = "issueLabelCreate")]
465            issue_label_create: CreateLabelMutation,
466        }
467
468        #[derive(Deserialize)]
469        struct CreateLabelMutation {
470            success: bool,
471            #[serde(rename = "issueLabel")]
472            issue_label: Option<IssueLabel>,
473        }
474
475        let response: CreateLabelResponse = self
476            .execute_query(query, Some(serde_json::to_value(variables)?))
477            .await?;
478
479        if !response.issue_label_create.success {
480            return Err(Error::LinearApi {
481                message: format!("Failed to create label '{}'", label_name),
482            });
483        }
484
485        let label = response
486            .issue_label_create
487            .issue_label
488            .ok_or_else(|| Error::LinearApi {
489                message: "Label creation succeeded but no label returned".to_string(),
490            })?;
491
492        debug!("Created label '{}' with ID: {:?}", label.name, label.id);
493        Ok(label.id.expect("id should be here"))
494    }
495
496    pub async fn check_issue_has_label(&self, issue_id: &str, label_name: &str) -> Result<bool> {
497        let query = r#"
498            query CheckIssueLabel($issueId: String!) {
499                issue(id: $issueId) {
500                    labels {
501                        nodes {
502                            name
503                        }
504                    }
505                }
506            }
507        "#;
508
509        let mut variables = HashMap::new();
510        variables.insert("issueId", Value::String(issue_id.to_string()));
511
512        #[derive(Deserialize)]
513        struct CheckLabelResponse {
514            issue: IssueLabelsOnly,
515        }
516
517        #[derive(Deserialize)]
518        struct IssueLabelsOnly {
519            labels: LabelsConnection,
520        }
521
522        let response: CheckLabelResponse = self
523            .execute_query(query, Some(serde_json::to_value(variables)?))
524            .await?;
525
526        let has_label = response
527            .issue
528            .labels
529            .nodes
530            .iter()
531            .any(|label| label.name == label_name);
532
533        debug!(
534            "Issue {} has label '{}': {}",
535            issue_id, label_name, has_label
536        );
537        Ok(has_label)
538    }
539}