use crate::commands::linear::{linear_graphql_errors, linear_graphql_request};
use serde::Deserialize;
use serde_json::{json, Value as JsonValue};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearTeam {
pub id: String,
pub key: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearWorkflowState {
pub id: String,
pub name: String,
pub state_type: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearLabel {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearUser {
pub id: String,
pub name: String,
pub email: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearProject {
pub id: String,
pub name: String,
pub state: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearCycle {
pub id: String,
pub name: Option<String>,
pub number: Option<i32>,
pub starts_at: Option<String>,
pub ends_at: Option<String>,
pub completed_at: Option<String>,
}
impl LinearCycle {
pub fn display_label(&self) -> String {
let name = self
.name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let number = self.number.map(|n| format!("Cycle {n}"));
let base = match (name, number) {
(Some(n), Some(num)) => format!("{num} — {n}"),
(Some(n), None) => n.to_string(),
(None, Some(num)) => num,
(None, None) => self.id.clone(),
};
if self.completed_at.is_some() {
format!("{base} (completed)")
} else {
base
}
}
}
#[derive(Debug, Deserialize)]
struct TeamsPage {
nodes: Vec<TeamNode>,
#[serde(rename = "pageInfo")]
page_info: PageInfo,
}
#[derive(Debug, Deserialize)]
struct TeamNode {
id: String,
key: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct PageInfo {
#[serde(rename = "hasNextPage")]
has_next_page: bool,
#[serde(rename = "endCursor")]
end_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct StatesPage {
nodes: Vec<StateNode>,
}
#[derive(Debug, Deserialize)]
struct StateNode {
id: String,
name: String,
#[serde(rename = "type")]
state_type: String,
}
#[derive(Debug, Deserialize)]
struct LabelsPage {
nodes: Vec<LabelNode>,
#[serde(rename = "pageInfo")]
page_info: PageInfo,
}
#[derive(Debug, Deserialize)]
struct LabelNode {
id: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct UsersPage {
nodes: Vec<UserNode>,
#[serde(rename = "pageInfo")]
page_info: PageInfo,
}
#[derive(Debug, Deserialize)]
struct UserNode {
id: String,
name: String,
#[serde(default)]
email: Option<String>,
#[serde(default, rename = "displayName")]
display_name: Option<String>,
}
pub async fn list_teams(api_key: &str) -> Result<Vec<LinearTeam>, String> {
let mut teams = Vec::new();
let mut after: Option<String> = None;
loop {
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
query XbpLinearTeams($after: String) {
teams(first: 50, after: $after, includeArchived: false) {
nodes { id key name }
pageInfo { hasNextPage endCursor }
}
}
"#,
"variables": { "after": after }
}),
)
.await?;
let page: TeamsPage = extract_data(body, "teams")?;
teams.extend(page.nodes.into_iter().map(|n| LinearTeam {
id: n.id,
key: n.key,
name: n.name,
}));
if !page.page_info.has_next_page {
break;
}
after = page.page_info.end_cursor;
if after.is_none() {
break;
}
}
Ok(teams)
}
pub async fn resolve_team_id(api_key: &str, team: &str) -> Result<String, String> {
let team = team.trim();
if team.is_empty() {
return Err("Team is required.".into());
}
if team.len() >= 32 && team.contains('-') {
return Ok(team.to_string());
}
let teams = list_teams(api_key).await?;
let lower = team.to_ascii_lowercase();
if let Some(found) = teams.iter().find(|t| {
t.id == team
|| t.key.eq_ignore_ascii_case(team)
|| t.name.eq_ignore_ascii_case(team)
|| t.key.to_ascii_lowercase() == lower
|| t.name.to_ascii_lowercase() == lower
}) {
return Ok(found.id.clone());
}
Err(format!(
"No Linear team matching `{team}`. Use a team key, name, or id."
))
}
pub async fn list_workflow_states(
api_key: &str,
team_id: &str,
) -> Result<Vec<LinearWorkflowState>, String> {
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
query XbpLinearStates($teamId: String!) {
team(id: $teamId) {
states {
nodes { id name type }
}
}
}
"#,
"variables": { "teamId": team_id }
}),
)
.await?;
linear_graphql_errors(&body)?;
let states = body
.get("data")
.and_then(|d| d.get("team"))
.and_then(|t| t.get("states"))
.cloned()
.ok_or_else(|| "Linear team states query returned no data.".to_string())?;
let page: StatesPage = serde_json::from_value(states)
.map_err(|e| format!("Failed to decode Linear states: {e}"))?;
Ok(page
.nodes
.into_iter()
.map(|n| LinearWorkflowState {
id: n.id,
name: n.name,
state_type: n.state_type,
})
.collect())
}
pub async fn resolve_state_id(
api_key: &str,
team_id: &str,
state: &str,
) -> Result<String, String> {
let state = state.trim();
if state.is_empty() {
return Err("State is required.".into());
}
if state.len() >= 32 && state.contains('-') {
return Ok(state.to_string());
}
let states = list_workflow_states(api_key, team_id).await?;
let lower = state.to_ascii_lowercase();
if let Some(found) = states.iter().find(|s| {
s.id == state
|| s.name.eq_ignore_ascii_case(state)
|| s.state_type.eq_ignore_ascii_case(state)
|| s.name.to_ascii_lowercase() == lower
}) {
return Ok(found.id.clone());
}
Err(format!(
"No workflow state matching `{state}` on this team."
))
}
pub async fn list_labels(api_key: &str, team_id: Option<&str>) -> Result<Vec<LinearLabel>, String> {
let mut labels = Vec::new();
let mut after: Option<String> = None;
loop {
let body = if let Some(team_id) = team_id {
linear_graphql_request(
api_key,
&json!({
"query": r#"
query XbpLinearTeamLabels($teamId: String!, $after: String) {
team(id: $teamId) {
labels(first: 50, after: $after) {
nodes { id name }
pageInfo { hasNextPage endCursor }
}
}
}
"#,
"variables": { "teamId": team_id, "after": after }
}),
)
.await?
} else {
linear_graphql_request(
api_key,
&json!({
"query": r#"
query XbpLinearLabels($after: String) {
issueLabels(first: 50, after: $after) {
nodes { id name }
pageInfo { hasNextPage endCursor }
}
}
"#,
"variables": { "after": after }
}),
)
.await?
};
linear_graphql_errors(&body)?;
let page_value = if team_id.is_some() {
body.get("data")
.and_then(|d| d.get("team"))
.and_then(|t| t.get("labels"))
.cloned()
} else {
body.get("data")
.and_then(|d| d.get("issueLabels"))
.cloned()
}
.ok_or_else(|| "Linear labels query returned no data.".to_string())?;
let page: LabelsPage = serde_json::from_value(page_value)
.map_err(|e| format!("Failed to decode Linear labels: {e}"))?;
labels.extend(page.nodes.into_iter().map(|n| LinearLabel {
id: n.id,
name: n.name,
}));
if !page.page_info.has_next_page {
break;
}
after = page.page_info.end_cursor;
if after.is_none() {
break;
}
}
Ok(labels)
}
pub async fn resolve_label_ids(
api_key: &str,
team_id: Option<&str>,
labels: &[String],
) -> Result<Vec<String>, String> {
if labels.is_empty() {
return Ok(Vec::new());
}
let known = list_labels(api_key, team_id).await?;
let mut ids = Vec::new();
for label in labels {
let label = label.trim();
if label.is_empty() {
continue;
}
if label.len() >= 32 && label.contains('-') {
ids.push(label.to_string());
continue;
}
if let Some(found) = known
.iter()
.find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
{
ids.push(found.id.clone());
} else {
return Err(format!("No Linear label matching `{label}`."));
}
}
Ok(ids)
}
pub async fn ensure_label_ids(
api_key: &str,
team_id: Option<&str>,
labels: &[String],
) -> Result<Vec<String>, String> {
if labels.is_empty() {
return Ok(Vec::new());
}
let mut known = list_labels(api_key, team_id).await?;
let mut ids = Vec::new();
for label in labels {
let label = label.trim();
if label.is_empty() {
continue;
}
if label.len() >= 32 && label.contains('-') {
ids.push(label.to_string());
continue;
}
if let Some(found) = known
.iter()
.find(|l| l.id == label || l.name.eq_ignore_ascii_case(label))
{
ids.push(found.id.clone());
continue;
}
if let Some(team_id) = team_id {
match create_issue_label(api_key, team_id, label).await {
Ok(created) => {
known.push(created.clone());
ids.push(created.id);
}
Err(err) => {
eprintln!(
"warning: could not create Linear label `{label}`: {err}"
);
}
}
} else {
eprintln!("warning: Linear label `{label}` not found and no team to create it on");
}
}
Ok(ids)
}
async fn create_issue_label(
api_key: &str,
team_id: &str,
name: &str,
) -> Result<LinearLabel, String> {
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
mutation XbpLinearLabelCreate($input: IssueLabelCreateInput!) {
issueLabelCreate(input: $input) {
success
issueLabel { id name }
}
}
"#,
"variables": {
"input": {
"name": name,
"teamId": team_id
}
}
}),
)
.await?;
linear_graphql_errors(&body)?;
let label = body
.get("data")
.and_then(|d| d.get("issueLabelCreate"))
.and_then(|c| c.get("issueLabel"))
.cloned()
.ok_or_else(|| "Linear issueLabelCreate returned no label.".to_string())?;
let id = label
.get("id")
.and_then(JsonValue::as_str)
.ok_or_else(|| "Linear label missing id.".to_string())?
.to_string();
let name = label
.get("name")
.and_then(JsonValue::as_str)
.unwrap_or(name)
.to_string();
Ok(LinearLabel { id, name })
}
pub async fn resolve_team_id_auto(
api_key: &str,
preferred: Option<&str>,
) -> Result<String, String> {
if let Some(preferred) = preferred.map(str::trim).filter(|s| !s.is_empty()) {
return resolve_team_id(api_key, preferred).await;
}
let teams = list_teams(api_key).await?;
if teams.len() == 1 {
return Ok(teams[0].id.clone());
}
if teams.is_empty() {
return Err("No Linear teams available for this API key.".into());
}
Err(
"Multiple Linear teams found. Pass `--team KEY` or set `linear.default_team_key` in `.xbp/xbp.yaml`."
.into(),
)
}
pub async fn list_users(api_key: &str) -> Result<Vec<LinearUser>, String> {
let mut users = Vec::new();
let mut after: Option<String> = None;
loop {
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
query XbpLinearUsers($after: String) {
users(first: 50, after: $after, includeDisabled: false) {
nodes { id name email displayName }
pageInfo { hasNextPage endCursor }
}
}
"#,
"variables": { "after": after }
}),
)
.await?;
let page: UsersPage = extract_data(body, "users")?;
users.extend(page.nodes.into_iter().map(|n| LinearUser {
id: n.id,
name: n.name,
email: n.email,
display_name: n.display_name,
}));
if !page.page_info.has_next_page {
break;
}
after = page.page_info.end_cursor;
if after.is_none() {
break;
}
}
Ok(users)
}
pub async fn resolve_assignee_id(api_key: &str, assignee: &str) -> Result<Option<String>, String> {
let assignee = assignee.trim();
if assignee.is_empty() || assignee.eq_ignore_ascii_case("none") {
return Ok(None);
}
if assignee.eq_ignore_ascii_case("me") {
return Ok(Some(viewer_id(api_key).await?));
}
if assignee.len() >= 32 && assignee.contains('-') {
return Ok(Some(assignee.to_string()));
}
let users = list_users(api_key).await?;
let lower = assignee.to_ascii_lowercase();
if let Some(found) = users.iter().find(|u| {
u.id == assignee
|| u.name.eq_ignore_ascii_case(assignee)
|| u.email
.as_deref()
.is_some_and(|e| e.eq_ignore_ascii_case(assignee))
|| u.display_name
.as_deref()
.is_some_and(|d| d.eq_ignore_ascii_case(assignee))
|| u.name.to_ascii_lowercase() == lower
}) {
return Ok(Some(found.id.clone()));
}
Err(format!("No Linear user matching `{assignee}`."))
}
async fn viewer_id(api_key: &str) -> Result<String, String> {
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"query XbpLinearViewer { viewer { id } }"#
}),
)
.await?;
linear_graphql_errors(&body)?;
body.get("data")
.and_then(|d| d.get("viewer"))
.and_then(|v| v.get("id"))
.and_then(JsonValue::as_str)
.map(str::to_string)
.ok_or_else(|| "Linear viewer query returned no id.".to_string())
}
#[derive(Debug, Deserialize)]
struct ProjectsPage {
nodes: Vec<ProjectNode>,
#[serde(rename = "pageInfo")]
page_info: PageInfo,
}
#[derive(Debug, Deserialize)]
struct ProjectNode {
id: String,
name: String,
#[serde(default)]
state: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CyclesPage {
nodes: Vec<CycleNode>,
#[serde(rename = "pageInfo")]
page_info: PageInfo,
}
#[derive(Debug, Deserialize)]
struct CycleNode {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
number: Option<i32>,
#[serde(default, rename = "startsAt")]
starts_at: Option<String>,
#[serde(default, rename = "endsAt")]
ends_at: Option<String>,
#[serde(default, rename = "completedAt")]
completed_at: Option<String>,
}
pub async fn list_projects(api_key: &str) -> Result<Vec<LinearProject>, String> {
let mut projects = Vec::new();
let mut after: Option<String> = None;
loop {
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
query XbpLinearProjects($after: String) {
projects(first: 50, after: $after, includeArchived: false) {
nodes { id name state }
pageInfo { hasNextPage endCursor }
}
}
"#,
"variables": { "after": after }
}),
)
.await?;
let page: ProjectsPage = extract_data(body, "projects")?;
projects.extend(page.nodes.into_iter().map(|n| LinearProject {
id: n.id,
name: n.name,
state: n.state,
}));
if !page.page_info.has_next_page {
break;
}
after = page.page_info.end_cursor;
if after.is_none() {
break;
}
}
Ok(projects)
}
pub async fn resolve_project_id(
api_key: &str,
project: &str,
) -> Result<Option<String>, String> {
let project = project.trim();
if project.is_empty() || project.eq_ignore_ascii_case("none") {
return Ok(None);
}
if project.len() >= 32 && project.contains('-') {
return Ok(Some(project.to_string()));
}
let projects = list_projects(api_key).await?;
let lower = project.to_ascii_lowercase();
if let Some(found) = projects.iter().find(|p| {
p.id == project
|| p.name.eq_ignore_ascii_case(project)
|| p.name.to_ascii_lowercase() == lower
}) {
return Ok(Some(found.id.clone()));
}
Err(format!("No Linear project matching `{project}`."))
}
pub async fn list_cycles(api_key: &str, team_id: &str) -> Result<Vec<LinearCycle>, String> {
let mut cycles = Vec::new();
let mut after: Option<String> = None;
loop {
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
query XbpLinearCycles($teamId: String!, $after: String) {
team(id: $teamId) {
cycles(first: 50, after: $after) {
nodes { id name number startsAt endsAt completedAt }
pageInfo { hasNextPage endCursor }
}
}
}
"#,
"variables": { "teamId": team_id, "after": after }
}),
)
.await?;
linear_graphql_errors(&body)?;
let page_value = body
.get("data")
.and_then(|d| d.get("team"))
.and_then(|t| t.get("cycles"))
.cloned()
.ok_or_else(|| "Linear team cycles query returned no data.".to_string())?;
let page: CyclesPage = serde_json::from_value(page_value)
.map_err(|e| format!("Failed to decode Linear cycles: {e}"))?;
cycles.extend(page.nodes.into_iter().map(|n| LinearCycle {
id: n.id,
name: n.name,
number: n.number,
starts_at: n.starts_at,
ends_at: n.ends_at,
completed_at: n.completed_at,
}));
if !page.page_info.has_next_page {
break;
}
after = page.page_info.end_cursor;
if after.is_none() {
break;
}
}
Ok(cycles)
}
pub async fn resolve_cycle_id(
api_key: &str,
team_id: &str,
cycle: &str,
) -> Result<Option<String>, String> {
let cycle = cycle.trim();
if cycle.is_empty() || cycle.eq_ignore_ascii_case("none") {
return Ok(None);
}
if cycle.len() >= 32 && cycle.contains('-') {
return Ok(Some(cycle.to_string()));
}
let cycles = list_cycles(api_key, team_id).await?;
let lower = cycle.to_ascii_lowercase();
if let Some(found) = cycles.iter().find(|c| {
c.id == cycle
|| c.name
.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(cycle) || n.to_ascii_lowercase() == lower)
|| c.number
.map(|n| n.to_string() == cycle || format!("cycle {n}") == lower)
.unwrap_or(false)
}) {
return Ok(Some(found.id.clone()));
}
Err(format!("No Linear cycle matching `{cycle}` on this team."))
}
fn extract_data<T: for<'de> Deserialize<'de>>(body: JsonValue, field: &str) -> Result<T, String> {
linear_graphql_errors(&body)?;
let value = body
.get("data")
.and_then(|d| d.get(field))
.cloned()
.ok_or_else(|| format!("Linear query returned no `{field}` data."))?;
serde_json::from_value(value).map_err(|e| format!("Failed to decode Linear `{field}`: {e}"))
}