mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-21 00:21:27 +00:00
1092 lines
35 KiB
Rust
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());
|
|
}
|
|
}
|