1use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use serde_json::Value;
6use symphony_core::{BlockerRef, Issue};
7
8use crate::{TrackerClient, TrackerError};
9
10const PAGE_SIZE: u32 = 50;
11
12pub struct LinearClient {
14 endpoint: String,
15 api_key: String,
16 project_slug: String,
17 #[allow(dead_code)]
18 active_states: Vec<String>,
19 http: reqwest::Client,
20}
21
22impl LinearClient {
23 pub fn new(
24 endpoint: String,
25 api_key: String,
26 project_slug: String,
27 active_states: Vec<String>,
28 ) -> Self {
29 Self {
30 endpoint,
31 api_key,
32 project_slug,
33 active_states,
34 http: reqwest::Client::builder()
35 .timeout(std::time::Duration::from_millis(30_000))
36 .build()
37 .expect("failed to build HTTP client"),
38 }
39 }
40
41 pub fn endpoint(&self) -> &str {
43 &self.endpoint
44 }
45
46 pub fn api_key(&self) -> &str {
48 &self.api_key
49 }
50
51 pub async fn graphql_query(
53 &self,
54 query: &str,
55 variables: Value,
56 ) -> Result<Value, TrackerError> {
57 let body = serde_json::json!({
58 "query": query,
59 "variables": variables,
60 });
61
62 let response = self
63 .http
64 .post(&self.endpoint)
65 .header("Authorization", &self.api_key)
66 .header("Content-Type", "application/json")
67 .json(&body)
68 .send()
69 .await
70 .map_err(|e| TrackerError::ApiRequest(e.to_string()))?;
71
72 let status = response.status().as_u16();
73 if !(200..300).contains(&status) {
74 let body_text = response
75 .text()
76 .await
77 .unwrap_or_else(|_| "<unreadable>".into());
78 return Err(TrackerError::ApiStatus {
79 status,
80 body: body_text,
81 });
82 }
83
84 let json: Value = response
85 .json()
86 .await
87 .map_err(|e| TrackerError::UnknownPayload(e.to_string()))?;
88
89 if let Some(errors) = json.get("errors")
91 && let Some(arr) = errors.as_array()
92 && !arr.is_empty()
93 {
94 return Err(TrackerError::GraphqlErrors(errors.to_string()));
95 }
96
97 json.get("data")
98 .cloned()
99 .ok_or_else(|| TrackerError::UnknownPayload("missing 'data' in response".into()))
100 }
101
102 async fn fetch_paginated_issues(
104 &self,
105 query: &str,
106 build_variables: impl Fn(Option<&str>) -> Value,
107 data_path: &[&str],
108 ) -> Result<Vec<Issue>, TrackerError> {
109 let mut all_issues = Vec::new();
110 let mut cursor: Option<String> = None;
111
112 loop {
113 let variables = build_variables(cursor.as_deref());
114 let data = self.graphql_query(query, variables).await?;
115
116 let mut node = &data;
118 for &key in data_path {
119 node = node.get(key).ok_or_else(|| {
120 TrackerError::UnknownPayload(format!("missing key '{key}' in response"))
121 })?;
122 }
123
124 let nodes = node
126 .get("nodes")
127 .and_then(|n| n.as_array())
128 .ok_or_else(|| {
129 TrackerError::UnknownPayload("missing 'nodes' array in response".into())
130 })?;
131
132 for node_val in nodes {
133 if let Some(issue) = normalize_issue(node_val) {
134 all_issues.push(issue);
135 }
136 }
137
138 let page_info = node.get("pageInfo");
140 let has_next = page_info
141 .and_then(|p| p.get("hasNextPage"))
142 .and_then(|v| v.as_bool())
143 .unwrap_or(false);
144
145 if !has_next {
146 break;
147 }
148
149 let end_cursor = page_info
150 .and_then(|p| p.get("endCursor"))
151 .and_then(|v| v.as_str());
152
153 match end_cursor {
154 Some(c) => cursor = Some(c.to_string()),
155 None => return Err(TrackerError::MissingEndCursor),
156 }
157 }
158
159 Ok(all_issues)
160 }
161}
162
163const CANDIDATE_ISSUES_QUERY: &str = r#"
165query CandidateIssues($projectSlug: String!, $first: Int!, $after: String) {
166 issues(
167 filter: {
168 project: { slugId: { eq: $projectSlug } }
169 }
170 first: $first
171 after: $after
172 orderBy: createdAt
173 ) {
174 nodes {
175 id
176 identifier
177 title
178 description
179 priority
180 state { name }
181 branchName
182 url
183 labels { nodes { name } }
184 relations { nodes { type relatedIssue { id identifier state { name } } } }
185 inverseRelations { nodes { type issue { id identifier state { name } } } }
186 createdAt
187 updatedAt
188 }
189 pageInfo {
190 hasNextPage
191 endCursor
192 }
193 }
194}
195"#;
196
197const ISSUES_BY_STATES_QUERY: &str = r#"
199query IssuesByStates($projectSlug: String!, $states: [String!]!, $first: Int!, $after: String) {
200 issues(
201 filter: {
202 project: { slugId: { eq: $projectSlug } }
203 state: { name: { in: $states } }
204 }
205 first: $first
206 after: $after
207 ) {
208 nodes {
209 id
210 identifier
211 title
212 description
213 priority
214 state { name }
215 branchName
216 url
217 labels { nodes { name } }
218 relations { nodes { type relatedIssue { id identifier state { name } } } }
219 inverseRelations { nodes { type issue { id identifier state { name } } } }
220 createdAt
221 updatedAt
222 }
223 pageInfo {
224 hasNextPage
225 endCursor
226 }
227 }
228}
229"#;
230
231const ISSUE_STATES_BY_IDS_QUERY: &str = r#"
233query IssueStatesByIds($ids: [ID!], $first: Int!) {
234 issues(
235 filter: { id: { in: $ids } }
236 first: $first
237 ) {
238 nodes {
239 id
240 identifier
241 title
242 state { name }
243 priority
244 createdAt
245 updatedAt
246 }
247 }
248}
249"#;
250
251#[async_trait]
252impl TrackerClient for LinearClient {
253 async fn fetch_candidate_issues(&self) -> Result<Vec<Issue>, TrackerError> {
254 self.fetch_paginated_issues(
255 CANDIDATE_ISSUES_QUERY,
256 |cursor| {
257 let mut vars = serde_json::json!({
258 "projectSlug": self.project_slug,
259 "first": PAGE_SIZE,
260 });
261 if let Some(c) = cursor {
262 vars.as_object_mut()
263 .unwrap()
264 .insert("after".into(), Value::String(c.into()));
265 }
266 vars
267 },
268 &["issues"],
269 )
270 .await
271 }
272
273 async fn fetch_issues_by_states(&self, states: &[String]) -> Result<Vec<Issue>, TrackerError> {
274 if states.is_empty() {
275 return Ok(vec![]);
276 }
277
278 self.fetch_paginated_issues(
279 ISSUES_BY_STATES_QUERY,
280 |cursor| {
281 let mut vars = serde_json::json!({
282 "projectSlug": self.project_slug,
283 "states": states,
284 "first": PAGE_SIZE,
285 });
286 if let Some(c) = cursor {
287 vars.as_object_mut()
288 .unwrap()
289 .insert("after".into(), Value::String(c.into()));
290 }
291 vars
292 },
293 &["issues"],
294 )
295 .await
296 }
297
298 async fn fetch_issue_states_by_ids(
299 &self,
300 issue_ids: &[String],
301 ) -> Result<Vec<Issue>, TrackerError> {
302 if issue_ids.is_empty() {
303 return Ok(vec![]);
304 }
305
306 let variables = serde_json::json!({
307 "ids": issue_ids,
308 "first": issue_ids.len(),
309 });
310 let data = self.graphql_query(ISSUE_STATES_BY_IDS_QUERY, variables).await?;
311
312 let nodes = data
313 .get("issues")
314 .and_then(|i| i.get("nodes"))
315 .and_then(|n| n.as_array())
316 .ok_or_else(|| {
317 TrackerError::UnknownPayload("missing 'issues.nodes' in response".into())
318 })?;
319
320 let mut issues = Vec::new();
321 for node_val in nodes {
322 if node_val.is_null() {
323 continue;
324 }
325 if let Some(issue) = normalize_issue_minimal(node_val) {
326 issues.push(issue);
327 }
328 }
329 Ok(issues)
330 }
331}
332
333fn normalize_issue(v: &Value) -> Option<Issue> {
335 let id = v.get("id")?.as_str()?.to_string();
336 let identifier = v.get("identifier")?.as_str()?.to_string();
337 let title = v.get("title")?.as_str()?.to_string();
338 let description = v.get("description").and_then(|d| d.as_str()).map(String::from);
339
340 let priority = v
342 .get("priority")
343 .and_then(|p| p.as_i64())
344 .map(|p| p as i32);
345
346 let state = v
347 .get("state")
348 .and_then(|s| s.get("name"))
349 .and_then(|n| n.as_str())
350 .unwrap_or("")
351 .to_string();
352
353 let branch_name = v
354 .get("branchName")
355 .and_then(|b| b.as_str())
356 .map(String::from);
357 let url = v.get("url").and_then(|u| u.as_str()).map(String::from);
358
359 let labels = v
361 .get("labels")
362 .and_then(|l| l.get("nodes"))
363 .and_then(|n| n.as_array())
364 .map(|arr| {
365 arr.iter()
366 .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
367 .map(|s| s.to_lowercase())
368 .collect()
369 })
370 .unwrap_or_default();
371
372 let blocked_by = extract_blockers(v);
374
375 let created_at = v
377 .get("createdAt")
378 .and_then(|t| t.as_str())
379 .and_then(|s| s.parse::<DateTime<Utc>>().ok());
380 let updated_at = v
381 .get("updatedAt")
382 .and_then(|t| t.as_str())
383 .and_then(|s| s.parse::<DateTime<Utc>>().ok());
384
385 Some(Issue {
386 id,
387 identifier,
388 title,
389 description,
390 priority,
391 state,
392 branch_name,
393 url,
394 labels,
395 blocked_by,
396 created_at,
397 updated_at,
398 })
399}
400
401fn normalize_issue_minimal(v: &Value) -> Option<Issue> {
403 let id = v.get("id")?.as_str()?.to_string();
404 let identifier = v.get("identifier")?.as_str()?.to_string();
405 let title = v
406 .get("title")
407 .and_then(|t| t.as_str())
408 .unwrap_or("")
409 .to_string();
410
411 let state = v
412 .get("state")
413 .and_then(|s| s.get("name"))
414 .and_then(|n| n.as_str())
415 .unwrap_or("")
416 .to_string();
417
418 let priority = v
419 .get("priority")
420 .and_then(|p| p.as_i64())
421 .map(|p| p as i32);
422
423 let created_at = v
424 .get("createdAt")
425 .and_then(|t| t.as_str())
426 .and_then(|s| s.parse::<DateTime<Utc>>().ok());
427 let updated_at = v
428 .get("updatedAt")
429 .and_then(|t| t.as_str())
430 .and_then(|s| s.parse::<DateTime<Utc>>().ok());
431
432 Some(Issue {
433 id,
434 identifier,
435 title,
436 description: None,
437 priority,
438 state,
439 branch_name: None,
440 url: None,
441 labels: vec![],
442 blocked_by: vec![],
443 created_at,
444 updated_at,
445 })
446}
447
448fn extract_blockers(v: &Value) -> Vec<BlockerRef> {
450 let mut blockers = Vec::new();
451
452 if let Some(inv_nodes) = v
454 .get("inverseRelations")
455 .and_then(|r| r.get("nodes"))
456 .and_then(|n| n.as_array())
457 {
458 for rel in inv_nodes {
459 let rel_type = rel
460 .get("type")
461 .and_then(|t| t.as_str())
462 .unwrap_or("");
463 if rel_type == "blocks"
464 && let Some(issue) = rel.get("issue")
465 {
466 blockers.push(BlockerRef {
467 id: issue.get("id").and_then(|i| i.as_str()).map(String::from),
468 identifier: issue
469 .get("identifier")
470 .and_then(|i| i.as_str())
471 .map(String::from),
472 state: issue
473 .get("state")
474 .and_then(|s| s.get("name"))
475 .and_then(|n| n.as_str())
476 .map(String::from),
477 });
478 }
479 }
480 }
481
482 blockers
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[tokio::test]
490 async fn empty_states_returns_empty() {
491 let client = LinearClient::new(
492 "https://api.linear.app/graphql".into(),
493 "test-key".into(),
494 "test-proj".into(),
495 vec!["Todo".into()],
496 );
497 let result = client.fetch_issues_by_states(&[]).await.unwrap();
498 assert!(result.is_empty());
499 }
500
501 #[tokio::test]
502 async fn empty_ids_returns_empty() {
503 let client = LinearClient::new(
504 "https://api.linear.app/graphql".into(),
505 "test-key".into(),
506 "test-proj".into(),
507 vec!["Todo".into()],
508 );
509 let result = client.fetch_issue_states_by_ids(&[]).await.unwrap();
510 assert!(result.is_empty());
511 }
512
513 #[test]
514 fn normalize_full_issue() {
515 let json = serde_json::json!({
516 "id": "issue-1",
517 "identifier": "PROJ-42",
518 "title": "Fix the bug",
519 "description": "Detailed description",
520 "priority": 2,
521 "state": { "name": "In Progress" },
522 "branchName": "fix/proj-42",
523 "url": "https://linear.app/team/PROJ-42",
524 "labels": { "nodes": [{ "name": "BUG" }, { "name": "Urgent" }] },
525 "relations": { "nodes": [] },
526 "inverseRelations": { "nodes": [
527 {
528 "type": "blocks",
529 "issue": { "id": "blocker-1", "identifier": "PROJ-10", "state": { "name": "Done" } }
530 }
531 ] },
532 "createdAt": "2025-01-15T10:00:00.000Z",
533 "updatedAt": "2025-01-16T10:00:00.000Z"
534 });
535
536 let issue = normalize_issue(&json).unwrap();
537 assert_eq!(issue.id, "issue-1");
538 assert_eq!(issue.identifier, "PROJ-42");
539 assert_eq!(issue.title, "Fix the bug");
540 assert_eq!(issue.description, Some("Detailed description".into()));
541 assert_eq!(issue.priority, Some(2));
542 assert_eq!(issue.state, "In Progress");
543 assert_eq!(issue.branch_name, Some("fix/proj-42".into()));
544 assert_eq!(issue.labels, vec!["bug", "urgent"]);
546 assert_eq!(issue.blocked_by.len(), 1);
548 assert_eq!(issue.blocked_by[0].identifier, Some("PROJ-10".into()));
549 assert_eq!(issue.blocked_by[0].state, Some("Done".into()));
550 assert!(issue.created_at.is_some());
551 assert!(issue.updated_at.is_some());
552 }
553
554 #[test]
555 fn normalize_non_integer_priority_becomes_none() {
556 let json = serde_json::json!({
557 "id": "issue-1",
558 "identifier": "PROJ-42",
559 "title": "Test",
560 "priority": "high",
561 "state": { "name": "Todo" }
562 });
563 let issue = normalize_issue(&json).unwrap();
564 assert_eq!(issue.priority, None);
565 }
566
567 #[test]
568 fn normalize_null_priority_becomes_none() {
569 let json = serde_json::json!({
570 "id": "issue-1",
571 "identifier": "PROJ-42",
572 "title": "Test",
573 "priority": null,
574 "state": { "name": "Todo" }
575 });
576 let issue = normalize_issue(&json).unwrap();
577 assert_eq!(issue.priority, None);
578 }
579
580 #[test]
581 fn normalize_labels_lowercase() {
582 let json = serde_json::json!({
583 "id": "issue-1",
584 "identifier": "PROJ-42",
585 "title": "Test",
586 "state": { "name": "Todo" },
587 "labels": { "nodes": [{ "name": "BUG" }, { "name": "FEATURE" }] }
588 });
589 let issue = normalize_issue(&json).unwrap();
590 assert_eq!(issue.labels, vec!["bug", "feature"]);
591 }
592
593 #[test]
594 fn normalize_blocker_from_inverse_blocks() {
595 let json = serde_json::json!({
596 "id": "issue-1",
597 "identifier": "PROJ-42",
598 "title": "Test",
599 "state": { "name": "Todo" },
600 "inverseRelations": { "nodes": [
601 { "type": "blocks", "issue": { "id": "b1", "identifier": "PROJ-10", "state": { "name": "In Progress" } } },
602 { "type": "related", "issue": { "id": "r1", "identifier": "PROJ-20", "state": { "name": "Todo" } } }
603 ] }
604 });
605 let issue = normalize_issue(&json).unwrap();
606 assert_eq!(issue.blocked_by.len(), 1);
608 assert_eq!(issue.blocked_by[0].identifier, Some("PROJ-10".into()));
609 }
610
611 #[test]
612 fn normalize_minimal_issue() {
613 let json = serde_json::json!({
614 "id": "issue-1",
615 "identifier": "PROJ-42",
616 "title": "Test",
617 "state": { "name": "Todo" },
618 "priority": 1,
619 "createdAt": "2025-01-15T10:00:00.000Z"
620 });
621 let issue = normalize_issue_minimal(&json).unwrap();
622 assert_eq!(issue.id, "issue-1");
623 assert_eq!(issue.identifier, "PROJ-42");
624 assert_eq!(issue.state, "Todo");
625 assert_eq!(issue.priority, Some(1));
626 assert!(issue.created_at.is_some());
627 }
628
629 #[test]
630 fn normalize_missing_required_fields_returns_none() {
631 let json = serde_json::json!({ "id": "issue-1", "title": "Test" });
633 assert!(normalize_issue(&json).is_none());
634 }
635
636 #[test]
637 fn error_variants_are_distinct() {
638 let errors: Vec<TrackerError> = vec![
639 TrackerError::UnsupportedKind("x".into()),
640 TrackerError::MissingApiKey,
641 TrackerError::MissingProjectSlug,
642 TrackerError::ApiRequest("x".into()),
643 TrackerError::ApiStatus { status: 401, body: "x".into() },
644 TrackerError::GraphqlErrors("x".into()),
645 TrackerError::UnknownPayload("x".into()),
646 TrackerError::MissingEndCursor,
647 ];
648 let msgs: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
649 assert!(msgs[0].starts_with("unsupported_tracker_kind"));
650 assert!(msgs[1].starts_with("missing_tracker_api_key"));
651 assert!(msgs[2].starts_with("missing_tracker_project_slug"));
652 assert!(msgs[3].starts_with("linear_api_request"));
653 assert!(msgs[4].starts_with("linear_api_status"));
654 assert!(msgs[5].starts_with("linear_graphql_errors"));
655 assert!(msgs[6].starts_with("linear_unknown_payload"));
656 assert!(msgs[7].starts_with("linear_missing_end_cursor"));
657 }
658
659 #[test]
660 fn iso8601_timestamp_parsing() {
661 let json = serde_json::json!({
662 "id": "issue-1",
663 "identifier": "PROJ-42",
664 "title": "Test",
665 "state": { "name": "Todo" },
666 "createdAt": "2025-01-15T10:30:00.000Z",
667 "updatedAt": "invalid-date"
668 });
669 let issue = normalize_issue(&json).unwrap();
670 assert!(issue.created_at.is_some());
671 assert!(issue.updated_at.is_none()); }
673
674 fn get_real_linear_config() -> Option<(String, String)> {
681 let api_key = std::env::var("LINEAR_API_KEY").ok()?;
682 let project_slug = std::env::var("LINEAR_PROJECT_SLUG")
683 .unwrap_or_else(|_| "symphony-test".into());
684 if api_key.is_empty() {
685 return None;
686 }
687 Some((api_key, project_slug))
688 }
689
690 #[tokio::test]
691 #[ignore] async fn real_linear_graphql_query() {
693 let (api_key, _) = get_real_linear_config()
694 .expect("LINEAR_API_KEY must be set for real integration tests");
695
696 let client = LinearClient::new(
697 "https://api.linear.app/graphql".into(),
698 api_key,
699 "unused".into(),
700 vec![],
701 );
702
703 let data = client
705 .graphql_query("query { viewer { id name } }", serde_json::json!({}))
706 .await
707 .expect("real Linear API call should succeed");
708
709 assert!(data.get("viewer").is_some(), "viewer field should be present");
710 assert!(
711 data["viewer"].get("id").is_some(),
712 "viewer.id should be present"
713 );
714 }
715
716 #[tokio::test]
717 #[ignore] async fn real_linear_fetch_issues() {
719 let (api_key, project_slug) = get_real_linear_config()
720 .expect("LINEAR_API_KEY must be set for real integration tests");
721
722 let client = LinearClient::new(
723 "https://api.linear.app/graphql".into(),
724 api_key,
725 project_slug,
726 vec!["Todo".into(), "In Progress".into()],
727 );
728
729 let issues = client
731 .fetch_candidate_issues()
732 .await
733 .expect("fetch_candidate_issues should succeed with valid credentials");
734
735 for issue in &issues {
737 assert!(!issue.id.is_empty(), "issue.id should not be empty");
738 assert!(!issue.identifier.is_empty(), "issue.identifier should not be empty");
739 assert!(!issue.title.is_empty(), "issue.title should not be empty");
740 assert!(!issue.state.is_empty(), "issue.state should not be empty");
741 }
742 }
743
744 #[tokio::test]
745 #[ignore] async fn real_linear_invalid_key_returns_error() {
747 let client = LinearClient::new(
748 "https://api.linear.app/graphql".into(),
749 "lin_api_invalid_key_12345".into(),
750 "test-proj".into(),
751 vec!["Todo".into()],
752 );
753
754 let result = client
755 .graphql_query("query { viewer { id } }", serde_json::json!({}))
756 .await;
757
758 assert!(result.is_err(), "invalid API key should produce an error");
759 }
760}