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, }
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 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 if let Some(label_id) = self.find_label(label_name).await? {
403 return Ok(label_id);
404 }
405
406 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())); #[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}