use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value as JsonValue};
const LINEAR_GRAPHQL_ENDPOINT: &str = "https://api.linear.app/graphql";
const LINEAR_USER_AGENT: &str = "xbp-cli/1.0";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LinearInitiativeSummary {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) status: Option<String>,
pub(crate) health: Option<String>,
pub(crate) archived_at: Option<String>,
pub(crate) target_date: Option<String>,
pub(crate) owner_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct GraphqlTypeRef {
pub(crate) name: Option<String>,
#[serde(rename = "ofType")]
pub(crate) of_type: Option<Box<GraphqlTypeRef>>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct GraphqlFieldArg {
pub(crate) name: String,
#[serde(rename = "type")]
pub(crate) type_ref: GraphqlTypeRef,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct GraphqlField {
pub(crate) name: String,
pub(crate) args: Vec<GraphqlFieldArg>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct GraphqlTypeData {
pub(crate) fields: Option<Vec<GraphqlField>>,
#[serde(rename = "inputFields")]
pub(crate) input_fields: Option<Vec<GraphqlFieldArg>>,
#[serde(rename = "enumValues")]
pub(crate) enum_values: Option<Vec<GraphqlEnumValue>>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct GraphqlEnumValue {
pub(crate) name: String,
}
#[derive(Debug, Clone, Deserialize)]
struct LinearInitiativesPage {
nodes: Vec<LinearInitiativeNode>,
#[serde(rename = "pageInfo")]
page_info: LinearPageInfo,
}
#[derive(Debug, Clone, Deserialize)]
struct LinearPageInfo {
#[serde(rename = "hasNextPage")]
has_next_page: bool,
#[serde(rename = "endCursor")]
end_cursor: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct LinearInitiativeNode {
id: String,
name: String,
#[serde(default)]
status: Option<String>,
#[serde(default)]
health: Option<String>,
#[serde(default, rename = "archivedAt")]
archived_at: Option<String>,
#[serde(default, rename = "targetDate")]
target_date: Option<String>,
#[serde(default)]
owner: Option<LinearOwner>,
}
#[derive(Debug, Clone, Deserialize)]
struct LinearOwner {
#[serde(default)]
name: Option<String>,
}
#[async_trait]
trait LinearGraphqlExecutor {
async fn execute(&mut self, body: &JsonValue) -> Result<JsonValue, String>;
}
struct ReqwestLinearGraphqlExecutor<'a> {
api_key: &'a str,
}
#[async_trait]
impl LinearGraphqlExecutor for ReqwestLinearGraphqlExecutor<'_> {
async fn execute(&mut self, body: &JsonValue) -> Result<JsonValue, String> {
linear_graphql_request(self.api_key, body).await
}
}
pub(crate) async fn fetch_available_initiatives(
api_key: &str,
) -> Result<Vec<LinearInitiativeSummary>, String> {
let mut executor = ReqwestLinearGraphqlExecutor { api_key };
fetch_available_initiatives_with(&mut executor).await
}
pub(crate) async fn fetch_graphql_type(
api_key: &str,
type_name: &str,
) -> Result<GraphqlTypeData, String> {
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
query XbpLinearType($name: String!) {
__type(name: $name) {
fields {
name
args {
name
type {
name
ofType {
name
ofType {
name
ofType {
name
ofType {
name
}
}
}
}
}
}
}
inputFields {
name
type {
name
ofType {
name
ofType {
name
ofType {
name
ofType {
name
}
}
}
}
}
}
enumValues {
name
}
}
}
"#,
"variables": {
"name": type_name
}
}),
)
.await?;
if let Some(errors) = body.get("errors").and_then(JsonValue::as_array) {
let message = errors
.first()
.and_then(|error| error.get("message"))
.and_then(JsonValue::as_str)
.unwrap_or("unknown Linear API error");
return Err(message.to_string());
}
let type_value = body
.get("data")
.and_then(|data| data.get("__type"))
.cloned()
.ok_or_else(|| {
format!(
"Linear schema type lookup for `{}` returned no data.",
type_name
)
})?;
serde_json::from_value(type_value)
.map_err(|e| format!("Failed to decode Linear schema for `{}`: {}", type_name, e))
}
pub(crate) async fn linear_graphql_request(
api_key: &str,
body: &JsonValue,
) -> Result<JsonValue, String> {
let response = reqwest::Client::new()
.post(LINEAR_GRAPHQL_ENDPOINT)
.header("Authorization", api_key.trim())
.header("Content-Type", "application/json")
.header("User-Agent", LINEAR_USER_AGENT)
.json(body)
.send()
.await
.map_err(|e| format!("Linear API request failed: {}", e))?;
let status = response.status();
let body: JsonValue = response
.json()
.await
.map_err(|e| format!("Failed to decode Linear API response: {}", e))?;
if !status.is_success() {
let detail = body
.get("errors")
.and_then(JsonValue::as_array)
.and_then(|errors| errors.first())
.and_then(|error| error.get("message"))
.and_then(JsonValue::as_str)
.unwrap_or("unknown Linear API error");
return Err(format!("Linear API returned {}: {}", status, detail));
}
Ok(body)
}
pub(crate) fn named_type_name(type_ref: &GraphqlTypeRef) -> Option<String> {
let mut current = Some(type_ref);
while let Some(value) = current {
if let Some(name) = &value.name {
return Some(name.clone());
}
current = value.of_type.as_deref();
}
None
}
async fn fetch_available_initiatives_with<E>(
executor: &mut E,
) -> Result<Vec<LinearInitiativeSummary>, String>
where
E: LinearGraphqlExecutor + Send,
{
let mut initiatives = Vec::new();
let mut after: Option<String> = None;
loop {
let page = fetch_initiatives_page(executor, after.as_deref()).await?;
initiatives.extend(
page.nodes
.into_iter()
.filter(|initiative| initiative.archived_at.is_none())
.map(Into::into),
);
if !page.page_info.has_next_page {
break;
}
after = page.page_info.end_cursor;
if after.is_none() {
return Err(
"Linear initiatives pagination returned `hasNextPage=true` without an end cursor."
.to_string(),
);
}
}
Ok(initiatives)
}
async fn fetch_initiatives_page<E>(
executor: &mut E,
after: Option<&str>,
) -> Result<LinearInitiativesPage, String>
where
E: LinearGraphqlExecutor + Send,
{
let body = executor
.execute(&json!({
"query": r#"
query XbpLinearInitiatives($after: String) {
initiatives(first: 50, after: $after, includeArchived: false, orderBy: updatedAt) {
nodes {
id
name
status
health
archivedAt
targetDate
owner {
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"#,
"variables": {
"after": after
}
}))
.await?;
parse_linear_initiatives_page(body)
}
fn parse_linear_initiatives_page(body: JsonValue) -> Result<LinearInitiativesPage, String> {
if let Some(errors) = body.get("errors").and_then(JsonValue::as_array) {
let message = errors
.first()
.and_then(|error| error.get("message"))
.and_then(JsonValue::as_str)
.unwrap_or("unknown Linear API error");
return Err(message.to_string());
}
let initiatives = body
.get("data")
.and_then(|data| data.get("initiatives"))
.cloned()
.ok_or_else(|| "Linear initiatives query returned no data.".to_string())?;
serde_json::from_value(initiatives)
.map_err(|e| format!("Failed to decode Linear initiatives response: {}", e))
}
impl From<LinearInitiativeNode> for LinearInitiativeSummary {
fn from(value: LinearInitiativeNode) -> Self {
Self {
id: value.id,
name: value.name,
status: value.status,
health: value.health,
archived_at: value.archived_at,
target_date: value.target_date,
owner_name: value.owner.and_then(|owner| owner.name),
}
}
}
#[cfg(test)]
mod tests {
use super::{fetch_available_initiatives_with, LinearGraphqlExecutor, LinearInitiativeSummary};
use async_trait::async_trait;
use serde_json::{json, Value as JsonValue};
use std::collections::VecDeque;
struct MockExecutor {
responses: VecDeque<Result<JsonValue, String>>,
requests: Vec<JsonValue>,
}
#[async_trait]
impl LinearGraphqlExecutor for MockExecutor {
async fn execute(&mut self, body: &JsonValue) -> Result<JsonValue, String> {
self.requests.push(body.clone());
self.responses
.pop_front()
.expect("mock response should exist")
}
}
#[tokio::test]
async fn paginates_available_initiatives_and_skips_archived_rows() {
let mut executor = MockExecutor {
responses: VecDeque::from(vec![
Ok(json!({
"data": {
"initiatives": {
"nodes": [
{
"id": "init-1",
"name": "First",
"status": "Active",
"health": "onTrack",
"archivedAt": null,
"targetDate": "2026-06-30",
"owner": { "name": "floris" }
},
{
"id": "init-archived",
"name": "Archived",
"status": "Completed",
"health": "offTrack",
"archivedAt": "2026-05-01T00:00:00.000Z",
"targetDate": null,
"owner": null
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "cursor-1"
}
}
}
})),
Ok(json!({
"data": {
"initiatives": {
"nodes": [
{
"id": "init-2",
"name": "Second",
"status": "Planned",
"health": null,
"archivedAt": null,
"targetDate": null,
"owner": { "name": "suits" }
}
],
"pageInfo": {
"hasNextPage": false,
"endCursor": "cursor-2"
}
}
}
})),
]),
requests: Vec::new(),
};
let initiatives = fetch_available_initiatives_with(&mut executor)
.await
.expect("initiatives");
assert_eq!(
initiatives,
vec![
LinearInitiativeSummary {
id: "init-1".to_string(),
name: "First".to_string(),
status: Some("Active".to_string()),
health: Some("onTrack".to_string()),
archived_at: None,
target_date: Some("2026-06-30".to_string()),
owner_name: Some("floris".to_string()),
},
LinearInitiativeSummary {
id: "init-2".to_string(),
name: "Second".to_string(),
status: Some("Planned".to_string()),
health: None,
archived_at: None,
target_date: None,
owner_name: Some("suits".to_string()),
},
]
);
assert_eq!(executor.requests.len(), 2);
assert_eq!(executor.requests[0]["variables"]["after"], JsonValue::Null);
assert_eq!(
executor.requests[1]["variables"]["after"],
JsonValue::String("cursor-1".to_string())
);
}
#[tokio::test]
async fn returns_empty_list_when_workspace_has_no_initiatives() {
let mut executor = MockExecutor {
responses: VecDeque::from(vec![Ok(json!({
"data": {
"initiatives": {
"nodes": [],
"pageInfo": {
"hasNextPage": false,
"endCursor": null
}
}
}
}))]),
requests: Vec::new(),
};
let initiatives = fetch_available_initiatives_with(&mut executor)
.await
.expect("initiatives");
assert!(initiatives.is_empty());
}
#[tokio::test]
async fn surfaces_graphql_errors_from_initiative_query() {
let mut executor = MockExecutor {
responses: VecDeque::from(vec![Ok(json!({
"errors": [
{
"message": "Linear said no"
}
]
}))]),
requests: Vec::new(),
};
let err = fetch_available_initiatives_with(&mut executor)
.await
.expect_err("should fail");
assert_eq!(err, "Linear said no");
}
}