Skip to main content

symphony_tracker/
linear.rs

1//! Linear issue tracker adapter (Spec Sections 11.2, 11.3, 11.4).
2
3use 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
12/// Linear GraphQL client.
13pub 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    /// Get the configured endpoint.
42    pub fn endpoint(&self) -> &str {
43        &self.endpoint
44    }
45
46    /// Get the configured API key.
47    pub fn api_key(&self) -> &str {
48        &self.api_key
49    }
50
51    /// Execute a GraphQL query against the Linear API.
52    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        // Check for GraphQL errors
90        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    /// Fetch issues with pagination for a given GraphQL query.
103    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            // Navigate data_path to find the issues connection
117            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            // Extract nodes
125            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            // Check pagination
139            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
163/// GraphQL query for fetching candidate issues (S11.2).
164const 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
197/// GraphQL query for fetching issues by states (S11.2).
198const 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
231/// GraphQL query for fetching issue states by IDs (S11.2).
232const 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
333/// Normalize a full Linear issue JSON node to domain Issue (Spec Section 11.3).
334fn 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    // Priority: integer only; non-integer becomes None (S11.3)
341    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    // Labels: normalize to lowercase (S11.3)
360    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    // Blocked_by: derive from inverse relations where type is "blocks" (S11.3)
373    let blocked_by = extract_blockers(v);
374
375    // Timestamps: ISO-8601 parsing (S11.3)
376    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
401/// Normalize a minimal issue from the nodes-by-ID query.
402fn 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
448/// Extract blockers from inverse relations where type is "blocks" (S11.3).
449fn extract_blockers(v: &Value) -> Vec<BlockerRef> {
450    let mut blockers = Vec::new();
451
452    // From inverseRelations: issue X blocks this issue
453    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        // Labels normalized to lowercase (S11.3)
545        assert_eq!(issue.labels, vec!["bug", "urgent"]);
546        // Blocker derived from inverse "blocks" relation (S11.3)
547        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        // Only "blocks" type should appear
607        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        // Missing identifier
632        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()); // invalid date → None
672    }
673
674    // ─── Real Linear Integration Tests (S17.8) ───
675    // These tests require LINEAR_API_KEY env var and a valid project.
676    // Run with: cargo test -- --ignored
677    // They are reported as "ignored" (skipped) when credentials are absent.
678
679    /// Helper to get Linear credentials from env, or skip.
680    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] // S17.8: skipped when credentials absent, reported as skipped
692    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        // Simple viewer query to validate auth works
704        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] // S17.8: skipped when credentials absent
718    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        // Fetch candidate issues — may return empty if project has no active issues
730        let issues = client
731            .fetch_candidate_issues()
732            .await
733            .expect("fetch_candidate_issues should succeed with valid credentials");
734
735        // Validate each issue has required fields
736        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] // S17.8: skipped when credentials absent
746    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}