fabro/lib/components/fabro-tracker/src/linear.rs
2026-07-23 17:59:34 -04:00

1092 lines
35 KiB
Rust

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<Issue> {
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<Value> {
execute_graphql_request(
client,
&config.endpoint,
&config.api_key,
"Linear",
query,
variables,
)
.await
}
fn extract_issues(response: &Value) -> anyhow::Result<Vec<Issue>> {
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<String> {
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<Vec<Issue>> {
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<String> = 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<Vec<Issue>> {
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<String, Issue> = 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<String> = (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<Value> = (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());
}
}