use std::collections::HashMap; use anyhow::{anyhow, bail}; use async_trait::async_trait; use serde_json::Value; use crate::{BlockerRef, Issue, Tracker, execute_graphql_request}; pub const LINEAR_API_ENDPOINT: &str = "https://api.linear.app/graphql"; const BLOCKS_RELATION_TYPE: &str = "blocks"; #[derive(Clone, Debug)] pub struct LinearOptions { pub api_key: String, pub endpoint: String, } impl LinearOptions { pub fn new(api_key: String) -> Self { Self { api_key, endpoint: LINEAR_API_ENDPOINT.to_string(), } } } const ISSUE_FIELDS: &str = " id identifier title description priority state { name } branchName url assignee { id } labels { nodes { name } } inverseRelations(first: 50) { nodes { type issue { id identifier state { name } } } } createdAt updatedAt "; fn normalize_issue(node: &Value) -> anyhow::Result { let id = node["id"] .as_str() .ok_or_else(|| anyhow!("Missing issue id"))? .to_string(); let identifier = node["identifier"] .as_str() .ok_or_else(|| anyhow!("Missing issue identifier"))? .to_string(); let title = node["title"] .as_str() .ok_or_else(|| anyhow!("Missing issue title"))? .to_string(); let description = node["description"] .as_str() .map(std::string::ToString::to_string); let priority = match node["priority"].as_i64() { Some(0) | None => None, Some(n) => Some(i32::try_from(n).map_err(|_| anyhow!("Priority out of range: {n}"))?), }; let state = node["state"]["name"] .as_str() .ok_or_else(|| anyhow!("Missing issue state name"))? .to_string(); let branch_name = node["branchName"] .as_str() .map(std::string::ToString::to_string); let url = node["url"] .as_str() .ok_or_else(|| anyhow!("Missing issue url"))? .to_string(); let assignee_id = node["assignee"]["id"] .as_str() .map(std::string::ToString::to_string); let labels = node["labels"]["nodes"] .as_array() .map(|arr| { arr.iter() .filter_map(|l| l["name"].as_str()) .map(str::to_lowercase) .collect() }) .unwrap_or_default(); let blocked_by = node["inverseRelations"]["nodes"] .as_array() .map(|arr| { arr.iter() .filter(|rel| { rel["type"] .as_str() .is_some_and(|t| t.eq_ignore_ascii_case(BLOCKS_RELATION_TYPE)) }) .filter_map(|rel| { let issue = &rel["issue"]; Some(BlockerRef { id: issue["id"].as_str()?.to_string(), identifier: issue["identifier"].as_str()?.to_string(), state: issue["state"]["name"].as_str()?.to_string(), }) }) .collect() }) .unwrap_or_default(); let created_at = node["createdAt"] .as_str() .map(std::string::ToString::to_string); let updated_at = node["updatedAt"] .as_str() .map(std::string::ToString::to_string); Ok(Issue { id, project_item_id: None, identifier, title, description, priority, state, branch_name, url, assignee_id, labels, blocked_by, created_at, updated_at, }) } async fn execute_graphql( client: &fabro_http::HttpClient, config: &LinearOptions, query: &str, variables: Value, ) -> anyhow::Result { execute_graphql_request( client, &config.endpoint, &config.api_key, "Linear", query, variables, ) .await } fn extract_issues(response: &Value) -> anyhow::Result> { let nodes = response["data"]["issues"]["nodes"] .as_array() .ok_or_else(|| anyhow!("Missing issues nodes in response"))?; nodes.iter().map(normalize_issue).collect() } /// A `Tracker` implementation backed by Linear. pub struct LinearTracker { config: LinearOptions, client: fabro_http::HttpClient, project_slug: String, } impl LinearTracker { pub fn new( config: LinearOptions, client: fabro_http::HttpClient, project_slug: String, ) -> Self { Self { config, client, project_slug, } } } #[async_trait] impl Tracker for LinearTracker { async fn fetch_viewer_id(&self) -> anyhow::Result { tracing::debug!("Fetching viewer ID from Linear"); let query = "query { viewer { id } }"; let response = execute_graphql(&self.client, &self.config, query, serde_json::json!({})).await?; response["data"]["viewer"]["id"] .as_str() .map(std::string::ToString::to_string) .ok_or_else(|| anyhow!("Missing viewer id in response")) } async fn create_comment(&self, issue: &Issue, body: &str) -> anyhow::Result<()> { tracing::debug!(issue_id = %issue.id, "Creating comment on Linear issue"); let query = r" mutation($issueId: String!, $body: String!) { commentCreate(input: { issueId: $issueId, body: $body }) { success } } "; let variables = serde_json::json!({ "issueId": issue.id, "body": body, }); let response = execute_graphql(&self.client, &self.config, query, variables).await?; let success = response["data"]["commentCreate"]["success"] .as_bool() .unwrap_or(false); if !success { bail!("Linear commentCreate returned success: false"); } Ok(()) } async fn update_issue_state(&self, issue: &Issue, state_name: &str) -> anyhow::Result<()> { tracing::debug!(issue_id = %issue.id, state_name, "Updating Linear issue state"); // Step 1: Resolve state name to ID via the issue's team let resolve_query = r" query($issueId: String!, $stateName: String!) { issue(id: $issueId) { team { states(filter: { name: { eq: $stateName } }) { nodes { id } } } } } "; let resolve_vars = serde_json::json!({ "issueId": issue.id, "stateName": state_name, }); let resolve_resp = execute_graphql(&self.client, &self.config, resolve_query, resolve_vars).await?; let state_id = resolve_resp["data"]["issue"]["team"]["states"]["nodes"] .as_array() .and_then(|arr| arr.first()) .and_then(|node| node["id"].as_str()) .ok_or_else(|| anyhow!("State '{state_name}' not found for issue {}", issue.id))? .to_string(); // Step 2: Update the issue let update_query = r" mutation($issueId: String!, $stateId: String!) { issueUpdate(id: $issueId, input: { stateId: $stateId }) { success } } "; let update_vars = serde_json::json!({ "issueId": issue.id, "stateId": state_id, }); let update_resp = execute_graphql(&self.client, &self.config, update_query, update_vars).await?; let success = update_resp["data"]["issueUpdate"]["success"] .as_bool() .unwrap_or(false); if !success { bail!("Linear issueUpdate returned success: false"); } Ok(()) } async fn fetch_candidate_issues(&self, state_names: &[&str]) -> anyhow::Result> { tracing::debug!( project_slug = %self.project_slug, ?state_names, "Fetching candidate issues from Linear" ); let query = format!( r" query($slug: String!, $states: [String!]!, $cursor: String) {{ issues( first: 50 after: $cursor filter: {{ project: {{ slugId: {{ eq: $slug }} }} state: {{ name: {{ in: $states }} }} }} ) {{ nodes {{ {ISSUE_FIELDS} }} pageInfo {{ hasNextPage endCursor }} }} }} " ); let mut all_issues = Vec::new(); let mut cursor: Option = None; loop { let variables = serde_json::json!({ "slug": self.project_slug, "states": state_names, "cursor": cursor, }); let response = execute_graphql(&self.client, &self.config, &query, variables).await?; all_issues.extend(extract_issues(&response)?); let page_info = &response["data"]["issues"]["pageInfo"]; if page_info["hasNextPage"].as_bool() == Some(true) { cursor = page_info["endCursor"] .as_str() .map(std::string::ToString::to_string); } else { break; } } Ok(all_issues) } async fn fetch_issues_by_ids(&self, ids: &[&str]) -> anyhow::Result> { if ids.is_empty() { return Ok(Vec::new()); } tracing::debug!(count = ids.len(), "Fetching issues by ID from Linear"); let query = format!( r" query($ids: [ID!]!) {{ issues(filter: {{ id: {{ in: $ids }} }}) {{ nodes {{ {ISSUE_FIELDS} }} }} }} " ); let mut issue_map: HashMap = HashMap::with_capacity(ids.len()); for batch in ids.chunks(50) { let variables = serde_json::json!({ "ids": batch }); let response = execute_graphql(&self.client, &self.config, &query, variables).await?; for issue in extract_issues(&response)? { issue_map.insert(issue.id.clone(), issue); } } // Return in the same order as the input IDs Ok(ids.iter().filter_map(|id| issue_map.remove(*id)).collect()) } } #[cfg(test)] mod tests { use super::*; fn test_http_client() -> fabro_http::HttpClient { fabro_http::test_http_client().unwrap() } fn mock_config(server_url: &str) -> LinearOptions { LinearOptions { api_key: "lin_api_test123".to_string(), endpoint: format!("{server_url}/graphql"), } } fn make_test_issue() -> Issue { Issue { id: "issue-1".to_string(), project_item_id: None, identifier: "T-1".to_string(), title: "Test".to_string(), description: None, priority: None, state: "Todo".to_string(), branch_name: None, url: "https://linear.app/t".to_string(), assignee_id: None, labels: vec![], blocked_by: vec![], created_at: None, updated_at: None, } } fn complete_issue_json() -> Value { serde_json::json!({ "id": "issue-1", "identifier": "ABC-123", "title": "Fix the bug", "description": "Detailed description", "priority": 2, "state": { "name": "In Progress" }, "branchName": "abc-123-fix-the-bug", "url": "https://linear.app/team/issue/ABC-123", "assignee": { "id": "user-1" }, "labels": { "nodes": [{ "name": "Bug" }, { "name": "URGENT" }] }, "inverseRelations": { "nodes": [ { "type": "blocks", "issue": { "id": "blocker-1", "identifier": "ABC-100", "state": { "name": "Done" } } } ] }, "createdAt": "2026-01-01T00:00:00.000Z", "updatedAt": "2026-01-02T00:00:00.000Z" }) } // ----------------------------------------------------------------------- // normalize_issue // ----------------------------------------------------------------------- #[test] fn normalize_complete_issue() { let node = complete_issue_json(); let issue = normalize_issue(&node).unwrap(); assert_eq!(issue.id, "issue-1"); assert!(issue.project_item_id.is_none()); assert_eq!(issue.identifier, "ABC-123"); assert_eq!(issue.title, "Fix the bug"); assert_eq!(issue.description.as_deref(), Some("Detailed description")); assert_eq!(issue.priority, Some(2)); assert_eq!(issue.state, "In Progress"); assert_eq!(issue.branch_name.as_deref(), Some("abc-123-fix-the-bug")); assert_eq!(issue.url, "https://linear.app/team/issue/ABC-123"); assert_eq!(issue.assignee_id.as_deref(), Some("user-1")); assert_eq!(issue.labels, vec!["bug", "urgent"]); assert_eq!(issue.blocked_by.len(), 1); assert_eq!(issue.blocked_by[0].identifier, "ABC-100"); assert_eq!( issue.created_at.as_deref(), Some("2026-01-01T00:00:00.000Z") ); assert_eq!( issue.updated_at.as_deref(), Some("2026-01-02T00:00:00.000Z") ); } #[test] fn normalize_minimal_issue() { let node = serde_json::json!({ "id": "issue-2", "identifier": "XYZ-1", "title": "Minimal", "description": null, "priority": null, "state": { "name": "Backlog" }, "branchName": null, "url": "https://linear.app/team/issue/XYZ-1", "assignee": null, "labels": { "nodes": [] }, "inverseRelations": { "nodes": [] }, "createdAt": null, "updatedAt": null }); let issue = normalize_issue(&node).unwrap(); assert_eq!(issue.id, "issue-2"); assert!(issue.project_item_id.is_none()); assert!(issue.description.is_none()); assert!(issue.priority.is_none()); assert!(issue.branch_name.is_none()); assert!(issue.assignee_id.is_none()); assert!(issue.labels.is_empty()); assert!(issue.blocked_by.is_empty()); assert!(issue.created_at.is_none()); assert!(issue.updated_at.is_none()); } #[test] fn normalize_labels_lowercased() { let node = serde_json::json!({ "id": "id", "identifier": "T-1", "title": "t", "state": { "name": "Todo" }, "url": "https://linear.app/t", "labels": { "nodes": [{ "name": "Feature" }, { "name": "HIGH Priority" }] }, "inverseRelations": { "nodes": [] } }); let issue = normalize_issue(&node).unwrap(); assert_eq!(issue.labels, vec!["feature", "high priority"]); } #[test] fn normalize_blockers_extracted() { let node = serde_json::json!({ "id": "id", "identifier": "T-1", "title": "t", "state": { "name": "Todo" }, "url": "https://linear.app/t", "labels": { "nodes": [] }, "inverseRelations": { "nodes": [ { "type": "blocks", "issue": { "id": "b1", "identifier": "T-10", "state": { "name": "In Progress" } } }, { "type": "related", "issue": { "id": "r1", "identifier": "T-20", "state": { "name": "Done" } } } ] } }); let issue = normalize_issue(&node).unwrap(); assert_eq!(issue.blocked_by.len(), 1); assert_eq!(issue.blocked_by[0].id, "b1"); assert_eq!(issue.blocked_by[0].identifier, "T-10"); assert_eq!(issue.blocked_by[0].state, "In Progress"); } #[test] fn normalize_blocker_type_case_insensitive() { let node = serde_json::json!({ "id": "id", "identifier": "T-1", "title": "t", "state": { "name": "Todo" }, "url": "https://linear.app/t", "labels": { "nodes": [] }, "inverseRelations": { "nodes": [ { "type": "Blocks", "issue": { "id": "b1", "identifier": "T-5", "state": { "name": "Done" } } } ] } }); let issue = normalize_issue(&node).unwrap(); assert_eq!(issue.blocked_by.len(), 1); } #[test] fn normalize_no_blockers() { let node = serde_json::json!({ "id": "id", "identifier": "T-1", "title": "t", "state": { "name": "Todo" }, "url": "https://linear.app/t", "labels": { "nodes": [] }, "inverseRelations": { "nodes": [] } }); let issue = normalize_issue(&node).unwrap(); assert!(issue.blocked_by.is_empty()); } #[test] fn normalize_priority_zero_is_none() { let node = serde_json::json!({ "id": "id", "identifier": "T-1", "title": "t", "priority": 0, "state": { "name": "Todo" }, "url": "https://linear.app/t", "labels": { "nodes": [] }, "inverseRelations": { "nodes": [] } }); let issue = normalize_issue(&node).unwrap(); assert!(issue.priority.is_none()); } // ----------------------------------------------------------------------- // execute_graphql // ----------------------------------------------------------------------- #[tokio::test] async fn execute_graphql_success() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); let mock = server .mock_async(|when, then| { when.method("POST") .path("/graphql") .header("Authorization", "lin_api_test123") .header("Content-Type", "application/json"); then.status(200) .body(r#"{"data": {"viewer": {"id": "user-1"}}}"#); }) .await; let client = test_http_client(); let result = execute_graphql( &client, &config, "query { viewer { id } }", serde_json::json!({}), ) .await .unwrap(); assert_eq!(result["data"]["viewer"]["id"], "user-1"); mock.assert_async().await; } #[tokio::test] async fn execute_graphql_http_401() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(401).body("Unauthorized"); }) .await; let client = test_http_client(); let err = execute_graphql( &client, &config, "query { viewer { id } }", serde_json::json!({}), ) .await .unwrap_err(); assert!(err.to_string().contains("401"), "got: {err}"); } #[tokio::test] async fn execute_graphql_http_500() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(500).body("Internal Server Error"); }) .await; let client = test_http_client(); let err = execute_graphql( &client, &config, "query { viewer { id } }", serde_json::json!({}), ) .await .unwrap_err(); assert!(err.to_string().contains("500"), "got: {err}"); } #[tokio::test] async fn execute_graphql_errors_array() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200) .body(r#"{"data": null, "errors": [{"message": "Variable not found"}]}"#); }) .await; let client = test_http_client(); let err = execute_graphql(&client, &config, "query { bad }", serde_json::json!({})) .await .unwrap_err(); assert!(err.to_string().contains("Variable not found"), "got: {err}"); } #[tokio::test] async fn execute_graphql_correct_headers() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); let mock = server .mock_async(|when, then| { when.method("POST") .path("/graphql") .header("Authorization", "lin_api_test123") .header("Content-Type", "application/json"); then.status(200).body(r#"{"data": {}}"#); }) .await; let client = test_http_client(); execute_graphql( &client, &config, "query { viewer { id } }", serde_json::json!({}), ) .await .unwrap(); mock.assert_async().await; } // ----------------------------------------------------------------------- // LinearTracker::fetch_viewer_id // ----------------------------------------------------------------------- #[tokio::test] async fn fetch_viewer_id_success() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200) .body(r#"{"data": {"viewer": {"id": "user-abc"}}}"#); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let id = tracker.fetch_viewer_id().await.unwrap(); assert_eq!(id, "user-abc"); } #[tokio::test] async fn fetch_viewer_id_error() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(401).body("Unauthorized"); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let err = tracker.fetch_viewer_id().await.unwrap_err(); assert!(err.to_string().contains("401"), "got: {err}"); } // ----------------------------------------------------------------------- // LinearTracker::create_comment // ----------------------------------------------------------------------- #[tokio::test] async fn create_comment_success() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200) .body(r#"{"data": {"commentCreate": {"success": true}}}"#); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let issue = make_test_issue(); tracker.create_comment(&issue, "Hello world").await.unwrap(); } #[tokio::test] async fn create_comment_returns_false() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200) .body(r#"{"data": {"commentCreate": {"success": false}}}"#); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let issue = make_test_issue(); let err = tracker.create_comment(&issue, "Hello").await.unwrap_err(); assert!(err.to_string().contains("success: false"), "got: {err}"); } // ----------------------------------------------------------------------- // LinearTracker::update_issue_state // ----------------------------------------------------------------------- #[tokio::test] async fn update_issue_state_success() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); // First call: resolve state name to ID let resolve_mock = server.mock_async(|when, then| { when.method("POST").path("/graphql").body_includes("team"); then.status(200) .body(r#"{"data": {"issue": {"team": {"states": {"nodes": [{"id": "state-done"}]}}}}}"#); }).await; // Second call: update issue let update_mock = server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes("issueUpdate"); then.status(200) .body(r#"{"data": {"issueUpdate": {"success": true}}}"#); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let issue = make_test_issue(); tracker.update_issue_state(&issue, "Done").await.unwrap(); resolve_mock.assert_async().await; update_mock.assert_async().await; } #[tokio::test] async fn update_issue_state_not_found() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200) .body(r#"{"data": {"issue": {"team": {"states": {"nodes": []}}}}}"#); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let issue = make_test_issue(); let err = tracker .update_issue_state(&issue, "Nonexistent") .await .unwrap_err(); assert!(err.to_string().contains("Nonexistent"), "got: {err}"); assert!(err.to_string().contains("not found"), "got: {err}"); } // ----------------------------------------------------------------------- // LinearTracker::fetch_candidate_issues // ----------------------------------------------------------------------- #[tokio::test] async fn fetch_candidate_issues_single_page() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); let issue = complete_issue_json(); let body = serde_json::json!({ "data": { "issues": { "nodes": [issue], "pageInfo": { "hasNextPage": false, "endCursor": null } } } }); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200).body(body.to_string()); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "my-project".to_string()); let issues = tracker .fetch_candidate_issues(&["In Progress"]) .await .unwrap(); assert_eq!(issues.len(), 1); assert_eq!(issues[0].identifier, "ABC-123"); assert!(issues[0].project_item_id.is_none()); } #[tokio::test] async fn fetch_candidate_issues_two_pages() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); let issue1 = serde_json::json!({ "id": "id-1", "identifier": "T-1", "title": "First", "state": { "name": "Todo" }, "url": "https://linear.app/t/1", "labels": { "nodes": [] }, "inverseRelations": { "nodes": [] } }); let issue2 = serde_json::json!({ "id": "id-2", "identifier": "T-2", "title": "Second", "state": { "name": "Todo" }, "url": "https://linear.app/t/2", "labels": { "nodes": [] }, "inverseRelations": { "nodes": [] } }); let page1 = serde_json::json!({ "data": { "issues": { "nodes": [issue1], "pageInfo": { "hasNextPage": true, "endCursor": "cursor-1" } } } }); let page2 = serde_json::json!({ "data": { "issues": { "nodes": [issue2], "pageInfo": { "hasNextPage": false, "endCursor": null } } } }); server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes(r#""cursor":null"#); then.status(200).body(page1.to_string()); }) .await; server .mock_async(|when, then| { when.method("POST") .path("/graphql") .body_includes(r#""cursor":"cursor-1""#); then.status(200).body(page2.to_string()); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); assert_eq!(issues.len(), 2); assert_eq!(issues[0].identifier, "T-1"); assert_eq!(issues[1].identifier, "T-2"); } #[tokio::test] async fn fetch_candidate_issues_empty() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); let body = serde_json::json!({ "data": { "issues": { "nodes": [], "pageInfo": { "hasNextPage": false, "endCursor": null } } } }); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200).body(body.to_string()); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap(); assert!(issues.is_empty()); } // ----------------------------------------------------------------------- // LinearTracker::fetch_issues_by_ids // ----------------------------------------------------------------------- #[tokio::test] async fn fetch_issues_by_ids_ordering() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); // API returns in different order than requested let body = serde_json::json!({ "data": { "issues": { "nodes": [ { "id": "id-b", "identifier": "T-2", "title": "B", "state": { "name": "Todo" }, "url": "https://linear.app/t/2", "labels": { "nodes": [] }, "inverseRelations": { "nodes": [] } }, { "id": "id-a", "identifier": "T-1", "title": "A", "state": { "name": "Todo" }, "url": "https://linear.app/t/1", "labels": { "nodes": [] }, "inverseRelations": { "nodes": [] } } ] } } }); server .mock_async(|when, then| { when.method("POST").path("/graphql"); then.status(200).body(body.to_string()); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let issues = tracker .fetch_issues_by_ids(&["id-a", "id-b"]) .await .unwrap(); assert_eq!(issues.len(), 2); assert_eq!(issues[0].id, "id-a"); assert_eq!(issues[1].id, "id-b"); } #[tokio::test] async fn fetch_issues_by_ids_batching() { let server = httpmock::MockServer::start_async().await; let config = mock_config(&server.url("")); // Create 51 IDs to trigger 2 batches let ids: Vec = (0..51).map(|i| format!("id-{i}")).collect(); let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect(); // First batch (ids 0..50) let batch1_nodes: Vec = (0..50) .map(|i| { serde_json::json!({ "id": format!("id-{i}"), "identifier": format!("T-{i}"), "title": format!("T{i}"), "state": { "name": "Todo" }, "url": format!("https://linear.app/t/{i}"), "labels": { "nodes": [] }, "inverseRelations": { "nodes": [] } }) }) .collect(); let batch1 = serde_json::json!({ "data": { "issues": { "nodes": batch1_nodes } } }); server .mock_async(|when, then| { when.method("POST").path("/graphql").body_includes("id-49"); then.status(200).body(batch1.to_string()); }) .await; // Second batch (id 50) let batch2_node = serde_json::json!({ "id": "id-50", "identifier": "T-50", "title": "T50", "state": { "name": "Todo" }, "url": "https://linear.app/t/50", "labels": { "nodes": [] }, "inverseRelations": { "nodes": [] } }); let batch2 = serde_json::json!({ "data": { "issues": { "nodes": [batch2_node] } } }); server .mock_async(|when, then| { when.method("POST").path("/graphql").body_includes("id-50"); then.status(200).body(batch2.to_string()); }) .await; let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let issues = tracker.fetch_issues_by_ids(&id_refs).await.unwrap(); assert_eq!(issues.len(), 51); assert_eq!(issues[0].id, "id-0"); assert_eq!(issues[50].id, "id-50"); } #[tokio::test] async fn fetch_issues_by_ids_empty() { let config = LinearOptions::new("unused".to_string()); let tracker = LinearTracker::new(config, test_http_client(), "proj".to_string()); let issues = tracker.fetch_issues_by_ids(&[]).await.unwrap(); assert!(issues.is_empty()); } }