mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-16 04:33:16 +00:00
Carry structured error sources through workflow, server, tracker, and CLI paths instead of flattening them into strings before the render boundary.
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());
|
|
}
|
|
}
|