use super::meta::{
list_workflow_states, resolve_assignee_id, resolve_label_ids, resolve_state_id, resolve_team_id,
};
use super::{parse_priority_arg, priority_label, require_interactive};
use crate::cli::commands::{
LinearAssignCmd, LinearCreateCmd, LinearEditCmd, LinearLabelsCmd, LinearListCmd,
LinearPriorityCmd, LinearShowCmd, LinearStatusCmd,
};
use crate::cli::ui::Loader;
use crate::commands::linear::{linear_graphql_errors, linear_graphql_request};
use crate::commands::text_util::truncate_chars;
use crate::commands::terminal_table::{render_table, TableStyle};
use crate::config::{LinearConfig, SshConfig};
use crate::strategies::deployment_config::XbpConfig;
use crate::utils::find_xbp_config_upwards;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input, MultiSelect};
use serde::Deserialize;
use serde_json::{json, Map as JsonMap, Value as JsonValue};
use std::env;
use std::fs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearIssue {
pub id: String,
pub identifier: String,
pub title: String,
pub description: Option<String>,
pub priority: i32,
pub url: Option<String>,
pub state_id: Option<String>,
pub state_name: Option<String>,
pub state_type: Option<String>,
pub assignee_id: Option<String>,
pub assignee_name: Option<String>,
pub team_id: Option<String>,
pub team_key: Option<String>,
pub team_name: Option<String>,
pub labels: Vec<(String, String)>, pub updated_at: Option<String>,
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IssueSortField {
#[default]
Updated,
Created,
Priority,
Identifier,
Title,
State,
}
impl IssueSortField {
pub fn parse(raw: &str) -> Result<Self, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"updated" | "updatedat" | "updated_at" => Ok(Self::Updated),
"created" | "createdat" | "created_at" => Ok(Self::Created),
"priority" | "pri" => Ok(Self::Priority),
"identifier" | "id" | "key" => Ok(Self::Identifier),
"title" | "name" => Ok(Self::Title),
"state" | "status" => Ok(Self::State),
other => Err(format!(
"Unknown sort field `{other}`. Use updated|created|priority|identifier|title|state."
)),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Updated => "updated",
Self::Created => "created",
Self::Priority => "priority",
Self::Identifier => "identifier",
Self::Title => "title",
Self::State => "state",
}
}
pub fn cycle(self) -> Self {
match self {
Self::Updated => Self::Created,
Self::Created => Self::Priority,
Self::Priority => Self::Identifier,
Self::Identifier => Self::Title,
Self::Title => Self::State,
Self::State => Self::Updated,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ListIssuesFilter {
pub team_id: Option<String>,
pub state: Option<String>,
pub assignee_id: Option<String>,
pub query: Option<String>,
pub labels: Vec<String>,
pub include_completed: bool,
pub sort: IssueSortField,
pub sort_asc: bool,
pub limit: usize,
}
#[derive(Debug, Clone, Default)]
pub struct CreateIssueInput {
pub team_id: String,
pub title: String,
pub description: Option<String>,
pub priority: Option<i32>,
pub state_id: Option<String>,
pub assignee_id: Option<String>,
pub label_ids: Vec<String>,
pub project_id: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateIssueInput {
pub title: Option<String>,
pub description: Option<String>,
pub priority: Option<i32>,
pub state_id: Option<String>,
pub assignee_id: Option<Option<String>>,
pub label_ids: Option<Vec<String>>,
pub project_id: Option<Option<String>>,
pub cycle_id: Option<Option<String>>,
}
#[derive(Debug, Deserialize)]
struct IssueNode {
id: String,
identifier: String,
title: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
priority: i32,
#[serde(default)]
url: Option<String>,
#[serde(default)]
state: Option<NamedRef>,
#[serde(default)]
assignee: Option<NamedRef>,
#[serde(default)]
team: Option<TeamRef>,
#[serde(default)]
labels: Option<LabelsNodes>,
#[serde(default, rename = "updatedAt")]
updated_at: Option<String>,
#[serde(default, rename = "createdAt")]
created_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct NamedRef {
id: String,
name: String,
#[serde(default, rename = "type")]
type_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TeamRef {
id: String,
key: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct LabelsNodes {
nodes: Vec<LabelRef>,
}
#[derive(Debug, Deserialize)]
struct LabelRef {
id: String,
name: String,
}
impl From<IssueNode> for LinearIssue {
fn from(n: IssueNode) -> Self {
let labels = n
.labels
.map(|l| l.nodes.into_iter().map(|x| (x.id, x.name)).collect())
.unwrap_or_default();
Self {
id: n.id,
identifier: n.identifier,
title: n.title,
description: n.description,
priority: n.priority,
url: n.url,
state_id: n.state.as_ref().map(|s| s.id.clone()),
state_name: n.state.as_ref().map(|s| s.name.clone()),
state_type: n.state.and_then(|s| s.type_name),
assignee_id: n.assignee.as_ref().map(|a| a.id.clone()),
assignee_name: n.assignee.map(|a| a.name),
team_id: n.team.as_ref().map(|t| t.id.clone()),
team_key: n.team.as_ref().map(|t| t.key.clone()),
team_name: n.team.map(|t| t.name),
labels,
updated_at: n.updated_at,
created_at: n.created_at,
}
}
}
const ISSUE_FIELDS: &str = r#"
id
identifier
title
description
priority
url
updatedAt
createdAt
state { id name type }
assignee { id name }
team { id key name }
labels { nodes { id name } }
"#;
pub async fn list_issues(
api_key: &str,
filter: ListIssuesFilter,
) -> Result<Vec<LinearIssue>, String> {
let limit = filter.limit.clamp(1, 250);
let mut filter_obj = JsonMap::new();
if let Some(team_id) = filter.team_id {
filter_obj.insert("team".into(), json!({ "id": { "eq": team_id } }));
}
if let Some(ref state) = filter.state {
let state = state.trim();
if !state.is_empty() {
if matches!(
state.to_ascii_lowercase().as_str(),
"backlog" | "unstarted" | "started" | "completed" | "canceled" | "cancelled"
) {
let st = if state.eq_ignore_ascii_case("cancelled") {
"canceled"
} else {
state
};
filter_obj.insert("state".into(), json!({ "type": { "eq": st } }));
} else {
filter_obj.insert("state".into(), json!({ "name": { "eq": state } }));
}
}
}
if let Some(assignee_id) = filter.assignee_id {
filter_obj.insert(
"assignee".into(),
json!({ "id": { "eq": assignee_id } }),
);
}
if !filter.include_completed
&& filter
.state
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.is_none()
{
filter_obj.insert(
"state".into(),
json!({ "type": { "nin": ["completed", "canceled"] } }),
);
}
let order_by = match filter.sort {
IssueSortField::Created => "createdAt",
_ => "updatedAt",
};
let query = if filter_obj.is_empty() {
format!(
r#"
query XbpLinearIssues($first: Int!) {{
issues(first: $first, orderBy: {order_by}) {{
nodes {{ {fields} }}
}}
}}
"#,
order_by = order_by,
fields = ISSUE_FIELDS
)
} else {
format!(
r#"
query XbpLinearIssues($first: Int!, $filter: IssueFilter) {{
issues(first: $first, filter: $filter, orderBy: {order_by}) {{
nodes {{ {fields} }}
}}
}}
"#,
order_by = order_by,
fields = ISSUE_FIELDS
)
};
let mut variables = json!({ "first": limit as i64 });
if !filter_obj.is_empty() {
variables["filter"] = JsonValue::Object(filter_obj);
}
let body = linear_graphql_request(api_key, &json!({ "query": query, "variables": variables }))
.await?;
linear_graphql_errors(&body)?;
let mut issues: Vec<LinearIssue> = body
.get("data")
.and_then(|d| d.get("issues"))
.and_then(|i| i.get("nodes"))
.and_then(JsonValue::as_array)
.ok_or_else(|| "Linear issues query returned no data.".to_string())?
.iter()
.cloned()
.map(|v| {
serde_json::from_value::<IssueNode>(v)
.map(LinearIssue::from)
.map_err(|e| format!("Failed to decode Linear issue: {e}"))
})
.collect::<Result<Vec<_>, _>>()?;
if let Some(q) = filter.query {
let q = q.trim().to_ascii_lowercase();
if !q.is_empty() {
issues.retain(|issue| issue_matches_query(issue, &q));
}
}
if !filter.labels.is_empty() {
let wanted: Vec<String> = filter
.labels
.iter()
.map(|l| l.trim().to_ascii_lowercase())
.filter(|l| !l.is_empty())
.collect();
if !wanted.is_empty() {
issues.retain(|issue| {
wanted.iter().all(|label| {
issue
.labels
.iter()
.any(|(_, name)| name.to_ascii_lowercase() == *label)
})
});
}
}
sort_issues(&mut issues, filter.sort, filter.sort_asc);
Ok(issues)
}
pub fn issue_matches_query(issue: &LinearIssue, query_lower: &str) -> bool {
if query_lower.is_empty() {
return true;
}
issue.title.to_ascii_lowercase().contains(query_lower)
|| issue.identifier.to_ascii_lowercase().contains(query_lower)
|| issue
.assignee_name
.as_deref()
.is_some_and(|a| a.to_ascii_lowercase().contains(query_lower))
|| issue
.state_name
.as_deref()
.is_some_and(|s| s.to_ascii_lowercase().contains(query_lower))
|| issue
.description
.as_deref()
.is_some_and(|d| d.to_ascii_lowercase().contains(query_lower))
|| issue
.labels
.iter()
.any(|(_, name)| name.to_ascii_lowercase().contains(query_lower))
}
pub fn sort_issues(issues: &mut [LinearIssue], field: IssueSortField, ascending: bool) {
issues.sort_by(|a, b| {
let ord = match field {
IssueSortField::Updated => a
.updated_at
.as_deref()
.unwrap_or("")
.cmp(b.updated_at.as_deref().unwrap_or("")),
IssueSortField::Created => a
.created_at
.as_deref()
.unwrap_or("")
.cmp(b.created_at.as_deref().unwrap_or("")),
IssueSortField::Priority => {
let ap = if a.priority == 0 { 99 } else { a.priority };
let bp = if b.priority == 0 { 99 } else { b.priority };
ap.cmp(&bp)
}
IssueSortField::Identifier => a.identifier.cmp(&b.identifier),
IssueSortField::Title => a
.title
.to_ascii_lowercase()
.cmp(&b.title.to_ascii_lowercase()),
IssueSortField::State => a
.state_name
.as_deref()
.unwrap_or("")
.cmp(b.state_name.as_deref().unwrap_or("")),
};
if ascending {
ord
} else {
ord.reverse()
}
});
}
pub async fn get_issue(api_key: &str, id_or_identifier: &str) -> Result<LinearIssue, String> {
let id = id_or_identifier.trim();
if id.is_empty() {
return Err("Issue id or identifier is required.".into());
}
let query = format!(
r#"
query XbpLinearIssue($id: String!) {{
issue(id: $id) {{ {fields} }}
}}
"#,
fields = ISSUE_FIELDS
);
let body = linear_graphql_request(
api_key,
&json!({ "query": query, "variables": { "id": id } }),
)
.await?;
linear_graphql_errors(&body)?;
let value = body
.get("data")
.and_then(|d| d.get("issue"))
.cloned()
.filter(|v| !v.is_null())
.ok_or_else(|| format!("Linear issue `{id}` not found."))?;
let node: IssueNode =
serde_json::from_value(value).map_err(|e| format!("Failed to decode Linear issue: {e}"))?;
Ok(node.into())
}
pub async fn create_issue(api_key: &str, input: CreateIssueInput) -> Result<LinearIssue, String> {
let mut create_input = JsonMap::new();
create_input.insert("teamId".into(), json!(input.team_id));
create_input.insert("title".into(), json!(input.title));
if let Some(description) = input.description {
create_input.insert("description".into(), json!(description));
}
if let Some(priority) = input.priority {
create_input.insert("priority".into(), json!(priority));
}
if let Some(state_id) = input.state_id {
create_input.insert("stateId".into(), json!(state_id));
}
if let Some(assignee_id) = input.assignee_id {
create_input.insert("assigneeId".into(), json!(assignee_id));
}
if !input.label_ids.is_empty() {
create_input.insert("labelIds".into(), json!(input.label_ids));
}
if let Some(project_id) = input.project_id {
create_input.insert("projectId".into(), json!(project_id));
}
let query = format!(
r#"
mutation XbpLinearIssueCreate($input: IssueCreateInput!) {{
issueCreate(input: $input) {{
success
issue {{ {fields} }}
}}
}}
"#,
fields = ISSUE_FIELDS
);
let body = linear_graphql_request(
api_key,
&json!({
"query": query,
"variables": { "input": JsonValue::Object(create_input) }
}),
)
.await?;
linear_graphql_errors(&body)?;
let payload = body
.get("data")
.and_then(|d| d.get("issueCreate"))
.cloned()
.ok_or_else(|| "Linear issueCreate returned no data.".to_string())?;
if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
return Err("Linear issueCreate reported success=false.".into());
}
let issue = payload
.get("issue")
.cloned()
.ok_or_else(|| "Linear issueCreate returned no issue.".to_string())?;
let node: IssueNode =
serde_json::from_value(issue).map_err(|e| format!("Failed to decode created issue: {e}"))?;
Ok(node.into())
}
pub async fn archive_issue(api_key: &str, id_or_identifier: &str) -> Result<(), String> {
let existing = get_issue(api_key, id_or_identifier).await?;
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
mutation XbpLinearIssueArchive($id: String!) {
issueArchive(id: $id) {
success
}
}
"#,
"variables": { "id": existing.id }
}),
)
.await?;
linear_graphql_errors(&body)?;
let success = body
.get("data")
.and_then(|d| d.get("issueArchive"))
.and_then(|p| p.get("success"))
.and_then(JsonValue::as_bool)
.unwrap_or(false);
if !success {
return Err(format!(
"Linear issueArchive reported success=false for `{}`.",
existing.identifier
));
}
Ok(())
}
pub async fn mark_duplicate_of(
api_key: &str,
duplicate: &str,
canonical: &str,
) -> Result<LinearIssue, String> {
let dup = get_issue(api_key, duplicate).await?;
let canon = get_issue(api_key, canonical).await?;
let mut update = JsonMap::new();
update.insert("duplicateOfId".into(), json!(canon.id));
let query = format!(
r#"
mutation XbpLinearIssueDuplicate($id: String!, $input: IssueUpdateInput!) {{
issueUpdate(id: $id, input: $input) {{
success
issue {{ {fields} }}
}}
}}
"#,
fields = ISSUE_FIELDS
);
let body = linear_graphql_request(
api_key,
&json!({
"query": query,
"variables": {
"id": dup.id,
"input": JsonValue::Object(update)
}
}),
)
.await?;
linear_graphql_errors(&body)?;
let payload = body
.get("data")
.and_then(|d| d.get("issueUpdate"))
.cloned()
.ok_or_else(|| "Linear issueUpdate (duplicateOf) returned no data.".to_string())?;
if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
return Err("Linear issueUpdate (duplicateOf) reported success=false.".into());
}
let issue = payload
.get("issue")
.cloned()
.ok_or_else(|| "Linear issueUpdate returned no issue.".to_string())?;
let node: IssueNode =
serde_json::from_value(issue).map_err(|e| format!("Failed to decode updated issue: {e}"))?;
Ok(node.into())
}
pub async fn issues_for_attachment_url(
api_key: &str,
url: &str,
) -> Result<Vec<LinearIssue>, String> {
let url = url.trim();
if url.is_empty() {
return Ok(Vec::new());
}
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
query XbpAttachmentsForURL($url: String!) {
attachmentsForURL(url: $url) {
nodes {
id
url
issue {
id
identifier
title
description
priority
url
updatedAt
createdAt
state { id name type }
assignee { id name }
team { id key name }
labels { nodes { id name } }
}
}
}
}
"#,
"variables": { "url": url }
}),
)
.await?;
linear_graphql_errors(&body)?;
let nodes = body
.get("data")
.and_then(|d| d.get("attachmentsForURL"))
.and_then(|a| a.get("nodes"))
.and_then(|n| n.as_array())
.cloned()
.unwrap_or_default();
let mut issues = Vec::new();
for node in nodes {
if let Some(issue_val) = node.get("issue").cloned().filter(|v| !v.is_null()) {
if let Ok(issue_node) = serde_json::from_value::<IssueNode>(issue_val) {
issues.push(issue_node.into());
}
}
}
Ok(issues)
}
pub async fn attachment_link_url(
api_key: &str,
issue_id: &str,
url: &str,
title: Option<&str>,
) -> Result<(), String> {
let url = url.trim();
if url.is_empty() {
return Err("Attachment URL cannot be empty.".into());
}
let mut input = JsonMap::new();
input.insert("issueId".into(), json!(issue_id));
input.insert("url".into(), json!(url));
if let Some(t) = title.map(str::trim).filter(|s| !s.is_empty()) {
input.insert("title".into(), json!(t));
}
let body = linear_graphql_request(
api_key,
&json!({
"query": r#"
mutation XbpAttachmentLinkURL($input: AttachmentCreateInput!) {
attachmentCreate(input: $input) {
success
}
}
"#,
"variables": { "input": JsonValue::Object(input) }
}),
)
.await?;
if let Err(err) = linear_graphql_errors(&body) {
let body2 = linear_graphql_request(
api_key,
&json!({
"query": r#"
mutation XbpAttachmentLinkURL2($issueId: String!, $url: String!, $title: String) {
attachmentLinkURL(issueId: $issueId, url: $url, title: $title) {
success
}
}
"#,
"variables": {
"issueId": issue_id,
"url": url,
"title": title
}
}),
)
.await?;
if let Err(err2) = linear_graphql_errors(&body2) {
return Err(format!("Failed to attach URL: {err}; fallback: {err2}"));
}
return Ok(());
}
Ok(())
}
pub async fn update_issue(
api_key: &str,
id_or_identifier: &str,
input: UpdateIssueInput,
) -> Result<LinearIssue, String> {
let existing = get_issue(api_key, id_or_identifier).await?;
let mut update = JsonMap::new();
if let Some(title) = input.title {
update.insert("title".into(), json!(title));
}
if let Some(description) = input.description {
update.insert("description".into(), json!(description));
}
if let Some(priority) = input.priority {
update.insert("priority".into(), json!(priority));
}
if let Some(state_id) = input.state_id {
update.insert("stateId".into(), json!(state_id));
}
if let Some(assignee) = input.assignee_id {
match assignee {
Some(id) => {
update.insert("assigneeId".into(), json!(id));
}
None => {
update.insert("assigneeId".into(), JsonValue::Null);
}
}
}
if let Some(label_ids) = input.label_ids {
update.insert("labelIds".into(), json!(label_ids));
}
if let Some(project) = input.project_id {
match project {
Some(id) => {
update.insert("projectId".into(), json!(id));
}
None => {
update.insert("projectId".into(), JsonValue::Null);
}
}
}
if let Some(cycle) = input.cycle_id {
match cycle {
Some(id) => {
update.insert("cycleId".into(), json!(id));
}
None => {
update.insert("cycleId".into(), JsonValue::Null);
}
}
}
if update.is_empty() {
return Ok(existing);
}
let query = format!(
r#"
mutation XbpLinearIssueUpdate($id: String!, $input: IssueUpdateInput!) {{
issueUpdate(id: $id, input: $input) {{
success
issue {{ {fields} }}
}}
}}
"#,
fields = ISSUE_FIELDS
);
let body = linear_graphql_request(
api_key,
&json!({
"query": query,
"variables": {
"id": existing.id,
"input": JsonValue::Object(update)
}
}),
)
.await?;
linear_graphql_errors(&body)?;
let payload = body
.get("data")
.and_then(|d| d.get("issueUpdate"))
.cloned()
.ok_or_else(|| "Linear issueUpdate returned no data.".to_string())?;
if payload.get("success").and_then(JsonValue::as_bool) == Some(false) {
return Err("Linear issueUpdate reported success=false.".into());
}
let issue = payload
.get("issue")
.cloned()
.ok_or_else(|| "Linear issueUpdate returned no issue.".to_string())?;
let node: IssueNode =
serde_json::from_value(issue).map_err(|e| format!("Failed to decode updated issue: {e}"))?;
Ok(node.into())
}
pub async fn run_list(api_key: &str, args: LinearListCmd) -> Result<(), String> {
if args.tui {
let filter = build_list_filter_from_list_cmd(api_key, &args).await?;
return super::tui::run_linear_tui(api_key, filter).await;
}
let loader = Loader::start("Fetching Linear issues");
let filter = build_list_filter_from_list_cmd(api_key, &args).await;
let filter = match filter {
Ok(f) => f,
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
let issues = match list_issues(api_key, filter).await {
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
print_issue_table(&issues);
Ok(())
}
pub async fn build_list_filter_from_list_cmd(
api_key: &str,
args: &LinearListCmd,
) -> Result<ListIssuesFilter, String> {
let sort = IssueSortField::parse(&args.sort)?;
let team_id = if let Some(team) = args.team.as_deref() {
Some(resolve_team_id(api_key, team).await?)
} else {
None
};
let assignee_spec = if args.me {
Some("me")
} else {
args.assignee.as_deref()
};
let assignee_id = if let Some(assignee) = assignee_spec {
resolve_assignee_id(api_key, assignee).await?
} else {
None
};
Ok(ListIssuesFilter {
team_id,
state: args.state.clone(),
assignee_id,
query: args.query.clone(),
labels: args.labels.clone(),
include_completed: args.include_completed,
sort,
sort_asc: args.asc,
limit: args.limit,
})
}
pub async fn build_list_filter_from_hub_cmd(
api_key: &str,
args: &crate::cli::commands::LinearIssuesCmd,
) -> Result<ListIssuesFilter, String> {
let sort = IssueSortField::parse(&args.sort)?;
let team_id = if let Some(team) = args.team.as_deref() {
Some(resolve_team_id(api_key, team).await?)
} else {
None
};
let assignee_spec = if args.me {
Some("me")
} else {
args.assignee.as_deref()
};
let assignee_id = if let Some(assignee) = assignee_spec {
resolve_assignee_id(api_key, assignee).await?
} else {
None
};
Ok(ListIssuesFilter {
team_id,
state: args.state.clone(),
assignee_id,
query: args.query.clone(),
labels: args.labels.clone(),
include_completed: args.include_completed,
sort,
sort_asc: args.asc,
limit: args.limit,
})
}
pub async fn run_show(api_key: &str, args: LinearShowCmd) -> Result<(), String> {
let loader = Loader::start("Fetching Linear issue");
let issue = match get_issue(api_key, &args.id).await {
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
print_issue_detail(&issue);
Ok(())
}
pub async fn run_create(api_key: &str, args: LinearCreateCmd) -> Result<(), String> {
let team_id = match args.team.as_deref() {
Some(t) if !t.trim().is_empty() => resolve_team_id(api_key, t).await?,
_ => match default_team_hint() {
Some(t) => resolve_team_id(api_key, &t).await?,
None => match super::meta::resolve_team_id_auto(api_key, None).await {
Ok(id) => id,
Err(_) if is_tty() => pick_team_interactive(api_key).await?,
Err(e) => return Err(e),
},
},
};
let title = match args.title.as_deref() {
Some(t) if !t.trim().is_empty() => t.trim().to_string(),
_ if is_tty() => {
require_interactive("Creating a Linear issue")?;
Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Title")
.interact_text()
.map_err(|e| e.to_string())?
}
_ => return Err("Title is required (`--title`).".into()),
};
let description = match args.description.clone() {
Some(d) => Some(d),
None if args.interactive_body && is_tty() => {
let body: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Description (optional)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
if body.trim().is_empty() {
None
} else {
Some(body)
}
}
None => None,
};
let priority = match args.priority.as_deref() {
Some(p) => Some(parse_priority_arg(p)?),
None => None,
};
let state_id = match args.state.as_deref() {
Some(s) => Some(resolve_state_id(api_key, &team_id, s).await?),
None => None,
};
let assignee_id = match args.assignee.as_deref() {
Some(a) => resolve_assignee_id(api_key, a).await?,
None => None,
};
let label_ids = resolve_label_ids(api_key, Some(&team_id), &args.labels).await?;
let loader = Loader::start("Creating Linear issue");
let issue = match create_issue(
api_key,
CreateIssueInput {
team_id,
title,
description,
priority,
state_id,
assignee_id,
label_ids,
project_id: None,
},
)
.await
{
Ok(v) => {
loader.success_with(&format!("created {}", v.identifier));
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
print_issue_detail(&issue);
Ok(())
}
pub async fn run_edit(api_key: &str, args: LinearEditCmd) -> Result<(), String> {
let mut input = UpdateIssueInput {
title: args.title.clone(),
description: args.description.clone(),
priority: args
.priority
.as_deref()
.map(parse_priority_arg)
.transpose()?,
state_id: None,
assignee_id: None,
label_ids: None,
project_id: None,
cycle_id: None,
};
if input.title.is_none()
&& input.description.is_none()
&& input.priority.is_none()
&& args.state.is_none()
&& args.assignee.is_none()
&& args.labels.is_empty()
{
require_interactive("Editing a Linear issue")?;
let issue = get_issue(api_key, &args.id).await?;
let title: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Title")
.with_initial_text(&issue.title)
.interact_text()
.map_err(|e| e.to_string())?;
input.title = Some(title);
let description: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Description")
.with_initial_text(issue.description.as_deref().unwrap_or(""))
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
input.description = Some(description);
}
if let Some(state) = args.state.as_deref() {
let issue = get_issue(api_key, &args.id).await?;
let team_id = issue
.team_id
.ok_or_else(|| "Issue has no team; cannot resolve state.".to_string())?;
input.state_id = Some(resolve_state_id(api_key, &team_id, state).await?);
}
if let Some(assignee) = args.assignee.as_deref() {
input.assignee_id = Some(resolve_assignee_id(api_key, assignee).await?);
}
if !args.labels.is_empty() {
let issue = get_issue(api_key, &args.id).await?;
input.label_ids = Some(
resolve_label_ids(api_key, issue.team_id.as_deref(), &args.labels).await?,
);
}
let loader = Loader::start("Updating Linear issue");
let issue = match update_issue(api_key, &args.id, input).await {
Ok(v) => {
loader.success_with(&format!("updated {}", v.identifier));
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
print_issue_detail(&issue);
Ok(())
}
pub async fn run_status(api_key: &str, args: LinearStatusCmd) -> Result<(), String> {
let issue = get_issue(api_key, &args.id).await?;
let team_id = issue
.team_id
.clone()
.ok_or_else(|| "Issue has no team; cannot change status.".to_string())?;
let state = match args.state.as_deref() {
Some(s) if !s.trim().is_empty() => s.to_string(),
_ => {
require_interactive("Changing Linear issue status")?;
let states = list_workflow_states(api_key, &team_id).await?;
if states.is_empty() {
return Err("No workflow states found for this team.".into());
}
let labels: Vec<String> = states
.iter()
.map(|s| format!("{} ({})", s.name, s.state_type))
.collect();
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Status for {}", issue.identifier))
.items(&labels)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
states[idx].name.clone()
}
};
let state_id = resolve_state_id(api_key, &team_id, &state).await?;
let loader = Loader::start("Updating status");
let updated = match update_issue(
api_key,
&issue.id,
UpdateIssueInput {
state_id: Some(state_id),
..Default::default()
},
)
.await
{
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
println!(
"{} {} → {}",
"OK".bright_green().bold(),
updated.identifier.bright_white().bold(),
updated
.state_name
.as_deref()
.unwrap_or("?")
.bright_cyan()
);
Ok(())
}
pub async fn run_priority(api_key: &str, args: LinearPriorityCmd) -> Result<(), String> {
let priority = match args.priority.as_deref() {
Some(p) => parse_priority_arg(p)?,
None => {
require_interactive("Changing Linear issue priority")?;
let options = ["None", "Urgent", "High", "Medium", "Low"];
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Priority for {}", args.id))
.items(&options)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
idx as i32
}
};
let loader = Loader::start("Updating priority");
let updated = match update_issue(
api_key,
&args.id,
UpdateIssueInput {
priority: Some(priority),
..Default::default()
},
)
.await
{
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
println!(
"{} {} priority → {}",
"OK".bright_green().bold(),
updated.identifier.bright_white().bold(),
priority_label(updated.priority).bright_yellow()
);
Ok(())
}
pub async fn run_labels(api_key: &str, args: LinearLabelsCmd) -> Result<(), String> {
let issue = get_issue(api_key, &args.id).await?;
let team_id = issue.team_id.clone();
let label_ids = if !args.add.is_empty() || !args.set.is_empty() || args.clear {
if args.clear {
Vec::new()
} else if !args.set.is_empty() {
resolve_label_ids(api_key, team_id.as_deref(), &args.set).await?
} else {
let mut current: Vec<String> = issue.labels.iter().map(|(id, _)| id.clone()).collect();
let add_ids = resolve_label_ids(api_key, team_id.as_deref(), &args.add).await?;
for id in add_ids {
if !current.contains(&id) {
current.push(id);
}
}
if !args.remove.is_empty() {
let remove_ids =
resolve_label_ids(api_key, team_id.as_deref(), &args.remove).await?;
current.retain(|id| !remove_ids.contains(id));
}
current
}
} else {
require_interactive("Assigning Linear labels")?;
let known = super::meta::list_labels(api_key, team_id.as_deref()).await?;
if known.is_empty() {
return Err("No labels found for this workspace/team.".into());
}
let names: Vec<String> = known.iter().map(|l| l.name.clone()).collect();
let defaults: Vec<bool> = known
.iter()
.map(|l| issue.labels.iter().any(|(id, _)| id == &l.id))
.collect();
let selected = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Labels for {}", issue.identifier))
.items(&names)
.defaults(&defaults)
.interact()
.map_err(|e| e.to_string())?;
selected.into_iter().map(|i| known[i].id.clone()).collect()
};
let loader = Loader::start("Updating labels");
let updated = match update_issue(
api_key,
&issue.id,
UpdateIssueInput {
label_ids: Some(label_ids),
..Default::default()
},
)
.await
{
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
let label_str = if updated.labels.is_empty() {
"(none)".dimmed().to_string()
} else {
updated
.labels
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
};
println!(
"{} {} labels → {}",
"OK".bright_green().bold(),
updated.identifier.bright_white().bold(),
label_str
);
Ok(())
}
pub async fn run_assign(api_key: &str, args: LinearAssignCmd) -> Result<(), String> {
let assignee = match args.assignee.as_deref() {
Some(a) => a.to_string(),
None => {
require_interactive("Assigning a Linear issue")?;
let users = super::meta::list_users(api_key).await?;
let mut labels = vec!["(unassign)".to_string(), "me".to_string()];
labels.extend(users.iter().map(|u| {
format!(
"{}{}",
u.display_name.as_deref().unwrap_or(&u.name),
u.email
.as_ref()
.map(|e| format!(" <{e}>"))
.unwrap_or_default()
)
}));
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Assignee for {}", args.id))
.items(&labels)
.default(1)
.interact()
.map_err(|e| e.to_string())?;
if idx == 0 {
"none".to_string()
} else if idx == 1 {
"me".to_string()
} else {
users[idx - 2].id.clone()
}
}
};
let assignee_id = resolve_assignee_id(api_key, &assignee).await?;
let loader = Loader::start("Updating assignee");
let updated = match update_issue(
api_key,
&args.id,
UpdateIssueInput {
assignee_id: Some(assignee_id),
..Default::default()
},
)
.await
{
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
println!(
"{} {} assignee → {}",
"OK".bright_green().bold(),
updated.identifier.bright_white().bold(),
updated
.assignee_name
.as_deref()
.unwrap_or("(none)")
.bright_cyan()
);
Ok(())
}
pub fn print_issue_table(issues: &[LinearIssue]) {
if issues.is_empty() {
println!("{}", "No issues found.".dimmed());
return;
}
let rows: Vec<Vec<String>> = issues
.iter()
.map(|issue| {
vec![
issue.identifier.clone().bright_white().bold().to_string(),
issue
.state_name
.clone()
.unwrap_or_else(|| "-".into())
.bright_cyan()
.to_string(),
priority_label(issue.priority).bright_yellow().to_string(),
issue
.assignee_name
.clone()
.unwrap_or_else(|| "-".into()),
truncate_chars(&issue.title, 60),
]
})
.collect();
print!(
"{}",
render_table(
&["ID", "State", "Pri", "Assignee", "Title"],
&rows,
TableStyle::Pipe,
"",
)
);
println!("Total: {} issue(s)", issues.len());
}
pub fn print_issue_detail(issue: &LinearIssue) {
println!();
println!(
"{} {}",
issue.identifier.bright_white().bold(),
issue.title.bright_cyan()
);
if let Some(url) = &issue.url {
println!("{} {}", "url".dimmed(), url.underline());
}
println!(
"{} {}",
"state".dimmed(),
issue.state_name.as_deref().unwrap_or("-")
);
println!(
"{} {}",
"priority".dimmed(),
priority_label(issue.priority)
);
println!(
"{} {}",
"assignee".dimmed(),
issue.assignee_name.as_deref().unwrap_or("-")
);
println!(
"{} {}",
"team".dimmed(),
issue
.team_key
.as_deref()
.or(issue.team_name.as_deref())
.unwrap_or("-")
);
let labels = if issue.labels.is_empty() {
"-".to_string()
} else {
issue
.labels
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
};
println!("{} {}", "labels".dimmed(), labels);
if let Some(desc) = &issue.description {
if !desc.trim().is_empty() {
println!();
println!("{}", desc.trim());
}
}
println!();
}
async fn pick_team_interactive(api_key: &str) -> Result<String, String> {
require_interactive("Selecting a Linear team")?;
let teams = super::meta::list_teams(api_key).await?;
if teams.is_empty() {
return Err("No Linear teams available for this API key.".into());
}
let labels: Vec<String> = teams
.iter()
.map(|t| format!("{} — {}", t.key, t.name))
.collect();
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select Linear team")
.items(&labels)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
Ok(teams[idx].id.clone())
}
pub async fn prompt_and_save_default_team(api_key: &str) -> Result<(), String> {
require_interactive("Saving default Linear team")?;
let teams = super::meta::list_teams(api_key).await?;
if teams.is_empty() {
return Err("No Linear teams available for this API key.".into());
}
let labels: Vec<String> = teams
.iter()
.map(|t| format!("{} — {}", t.key, t.name))
.collect();
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Default Linear team for this repo")
.items(&labels)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
let selected = &teams[idx];
save_default_team_to_project(&selected.id, &selected.key)?;
println!(
"{} default team `{}` ({}) → .xbp/xbp.yaml",
"OK".bright_green().bold(),
selected.key.bright_white().bold(),
selected.name
);
Ok(())
}
fn save_default_team_to_project(team_id: &str, team_key: &str) -> Result<(), String> {
let cwd = env::current_dir().map_err(|e| format!("cwd: {e}"))?;
let found = find_xbp_config_upwards(&cwd).ok_or_else(|| {
"No XBP project config found. Run this inside a repo with `.xbp/xbp.yaml`.".to_string()
})?;
let content = fs::read_to_string(&found.config_path)
.map_err(|e| format!("Failed to read {}: {e}", found.config_path.display()))?;
let mut root: serde_json::Value = if found.kind == "yaml" {
let yaml: serde_yaml::Value =
serde_yaml::from_str(&content).map_err(|e| format!("parse yaml: {e}"))?;
serde_json::to_value(yaml).map_err(|e| e.to_string())?
} else {
serde_json::from_str(&content).map_err(|e| format!("parse json: {e}"))?
};
let obj = root
.as_object_mut()
.ok_or_else(|| "Project config root must be a mapping.".to_string())?;
let linear = obj
.entry("linear".to_string())
.or_insert_with(|| serde_json::json!({}));
if linear.is_null() {
*linear = serde_json::json!({});
}
let linear_obj = linear
.as_object_mut()
.ok_or_else(|| "Project `linear` must be an object.".to_string())?;
linear_obj.insert(
"default_team_id".into(),
serde_json::Value::String(team_id.to_string()),
);
linear_obj.insert(
"default_team_key".into(),
serde_json::Value::String(team_key.to_string()),
);
if found.kind == "yaml" {
let yaml_val: serde_yaml::Value =
serde_json::from_value(root).map_err(|e| e.to_string())?;
let yaml = serde_yaml::to_string(&yaml_val).map_err(|e| e.to_string())?;
fs::write(&found.config_path, yaml)
.map_err(|e| format!("Failed to write {}: {e}", found.config_path.display()))?;
} else {
let json = serde_json::to_string_pretty(&root).map_err(|e| e.to_string())?;
fs::write(&found.config_path, json + "\n")
.map_err(|e| format!("Failed to write {}: {e}", found.config_path.display()))?;
}
Ok(())
}
pub fn default_team_hint() -> Option<String> {
if let Ok(cwd) = env::current_dir() {
if let Some(found) = find_xbp_config_upwards(&cwd) {
if let Ok(content) = fs::read_to_string(&found.config_path) {
let linear = if found.kind == "yaml" {
serde_yaml::from_str::<XbpConfig>(&content)
.ok()
.and_then(|cfg| cfg.linear)
} else {
serde_json::from_str::<XbpConfig>(&content)
.ok()
.and_then(|cfg| cfg.linear)
};
if let Some(linear) = linear {
if let Some(id) = linear.default_team_id.filter(|s| !s.trim().is_empty()) {
return Some(id);
}
if let Some(key) = linear.default_team_key.filter(|s| !s.trim().is_empty()) {
return Some(key);
}
}
}
}
}
if let Ok(cfg) = SshConfig::load() {
if let Some(LinearConfig {
default_team_id,
default_team_key,
..
}) = cfg.linear
{
if let Some(id) = default_team_id.filter(|s| !s.trim().is_empty()) {
return Some(id);
}
if let Some(key) = default_team_key.filter(|s| !s.trim().is_empty()) {
return Some(key);
}
}
}
None
}
fn is_tty() -> bool {
crate::commands::cloudflare_config::is_interactive_terminal()
}