From b509277e15257e49bb64c103b28318d54cc8fddd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 11 Mar 2026 22:36:33 -0400 Subject: [PATCH] Add arc-linear crate for Linear issue tracker API integration GraphQL client with 6 public functions: execute_graphql, fetch_viewer_id, create_comment, update_issue_state, fetch_candidate_issues, and fetch_issues_by_ids. Includes Issue/BlockerRef domain types with manual normalization from GraphQL JSON responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 12 + lib/crates/arc-linear/Cargo.toml | 19 + lib/crates/arc-linear/src/lib.rs | 1084 ++++++++++++++++++++++++++++++ 3 files changed, 1115 insertions(+) create mode 100644 lib/crates/arc-linear/Cargo.toml create mode 100644 lib/crates/arc-linear/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 966f06401..835672262 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,6 +314,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "arc-linear" +version = "0.2.0" +dependencies = [ + "mockito", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "arc-llm" version = "0.2.0" diff --git a/lib/crates/arc-linear/Cargo.toml b/lib/crates/arc-linear/Cargo.toml new file mode 100644 index 000000000..161e1eb62 --- /dev/null +++ b/lib/crates/arc-linear/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "arc-linear" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "Linear issue tracker API helpers for Arc" + +[lib] +doctest = false + +[dependencies] +serde.workspace = true +serde_json.workspace = true +reqwest.workspace = true +tracing.workspace = true + +[dev-dependencies] +mockito = "1" +tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/crates/arc-linear/src/lib.rs b/lib/crates/arc-linear/src/lib.rs new file mode 100644 index 000000000..6f6facf75 --- /dev/null +++ b/lib/crates/arc-linear/src/lib.rs @@ -0,0 +1,1084 @@ +use serde_json::Value; + +pub const LINEAR_API_ENDPOINT: &str = "https://api.linear.app/graphql"; + +#[derive(Clone, Debug)] +pub struct LinearConfig { + pub api_key: String, + pub endpoint: String, +} + +impl LinearConfig { + pub fn new(api_key: String) -> Self { + Self { + api_key, + endpoint: LINEAR_API_ENDPOINT.to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub struct Issue { + pub id: String, + pub identifier: String, + pub title: String, + pub description: Option, + pub priority: Option, + pub state: String, + pub branch_name: Option, + pub url: String, + pub assignee_id: Option, + pub labels: Vec, + pub blocked_by: Vec, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone)] +pub struct BlockerRef { + pub id: String, + pub identifier: String, + pub state: 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) -> Result { + let id = node["id"].as_str().ok_or("Missing issue id")?.to_string(); + let identifier = node["identifier"] + .as_str() + .ok_or("Missing issue identifier")? + .to_string(); + let title = node["title"] + .as_str() + .ok_or("Missing issue title")? + .to_string(); + let description = node["description"].as_str().map(|s| s.to_string()); + let priority = match node["priority"].as_i64() { + Some(0) | None => None, + Some(n) => Some(n as i32), + }; + let state = node["state"]["name"] + .as_str() + .ok_or("Missing issue state name")? + .to_string(); + let branch_name = node["branchName"].as_str().map(|s| s.to_string()); + let url = node["url"].as_str().ok_or("Missing issue url")?.to_string(); + let assignee_id = node["assignee"]["id"].as_str().map(|s| s.to_string()); + + let labels = node["labels"]["nodes"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|l| l["name"].as_str()) + .map(|s| s.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")) + }) + .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(|s| s.to_string()); + let updated_at = node["updatedAt"].as_str().map(|s| s.to_string()); + + Ok(Issue { + id, + identifier, + title, + description, + priority, + state, + branch_name, + url, + assignee_id, + labels, + blocked_by, + created_at, + updated_at, + }) +} + +pub async fn execute_graphql( + client: &reqwest::Client, + config: &LinearConfig, + query: &str, + variables: Value, +) -> Result { + let body = serde_json::json!({ + "query": query, + "variables": variables, + }); + + let resp = client + .post(&config.endpoint) + .header("Authorization", &config.api_key) + .header("Content-Type", "application/json") + .timeout(std::time::Duration::from_secs(30)) + .json(&body) + .send() + .await + .map_err(|e| format!("Linear API request failed: {e}"))?; + + let status = resp.status(); + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + return Err(format!("Linear API returned HTTP {status}: {body_text}")); + } + + let response: Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse Linear API response: {e}"))?; + + if let Some(errors) = response["errors"].as_array() { + if !errors.is_empty() { + let messages: Vec<&str> = errors + .iter() + .filter_map(|e| e["message"].as_str()) + .collect(); + return Err(format!("Linear GraphQL errors: {}", messages.join("; "))); + } + } + + Ok(response) +} + +pub async fn fetch_viewer_id( + client: &reqwest::Client, + config: &LinearConfig, +) -> Result { + let query = "query { viewer { id } }"; + let response = execute_graphql(client, config, query, serde_json::json!({})).await?; + + response["data"]["viewer"]["id"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Missing viewer id in response".to_string()) +} + +pub async fn create_comment( + client: &reqwest::Client, + config: &LinearConfig, + issue_id: &str, + body: &str, +) -> Result<(), String> { + 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(client, config, query, variables).await?; + + let success = response["data"]["commentCreate"]["success"] + .as_bool() + .unwrap_or(false); + if !success { + return Err("Linear commentCreate returned success: false".to_string()); + } + + Ok(()) +} + +pub async fn update_issue_state( + client: &reqwest::Client, + config: &LinearConfig, + issue_id: &str, + state_name: &str, +) -> Result<(), String> { + // 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(client, 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(|| format!("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(client, config, update_query, update_vars).await?; + + let success = update_resp["data"]["issueUpdate"]["success"] + .as_bool() + .unwrap_or(false); + if !success { + return Err("Linear issueUpdate returned success: false".to_string()); + } + + Ok(()) +} + +pub async fn fetch_candidate_issues( + client: &reqwest::Client, + config: &LinearConfig, + project_slug: &str, + state_names: &[&str], +) -> Result, String> { + 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": project_slug, + "states": state_names, + "cursor": cursor, + }); + + let response = execute_graphql(client, config, &query, variables).await?; + + let nodes = response["data"]["issues"]["nodes"] + .as_array() + .ok_or("Missing issues nodes in response")?; + + for node in nodes { + all_issues.push(normalize_issue(node)?); + } + + let page_info = &response["data"]["issues"]["pageInfo"]; + if page_info["hasNextPage"].as_bool() == Some(true) { + cursor = page_info["endCursor"].as_str().map(|s| s.to_string()); + } else { + break; + } + } + + Ok(all_issues) +} + +pub async fn fetch_issues_by_ids( + client: &reqwest::Client, + config: &LinearConfig, + ids: &[&str], +) -> Result, String> { + if ids.is_empty() { + return Ok(Vec::new()); + } + + let query = format!( + r#" + query($ids: [ID!]!) {{ + issues(filter: {{ id: {{ in: $ids }} }}) {{ + nodes {{ {ISSUE_FIELDS} }} + }} + }} + "# + ); + + let mut all_issues: Vec = Vec::new(); + + for batch in ids.chunks(50) { + let variables = serde_json::json!({ "ids": batch }); + let response = execute_graphql(client, config, &query, variables).await?; + + let nodes = response["data"]["issues"]["nodes"] + .as_array() + .ok_or("Missing issues nodes in response")?; + + for node in nodes { + all_issues.push(normalize_issue(node)?); + } + } + + // Return in the same order as the input IDs + let mut ordered: Vec = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(issue) = all_issues.iter().find(|i| i.id == *id) { + ordered.push(issue.clone()); + } + } + + Ok(ordered) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_config(server_url: &str) -> LinearConfig { + LinearConfig { + api_key: "lin_api_test123".to_string(), + endpoint: format!("{server_url}/graphql"), + } + } + + 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_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.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 mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + let mock = server + .mock("POST", "/graphql") + .match_header("Authorization", "lin_api_test123") + .match_header("Content-Type", "application/json") + .with_status(200) + .with_body(r#"{"data": {"viewer": {"id": "user-1"}}}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + 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 mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + server + .mock("POST", "/graphql") + .with_status(401) + .with_body("Unauthorized") + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = execute_graphql( + &client, + &config, + "query { viewer { id } }", + serde_json::json!({}), + ) + .await + .unwrap_err(); + + assert!(err.contains("401"), "got: {err}"); + } + + #[tokio::test] + async fn execute_graphql_http_500() { + let mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + server + .mock("POST", "/graphql") + .with_status(500) + .with_body("Internal Server Error") + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = execute_graphql( + &client, + &config, + "query { viewer { id } }", + serde_json::json!({}), + ) + .await + .unwrap_err(); + + assert!(err.contains("500"), "got: {err}"); + } + + #[tokio::test] + async fn execute_graphql_errors_array() { + let mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": null, "errors": [{"message": "Variable not found"}]}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = execute_graphql(&client, &config, "query { bad }", serde_json::json!({})) + .await + .unwrap_err(); + + assert!(err.contains("Variable not found"), "got: {err}"); + } + + #[tokio::test] + async fn execute_graphql_correct_headers() { + let mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + let mock = server + .mock("POST", "/graphql") + .match_header("Authorization", "lin_api_test123") + .match_header("Content-Type", "application/json") + .with_status(200) + .with_body(r#"{"data": {}}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + execute_graphql( + &client, + &config, + "query { viewer { id } }", + serde_json::json!({}), + ) + .await + .unwrap(); + + mock.assert_async().await; + } + + // ----------------------------------------------------------------------- + // fetch_viewer_id + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn fetch_viewer_id_success() { + let mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"viewer": {"id": "user-abc"}}}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let id = fetch_viewer_id(&client, &config).await.unwrap(); + assert_eq!(id, "user-abc"); + } + + #[tokio::test] + async fn fetch_viewer_id_error() { + let mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + server + .mock("POST", "/graphql") + .with_status(401) + .with_body("Unauthorized") + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = fetch_viewer_id(&client, &config).await.unwrap_err(); + assert!(err.contains("401"), "got: {err}"); + } + + // ----------------------------------------------------------------------- + // create_comment + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn create_comment_success() { + let mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"commentCreate": {"success": true}}}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + create_comment(&client, &config, "issue-1", "Hello world") + .await + .unwrap(); + } + + #[tokio::test] + async fn create_comment_returns_false() { + let mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"commentCreate": {"success": false}}}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = create_comment(&client, &config, "issue-1", "Hello") + .await + .unwrap_err(); + assert!(err.contains("success: false"), "got: {err}"); + } + + // ----------------------------------------------------------------------- + // update_issue_state + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn update_issue_state_success() { + let mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + // First call: resolve state name to ID + let resolve_mock = server + .mock("POST", "/graphql") + .with_status(200) + .with_body( + r#"{"data": {"issue": {"team": {"states": {"nodes": [{"id": "state-done"}]}}}}}"#, + ) + .create_async() + .await; + + // Second call: update issue + let update_mock = server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"issueUpdate": {"success": true}}}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + update_issue_state(&client, &config, "issue-1", "Done") + .await + .unwrap(); + + resolve_mock.assert_async().await; + update_mock.assert_async().await; + } + + #[tokio::test] + async fn update_issue_state_not_found() { + let mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(r#"{"data": {"issue": {"team": {"states": {"nodes": []}}}}}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = update_issue_state(&client, &config, "issue-1", "Nonexistent") + .await + .unwrap_err(); + assert!(err.contains("Nonexistent"), "got: {err}"); + assert!(err.contains("not found"), "got: {err}"); + } + + // ----------------------------------------------------------------------- + // fetch_candidate_issues + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn fetch_candidate_issues_single_page() { + let mut server = mockito::Server::new_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("POST", "/graphql") + .with_status(200) + .with_body(body.to_string()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let issues = fetch_candidate_issues(&client, &config, "my-project", &["In Progress"]) + .await + .unwrap(); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].identifier, "ABC-123"); + } + + #[tokio::test] + async fn fetch_candidate_issues_two_pages() { + let mut server = mockito::Server::new_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("POST", "/graphql") + .match_body(mockito::Matcher::PartialJsonString( + r#"{"variables":{"cursor":null}}"#.to_string(), + )) + .with_status(200) + .with_body(page1.to_string()) + .create_async() + .await; + server + .mock("POST", "/graphql") + .match_body(mockito::Matcher::PartialJsonString( + r#"{"variables":{"cursor":"cursor-1"}}"#.to_string(), + )) + .with_status(200) + .with_body(page2.to_string()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let issues = fetch_candidate_issues(&client, &config, "proj", &["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 mut server = mockito::Server::new_async().await; + let config = mock_config(&server.url()); + + let body = serde_json::json!({ + "data": { + "issues": { + "nodes": [], + "pageInfo": { "hasNextPage": false, "endCursor": null } + } + } + }); + + server + .mock("POST", "/graphql") + .with_status(200) + .with_body(body.to_string()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let issues = fetch_candidate_issues(&client, &config, "proj", &["Todo"]) + .await + .unwrap(); + + assert!(issues.is_empty()); + } + + // ----------------------------------------------------------------------- + // fetch_issues_by_ids + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn fetch_issues_by_ids_ordering() { + let mut server = mockito::Server::new_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("POST", "/graphql") + .with_status(200) + .with_body(body.to_string()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let issues = fetch_issues_by_ids(&client, &config, &["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 mut server = mockito::Server::new_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(|s| s.as_str()).collect(); + + // Second batch (ids 50..51) — registered first for LIFO + 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("POST", "/graphql") + .with_status(200) + .with_body(batch2.to_string()) + .create_async() + .await; + + // 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("POST", "/graphql") + .with_status(200) + .with_body(batch1.to_string()) + .create_async() + .await; + + let client = reqwest::Client::new(); + let issues = fetch_issues_by_ids(&client, &config, &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 client = reqwest::Client::new(); + let config = LinearConfig::new("unused".to_string()); + let issues = fetch_issues_by_ids(&client, &config, &[]).await.unwrap(); + assert!(issues.is_empty()); + } +}