use anyhow::Result;
use clap::Subcommand;
use colored::Colorize;
use serde_json::{json, Map, Value};
use std::io::{self, BufRead};
use tabled::{Table, Tabled};
use crate::api::{
resolve_label_id, resolve_state_id, resolve_team_id, resolve_user_id, LinearClient,
};
use crate::cache::CacheOptions;
use crate::display_options;
use crate::input::read_ids_from_stdin;
use crate::output::{
ensure_non_empty, filter_values, print_json, print_json_owned, sort_values, OutputOptions,
};
use crate::pagination::{paginate_nodes, stream_nodes};
use crate::priority::priority_to_string;
use crate::text::truncate;
use crate::vcs::{generate_branch_name, run_git_command};
use crate::AgentOptions;
use super::templates;
#[derive(Subcommand)]
pub enum IssueCommands {
#[command(alias = "ls")]
#[command(after_help = r#"EXAMPLES:
linear issues list # List all issues
linear i list -t ENG # Filter by team
linear i list -t ENG -s "In Progress" # Filter by team and status
linear i list --assignee me # Show my assigned issues
linear i list --project "My Project" # Filter by project name
linear i list --output json # Output as JSON"#)]
List {
#[arg(short, long)]
team: Option<String>,
#[arg(short, long)]
state: Option<String>,
#[arg(short, long)]
assignee: Option<String>,
#[arg(long)]
mine: bool,
#[arg(long)]
project: Option<String>,
#[arg(short, long)]
label: Option<String>,
#[arg(long)]
view: Option<String>,
#[arg(long, alias = "newer-than")]
since: Option<String>,
#[arg(long)]
archived: bool,
#[arg(long)]
group_by: Option<String>,
#[arg(long)]
count_only: bool,
},
#[command(after_help = r#"EXAMPLES:
linear issues get LIN-123 # View issue by identifier
linear i get abc123-uuid # View issue by ID
linear i get LIN-1 LIN-2 LIN-3 # Get multiple issues
linear i get LIN-123 --output json # Output as JSON
echo "LIN-123" | linear i get - # Read ID from stdin (piping)"#)]
Get {
ids: Vec<String>,
#[arg(long)]
history: bool,
#[arg(long)]
comments: bool,
},
Open {
id: String,
},
#[command(after_help = r#"EXAMPLES:
linear issues create "Fix bug" -t ENG # Create with title and team
linear i create "Feature" -t ENG -p 2 # Create with high priority
linear i create "Task" -t ENG -a me # Assign to yourself
linear i create "Task" -t ENG --due +3d # Due in 3 days
linear i create "Bug" -t ENG --dry-run # Preview without creating"#)]
Create {
title: String,
#[arg(short, long)]
team: Option<String>,
#[arg(short, long)]
description: Option<String>,
#[arg(long)]
data: Option<String>,
#[arg(short, long)]
priority: Option<i32>,
#[arg(short, long)]
state: Option<String>,
#[arg(short, long)]
assignee: Option<String>,
#[arg(short, long)]
labels: Vec<String>,
#[arg(long)]
due: Option<String>,
#[arg(short, long)]
estimate: Option<f64>,
#[arg(long)]
template: Option<String>,
#[arg(long)]
dry_run: bool,
},
#[command(after_help = r#"EXAMPLES:
linear issues update LIN-123 -s Done # Mark as done
linear i update LIN-123 -T "New title" # Change title
linear i update LIN-123 -p 1 # Set to urgent priority
linear i update LIN-123 --due tomorrow # Due tomorrow
linear i update LIN-123 -a me # Assign to yourself
linear i update LIN-123 -l bug -l urgent # Add labels"#)]
Update {
id: String,
#[arg(short = 'T', long)]
title: Option<String>,
#[arg(short, long)]
description: Option<String>,
#[arg(long)]
data: Option<String>,
#[arg(short, long)]
priority: Option<i32>,
#[arg(short, long)]
state: Option<String>,
#[arg(short, long)]
assignee: Option<String>,
#[arg(short, long)]
labels: Vec<String>,
#[arg(long)]
due: Option<String>,
#[arg(short, long)]
estimate: Option<f64>,
#[arg(long)]
dry_run: bool,
},
#[command(after_help = r#"EXAMPLES:
linear issues delete LIN-123 # Delete with confirmation
linear i delete LIN-123 --force # Delete without confirmation"#)]
Delete {
id: String,
#[arg(short, long)]
force: bool,
},
#[command(after_help = r#"EXAMPLES:
linear issues start LIN-123 # Start working on issue
linear i start LIN-123 --checkout # Start and checkout git branch
linear i start LIN-123 -c -b feature/fix # Start with custom branch"#)]
Start {
id: String,
#[arg(short, long)]
checkout: bool,
#[arg(short, long)]
branch: Option<String>,
},
#[command(after_help = r#"EXAMPLES:
linear issues stop LIN-123 # Stop working on issue
linear i stop LIN-123 --unassign # Stop and unassign"#)]
Stop {
id: String,
#[arg(short, long)]
unassign: bool,
},
#[command(alias = "done")]
Close {
id: String,
},
Archive {
id: String,
},
Unarchive {
id: String,
},
Comment {
id: String,
#[arg(short, long)]
body: String,
},
Link {
id: String,
},
Assign {
id: String,
user: Option<String>,
},
#[command(alias = "mv")]
Move {
id: String,
project: String,
},
Transfer {
id: String,
team: String,
},
}
#[derive(Tabled)]
struct IssueRow {
#[tabled(rename = "ID")]
identifier: String,
#[tabled(rename = "Title")]
title: String,
#[tabled(rename = "State")]
state: String,
#[tabled(rename = "Priority")]
priority: String,
#[tabled(rename = "Assignee")]
assignee: String,
}
pub async fn handle(
cmd: IssueCommands,
output: &OutputOptions,
agent_opts: AgentOptions,
) -> Result<()> {
match cmd {
IssueCommands::List {
team,
state,
assignee,
mine,
project,
label,
view,
since,
archived,
group_by,
count_only,
} => {
let assignee = if mine { Some("me".to_string()) } else { assignee };
list_issues(team, state, assignee, project, label, view, since, archived, group_by, count_only, output, agent_opts).await
}
IssueCommands::Get { ids, history, comments } => {
let final_ids = read_ids_from_stdin(ids);
if final_ids.is_empty() {
anyhow::bail!(
"No issue IDs provided. Provide IDs as arguments or pipe them via stdin."
);
}
get_issues(&final_ids, output, history, comments).await
}
IssueCommands::Open { id } => open_issue(&id).await,
IssueCommands::Create {
title,
team,
description,
data,
priority,
state,
assignee,
labels,
due,
estimate,
template,
dry_run,
} => {
let dry_run = dry_run || output.dry_run || agent_opts.dry_run;
let tpl = if let Some(ref tpl_name) = template {
templates::get_template(tpl_name)?
.ok_or_else(|| anyhow::anyhow!("Template not found: {}", tpl_name))?
} else {
templates::IssueTemplate {
name: String::new(),
title_prefix: None,
description: None,
default_priority: None,
default_labels: vec![],
team: None,
}
};
let data_json = read_json_data(data.as_deref())?;
let data_team = data_json.as_ref().and_then(|v| {
v.get("team")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
});
let data_team_id = data_json.as_ref().and_then(|v| {
v.get("teamId")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
});
let final_team = team
.or(tpl.team.clone())
.or(data_team)
.or(data_team_id)
.ok_or_else(|| {
anyhow::anyhow!("--team is required (or use a template with a default team)")
})?;
let final_title = if let Some(ref prefix) = tpl.title_prefix {
format!("{} {}", prefix, title)
} else {
title
};
if data.as_deref() == Some("-") && description.as_deref() == Some("-") {
anyhow::bail!("--data - and --description - cannot both read from stdin");
}
let final_description = match description.as_deref() {
Some("-") => {
let stdin = io::stdin();
let lines: Vec<String> = stdin.lock().lines().map_while(Result::ok).collect();
Some(lines.join("\n"))
}
Some(d) => Some(d.to_string()),
None => tpl.description.clone(),
};
let final_priority = priority.or(tpl.default_priority);
let mut final_labels = tpl.default_labels.clone();
final_labels.extend(labels);
create_issue(
&final_title,
&final_team,
data_json,
final_description,
final_priority,
state,
assignee,
final_labels,
due,
estimate,
output,
agent_opts,
dry_run,
)
.await
}
IssueCommands::Update {
id,
title,
description,
data,
priority,
state,
assignee,
labels,
due,
estimate,
dry_run,
} => {
let dry_run = dry_run || output.dry_run || agent_opts.dry_run;
if data.as_deref() == Some("-") && description.as_deref() == Some("-") {
anyhow::bail!("--data - and --description - cannot both read from stdin");
}
let data_json = read_json_data(data.as_deref())?;
let final_description = match description.as_deref() {
Some("-") => {
let stdin = io::stdin();
let lines: Vec<String> = stdin.lock().lines().map_while(Result::ok).collect();
Some(lines.join("\n"))
}
Some(d) => Some(d.to_string()),
None => None,
};
update_issue(
&id,
title,
final_description,
data_json,
priority,
state,
assignee,
labels,
due,
estimate,
dry_run,
output,
agent_opts,
)
.await
}
IssueCommands::Delete { id, force } => delete_issue(&id, force, agent_opts).await,
IssueCommands::Start {
id,
checkout,
branch,
} => start_issue(&id, checkout, branch, agent_opts).await,
IssueCommands::Stop { id, unassign } => stop_issue(&id, unassign, agent_opts).await,
IssueCommands::Close { id } => close_issue(&id).await,
IssueCommands::Archive { id } => archive_issue(&id, true).await,
IssueCommands::Unarchive { id } => archive_issue(&id, false).await,
IssueCommands::Comment { id, body } => comment_issue(&id, &body).await,
IssueCommands::Link { id } => link_issue(&id).await,
IssueCommands::Assign { id, user } => assign_issue(&id, user).await,
IssueCommands::Move { id, project } => move_issue(&id, &project).await,
IssueCommands::Transfer { id, team } => transfer_issue(&id, &team).await,
}
}
#[allow(clippy::too_many_arguments)]
async fn list_issues(
team: Option<String>,
state: Option<String>,
assignee: Option<String>,
project: Option<String>,
label: Option<String>,
view: Option<String>,
since: Option<String>,
include_archived: bool,
group_by: Option<String>,
count_only: bool,
output: &OutputOptions,
_agent_opts: AgentOptions,
) -> Result<()> {
let client = LinearClient::new()?;
let since_date = if let Some(ref since_str) = since {
let date = crate::dates::parse_due_date(since_str)
.ok_or_else(|| anyhow::anyhow!("Invalid --since date: '{}'. Use today, -7d, 2024-01-15, etc.", since_str))?;
Some(format!("{}T00:00:00.000Z", date))
} else {
None
};
let filter_data = if let Some(ref view_name) = view {
Some(super::views::fetch_view_filter(&client, view_name, &output.cache).await?)
} else {
None
};
let use_filter_query = filter_data.is_some() || since_date.is_some() || label.is_some();
let query = if use_filter_query {
r#"
query($filter: IssueFilter, $includeArchived: Boolean, $first: Int, $after: String, $last: Int, $before: String) {
issues(
first: $first,
after: $after,
last: $last,
before: $before,
includeArchived: $includeArchived,
filter: $filter
) {
nodes {
id
identifier
title
priority
state { name }
assignee { name }
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
"#
} else {
r#"
query($team: String, $state: String, $assignee: String, $project: String, $includeArchived: Boolean, $first: Int, $after: String, $last: Int, $before: String) {
issues(
first: $first,
after: $after,
last: $last,
before: $before,
includeArchived: $includeArchived,
filter: {
team: { name: { eqIgnoreCase: $team } },
state: { name: { eqIgnoreCase: $state } },
assignee: { name: { eqIgnoreCase: $assignee } },
project: { name: { eqIgnoreCase: $project } }
}
) {
nodes {
id
identifier
title
priority
state { name }
assignee { name }
}
pageInfo {
hasNextPage
endCursor
hasPreviousPage
startCursor
}
}
}
"#
};
let mut variables = Map::new();
variables.insert("includeArchived".to_string(), json!(include_archived));
if let Some(ref fd) = filter_data {
let mut filter = fd.clone();
if let Some(ref since_ts) = since_date {
if let Some(obj) = filter.as_object_mut() {
obj.insert("createdAt".to_string(), json!({ "gte": since_ts }));
}
}
if let Some(t) = team {
if let Some(obj) = filter.as_object_mut() {
obj.insert("team".to_string(), json!({ "name": { "eqIgnoreCase": t } }));
}
}
if let Some(s) = state {
if let Some(obj) = filter.as_object_mut() {
obj.insert("state".to_string(), json!({ "name": { "eqIgnoreCase": s } }));
}
}
if let Some(a) = assignee {
if let Some(obj) = filter.as_object_mut() {
obj.insert("assignee".to_string(), json!({ "name": { "eqIgnoreCase": a } }));
}
}
if let Some(p) = project {
if let Some(obj) = filter.as_object_mut() {
obj.insert("project".to_string(), json!({ "name": { "eqIgnoreCase": p } }));
}
}
if let Some(ref l) = label {
if let Some(obj) = filter.as_object_mut() {
obj.insert("labels".to_string(), json!({ "name": { "eqIgnoreCase": l } }));
}
}
variables.insert("filter".to_string(), filter);
} else if since_date.is_some() || label.is_some() {
let mut filter = json!({});
if let Some(ref since_ts) = since_date {
filter["createdAt"] = json!({ "gte": since_ts });
}
if let Some(t) = team {
filter["team"] = json!({ "name": { "eqIgnoreCase": t } });
}
if let Some(s) = state {
filter["state"] = json!({ "name": { "eqIgnoreCase": s } });
}
if let Some(a) = assignee {
filter["assignee"] = json!({ "name": { "eqIgnoreCase": a } });
}
if let Some(p) = project {
filter["project"] = json!({ "name": { "eqIgnoreCase": p } });
}
if let Some(ref l) = label {
filter["labels"] = json!({ "name": { "eqIgnoreCase": l } });
}
variables.insert("filter".to_string(), filter);
} else {
if let Some(t) = team {
variables.insert("team".to_string(), json!(t));
}
if let Some(s) = state {
variables.insert("state".to_string(), json!(s));
}
if let Some(a) = assignee {
variables.insert("assignee".to_string(), json!(a));
}
if let Some(p) = project {
variables.insert("project".to_string(), json!(p));
}
}
let pagination = output.pagination.with_default_limit(50);
if output.is_ndjson() {
let mut count = 0;
stream_nodes(
&client,
query,
variables,
&["data", "issues", "nodes"],
&["data", "issues", "pageInfo"],
&pagination,
50,
|batch| {
count += batch.len();
async move {
for issue in batch {
println!("{}", serde_json::to_string(&issue)?);
}
Ok(())
}
},
)
.await?;
return Ok(());
}
let issues = paginate_nodes(
&client,
query,
variables,
&["data", "issues", "nodes"],
&["data", "issues", "pageInfo"],
&pagination,
50,
)
.await?;
if output.is_json() || output.has_template() {
print_json_owned(serde_json::json!(issues), output)?;
return Ok(());
}
let mut issues = issues;
filter_values(&mut issues, &output.filters);
if let Some(sort_key) = output.json.sort.as_deref() {
sort_values(&mut issues, sort_key, output.json.order);
}
ensure_non_empty(&issues, output)?;
if issues.is_empty() {
if count_only {
println!("0");
} else {
println!("No issues found.");
}
return Ok(());
}
if count_only {
println!("{}", issues.len());
return Ok(());
}
let width = display_options().max_width(50);
if let Some(ref group_field) = group_by {
let key_fn: Box<dyn Fn(&serde_json::Value) -> String> = match group_field.as_str() {
"state" | "status" => Box::new(|issue: &serde_json::Value| {
issue["state"]["name"].as_str().unwrap_or("Unknown").to_string()
}),
"priority" => Box::new(|issue: &serde_json::Value| {
priority_to_string(issue["priority"].as_i64())
}),
"assignee" => Box::new(|issue: &serde_json::Value| {
issue["assignee"]["name"].as_str().unwrap_or("Unassigned").to_string()
}),
"project" => Box::new(|issue: &serde_json::Value| {
issue["project"]["name"].as_str().unwrap_or("No Project").to_string()
}),
other => anyhow::bail!("Unknown --group-by field: '{}'. Use state, priority, assignee, or project.", other),
};
let mut groups: Vec<(String, Vec<&serde_json::Value>)> = Vec::new();
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for issue in &issues {
let key = key_fn(issue);
if let Some(&idx) = seen.get(&key) {
groups[idx].1.push(issue);
} else {
seen.insert(key.clone(), groups.len());
groups.push((key, vec![issue]));
}
}
for (group_name, group_issues) in &groups {
println!("\n{} ({})", group_name.cyan().bold(), group_issues.len());
println!("{}", "-".repeat(50));
for issue in group_issues {
let id = issue["identifier"].as_str().unwrap_or("");
let title = truncate(issue["title"].as_str().unwrap_or(""), width);
println!(" {} {}", id.cyan(), title);
}
}
println!("\n{} issues in {} groups", issues.len(), groups.len());
return Ok(());
}
let rows: Vec<IssueRow> = issues
.iter()
.map(|issue| IssueRow {
identifier: issue["identifier"].as_str().unwrap_or("").to_string(),
title: truncate(issue["title"].as_str().unwrap_or(""), width),
state: issue["state"]["name"].as_str().unwrap_or("-").to_string(),
priority: priority_to_string(issue["priority"].as_i64()),
assignee: issue["assignee"]["name"]
.as_str()
.unwrap_or("-")
.to_string(),
})
.collect();
let table = Table::new(rows).to_string();
println!("{}", table);
println!("\n{} issues", issues.len());
Ok(())
}
async fn get_issues(ids: &[String], output: &OutputOptions, history: bool, comments: bool) -> Result<()> {
if ids.len() == 1 {
return get_issue(&ids[0], output, history, comments).await;
}
let client = LinearClient::new()?;
use futures::stream::{self, StreamExt};
const MAX_CONCURRENT: usize = 10;
let results: Vec<_> = stream::iter(ids.iter().cloned())
.map(|id| {
let client = client.clone();
async move {
let query = r#"
query($id: String!) {
issue(id: $id) {
id
identifier
title
description
priority
url
state { name }
team { name }
assignee { name }
}
}
"#;
let result = client.query(query, Some(json!({ "id": id }))).await;
(id, result)
}
})
.buffer_unordered(MAX_CONCURRENT)
.collect()
.await;
if output.is_json() || output.has_template() {
let issues: Vec<_> = results
.iter()
.filter_map(|(_, r)| {
r.as_ref().ok().and_then(|data| {
let issue = &data["data"]["issue"];
if !issue.is_null() {
Some(issue.clone())
} else {
None
}
})
})
.collect();
print_json_owned(serde_json::json!(issues), output)?;
return Ok(());
}
for (id, result) in results {
match result {
Ok(data) => {
let issue = &data["data"]["issue"];
if issue.is_null() {
eprintln!("{} Issue not found: {}", "!".yellow(), id);
} else {
let identifier = issue["identifier"].as_str().unwrap_or("");
let title = issue["title"].as_str().unwrap_or("");
let state = issue["state"]["name"].as_str().unwrap_or("-");
let priority = priority_to_string(issue["priority"].as_i64());
println!("{} {} [{}] {}", identifier.cyan(), title, state, priority);
}
}
Err(e) => {
eprintln!("{} Error fetching {}: {}", "!".red(), id, e);
}
}
}
Ok(())
}
async fn open_issue(id: &str) -> Result<()> {
let client = LinearClient::new()?;
let query = r#"
query($id: String!) {
issue(id: $id) {
identifier
url
}
}
"#;
let result = client.query(query, Some(json!({ "id": id }))).await?;
let issue = &result["data"]["issue"];
if issue.is_null() {
anyhow::bail!("Issue not found: {}", id);
}
let url = issue["url"].as_str().unwrap_or("");
if url.is_empty() {
anyhow::bail!("No URL for issue: {}", id);
}
let identifier = issue["identifier"].as_str().unwrap_or(id);
println!("Opening {} in browser...", identifier);
open::that(url)?;
Ok(())
}
fn format_history_entry(entry: &serde_json::Value) -> String {
let mut parts = Vec::new();
if let (Some(from), Some(to)) = (
entry["fromState"]["name"].as_str(),
entry["toState"]["name"].as_str(),
) {
parts.push(format!("Status: {} → {}", from, to));
}
match (
entry["fromAssignee"]["name"].as_str(),
entry["toAssignee"]["name"].as_str(),
) {
(Some(from), Some(to)) => parts.push(format!("Assignee: {} → {}", from, to)),
(None, Some(to)) => parts.push(format!("Assigned to {}", to)),
(Some(from), None) => parts.push(format!("Unassigned from {}", from)),
_ => {}
}
if let (Some(from), Some(to)) = (entry["fromPriority"].as_f64(), entry["toPriority"].as_f64()) {
let from_i = from as i64;
let to_i = to as i64;
if from_i != to_i {
parts.push(format!(
"Priority: {} → {}",
priority_to_string(Some(from_i)),
priority_to_string(Some(to_i))
));
}
}
if entry["fromTitle"].is_string() && entry["toTitle"].is_string() {
parts.push("Title updated".to_string());
}
if entry["updatedDescription"].as_bool() == Some(true) {
parts.push("Description updated".to_string());
}
if let Some(added) = entry["addedLabels"].as_array() {
if !added.is_empty() {
let names: Vec<&str> = added.iter().filter_map(|l| l["name"].as_str()).collect();
if !names.is_empty() {
parts.push(format!("Added labels: {}", names.join(", ")));
}
}
}
if let Some(removed) = entry["removedLabels"].as_array() {
if !removed.is_empty() {
let names: Vec<&str> = removed.iter().filter_map(|l| l["name"].as_str()).collect();
if !names.is_empty() {
parts.push(format!("Removed labels: {}", names.join(", ")));
}
}
}
match (
entry["fromProject"]["name"].as_str(),
entry["toProject"]["name"].as_str(),
) {
(Some(from), Some(to)) => parts.push(format!("Project: {} → {}", from, to)),
(None, Some(to)) => parts.push(format!("Added to project {}", to)),
(Some(from), None) => parts.push(format!("Removed from project {}", from)),
_ => {}
}
if entry["archived"].as_bool() == Some(true) {
parts.push("Archived".to_string());
}
if entry["trashed"].as_bool() == Some(true) {
parts.push("Trashed".to_string());
}
parts.join("; ")
}
async fn get_issue(id: &str, output: &OutputOptions, history: bool, comments: bool) -> Result<()> {
let client = LinearClient::new()?;
let history_fragment = if history {
r#"
history(first: 15) {
nodes {
createdAt
actor { name }
fromState { name }
toState { name }
fromAssignee { name }
toAssignee { name }
fromPriority
toPriority
fromTitle
toTitle
updatedDescription
addedLabels { name }
removedLabels { name }
fromProject { name }
toProject { name }
archived
trashed
}
}"#
} else {
""
};
let comments_fragment = if comments {
r#"
comments(first: 20) {
nodes {
createdAt
body
user { name }
}
}"#
} else {
""
};
let query = format!(
r#"
query($id: String!) {{
issue(id: $id) {{
id
identifier
title
description
priority
url
createdAt
updatedAt
state {{ name }}
team {{ name }}
assignee {{ name email }}
labels {{ nodes {{ name color }} }}
project {{ name }}
parent {{ identifier title }}
children {{ nodes {{ identifier title state {{ name }} }} }}
dueDate
estimate
{}
{}
}}
}}
"#,
history_fragment, comments_fragment
);
let result = client.query(&query, Some(json!({ "id": id }))).await?;
let issue = &result["data"]["issue"];
if issue.is_null() {
anyhow::bail!("Issue not found: {}", id);
}
if output.is_json() || output.has_template() {
print_json(issue, output)?;
return Ok(());
}
let identifier = issue["identifier"].as_str().unwrap_or("");
let title = issue["title"].as_str().unwrap_or("");
println!("{} {}", identifier.cyan().bold(), title.bold());
println!("{}", "-".repeat(60));
if let Some(desc) = issue["description"].as_str() {
if !desc.is_empty() {
println!("\n{}", crate::text::strip_markdown(desc));
println!();
}
}
println!(
"State: {}",
issue["state"]["name"].as_str().unwrap_or("-")
);
println!(
"Priority: {}",
priority_to_string(issue["priority"].as_i64())
);
println!(
"Team: {}",
issue["team"]["name"].as_str().unwrap_or("-")
);
if let Some(assignee) = issue["assignee"]["name"].as_str() {
let email = issue["assignee"]["email"].as_str().unwrap_or("");
if !email.is_empty() {
println!("Assignee: {} ({})", assignee, email.dimmed());
} else {
println!("Assignee: {}", assignee);
}
} else {
println!("Assignee: -");
}
if let Some(project) = issue["project"]["name"].as_str() {
println!("Project: {}", project);
}
if let Some(parent) = issue["parent"]["identifier"].as_str() {
let parent_title = issue["parent"]["title"].as_str().unwrap_or("");
println!("Parent: {} {}", parent, parent_title.dimmed());
}
let labels = issue["labels"]["nodes"].as_array();
if let Some(labels) = labels {
if !labels.is_empty() {
let label_names: Vec<&str> = labels.iter().filter_map(|l| l["name"].as_str()).collect();
println!("Labels: {}", label_names.join(", "));
}
}
if let Some(due) = issue["dueDate"].as_str() {
println!("Due: {}", due);
}
if let Some(est) = issue["estimate"].as_f64() {
println!("Estimate: {}", est);
}
if let Some(children) = issue["children"]["nodes"].as_array() {
if !children.is_empty() {
println!("\n{} ({}):", "Sub-issues".bold(), children.len());
for child in children {
let cid = child["identifier"].as_str().unwrap_or("");
let ctitle = child["title"].as_str().unwrap_or("");
let cstate = child["state"]["name"].as_str().unwrap_or("-");
println!(" {} {} [{}]", cid.cyan(), ctitle, cstate);
}
}
}
println!("\nURL: {}", issue["url"].as_str().unwrap_or("-"));
println!("ID: {}", issue["id"].as_str().unwrap_or("-"));
if history {
if let Some(entries) = issue["history"]["nodes"].as_array() {
if !entries.is_empty() {
println!("\n{}", "Activity".bold());
println!("{}", "-".repeat(60));
for entry in entries {
let ts = entry["createdAt"].as_str().unwrap_or("");
let date = if ts.len() >= 10 { &ts[..10] } else { ts };
let actor = entry["actor"]["name"].as_str().unwrap_or("System");
let desc = format_history_entry(entry);
if !desc.is_empty() {
println!(" {} {} — {}", date.dimmed(), actor, desc);
}
}
}
}
}
if comments {
if let Some(comment_nodes) = issue["comments"]["nodes"].as_array() {
if !comment_nodes.is_empty() {
println!("\n{} ({}):", "Comments".bold(), comment_nodes.len());
println!("{}", "-".repeat(60));
for comment in comment_nodes {
let ts = comment["createdAt"].as_str().unwrap_or("");
let date = if ts.len() >= 10 { &ts[..10] } else { ts };
let author = comment["user"]["name"].as_str().unwrap_or("Unknown");
let body = comment["body"].as_str().unwrap_or("");
println!("\n {} {} {}:", date.dimmed(), "by".dimmed(), author.cyan());
for line in crate::text::strip_markdown(body).lines() {
println!(" {}", line);
}
}
} else {
println!("\nNo comments.");
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn create_issue(
title: &str,
team: &str,
data_json: Option<Value>,
description: Option<String>,
priority: Option<i32>,
state: Option<String>,
assignee: Option<String>,
labels: Vec<String>,
due: Option<String>,
estimate: Option<f64>,
output: &OutputOptions,
agent_opts: AgentOptions,
dry_run: bool,
) -> Result<()> {
let client = LinearClient::new()?;
let final_team = team;
let team_id = resolve_team_id(&client, final_team, &output.cache).await?;
let final_title = title.to_string();
let mut input = match data_json {
Some(Value::Object(map)) => Value::Object(map),
Some(_) => anyhow::bail!("--data must be a JSON object"),
None => json!({}),
};
input["title"] = json!(final_title);
input["teamId"] = json!(team_id);
if let Some(ref desc) = description {
input["description"] = json!(desc);
}
if let Some(p) = priority {
input["priority"] = json!(p);
}
if let Some(ref s) = state {
if dry_run {
input["stateId"] = json!(s);
} else {
let state_id = resolve_state_id(&client, &team_id, s).await?;
input["stateId"] = json!(state_id);
}
}
if let Some(ref a) = assignee {
if dry_run {
input["assigneeId"] = json!(a);
} else {
let assignee_id = resolve_user_id(&client, a, &output.cache).await?;
input["assigneeId"] = json!(assignee_id);
}
}
if !labels.is_empty() {
if dry_run {
let mut label_ids: Vec<String> = input["labelIds"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
label_ids.extend(labels.clone());
input["labelIds"] = json!(label_ids);
} else {
let mut label_ids: Vec<String> = input["labelIds"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
for label in &labels {
let label_id = resolve_label_id(&client, label, &output.cache).await?;
label_ids.push(label_id);
}
input["labelIds"] = json!(label_ids);
}
}
if let Some(ref d) = due {
if let Some(parsed) = crate::dates::parse_due_date(d) {
input["dueDate"] = json!(parsed);
} else {
input["dueDate"] = json!(d);
}
}
if let Some(e) = estimate {
input["estimate"] = json!(e);
}
if dry_run {
if output.is_json() || output.has_template() {
print_json_owned(
json!({
"dry_run": true,
"would_create": {
"title": final_title,
"team": final_team,
"teamId": team_id,
"description": description,
"priority": priority,
"state": state,
"assignee": assignee,
"labels": labels,
"dueDate": due,
"estimate": estimate,
}
}),
output,
)?;
} else {
println!("{}", "[DRY RUN] Would create issue:".yellow().bold());
println!(" Title: {}", final_title);
println!(" Team: {} ({})", final_team, team_id);
if let Some(ref desc) = description {
let preview = if desc.len() > 50 {
format!("{}...", &desc[..50])
} else {
desc.clone()
};
println!(" Description: {}", preview);
}
if let Some(p) = priority {
println!(" Priority: {}", p);
}
if let Some(ref s) = state {
println!(" State: {}", s);
}
if let Some(ref a) = assignee {
println!(" Assignee: {}", a);
}
if !labels.is_empty() {
println!(" Labels: {}", labels.join(", "));
}
if let Some(ref d) = due {
println!(" Due: {}", d);
}
if let Some(e) = estimate {
println!(" Estimate: {}", e);
}
}
return Ok(());
}
let mutation = r#"
mutation($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
url
}
}
}
"#;
let result = client
.mutate(mutation, Some(json!({ "input": input })))
.await?;
if result["data"]["issueCreate"]["success"].as_bool() == Some(true) {
let issue = &result["data"]["issueCreate"]["issue"];
let identifier = issue["identifier"].as_str().unwrap_or("");
if agent_opts.id_only {
println!("{}", identifier);
return Ok(());
}
if output.is_json() || output.has_template() {
print_json(issue, output)?;
return Ok(());
}
if agent_opts.quiet {
println!("{}", identifier);
return Ok(());
}
let issue_title = issue["title"].as_str().unwrap_or("");
println!(
"{} Created issue: {} {}",
"+".green(),
identifier.cyan(),
issue_title
);
println!(" ID: {}", issue["id"].as_str().unwrap_or(""));
println!(" URL: {}", issue["url"].as_str().unwrap_or(""));
} else {
anyhow::bail!("Failed to create issue");
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn update_issue(
id: &str,
title: Option<String>,
description: Option<String>,
data_json: Option<Value>,
priority: Option<i32>,
state: Option<String>,
assignee: Option<String>,
labels: Vec<String>,
due: Option<String>,
estimate: Option<f64>,
dry_run: bool,
output: &OutputOptions,
agent_opts: AgentOptions,
) -> Result<()> {
let client = LinearClient::new()?;
let mut input = match data_json {
Some(Value::Object(map)) => Value::Object(map),
Some(_) => anyhow::bail!("--data must be a JSON object"),
None => json!({}),
};
if let Some(t) = title {
input["title"] = json!(t);
}
if let Some(d) = description {
input["description"] = json!(d);
}
if let Some(p) = priority {
input["priority"] = json!(p);
}
if let Some(s) = state {
if dry_run {
input["stateId"] = json!(s);
} else {
let team_query = r#"
query($id: String!) {
issue(id: $id) {
team { id }
}
}
"#;
let team_result = client.query(team_query, Some(json!({ "id": id }))).await?;
let issue_team_id = team_result["data"]["issue"]["team"]["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Could not determine team for issue {}", id))?;
let state_id = resolve_state_id(&client, issue_team_id, &s).await?;
input["stateId"] = json!(state_id);
}
}
if let Some(a) = assignee {
if dry_run {
input["assigneeId"] = json!(a);
} else {
let assignee_id = resolve_user_id(&client, &a, &output.cache).await?;
input["assigneeId"] = json!(assignee_id);
}
}
if !labels.is_empty() {
if dry_run {
input["labelIds"] = json!(labels);
} else {
let mut label_ids = Vec::new();
for label in &labels {
let label_id = resolve_label_id(&client, label, &output.cache).await?;
label_ids.push(label_id);
}
input["labelIds"] = json!(label_ids);
}
}
if let Some(ref d) = due {
if d.eq_ignore_ascii_case("none") || d.eq_ignore_ascii_case("clear") {
input["dueDate"] = json!(null);
} else if let Some(parsed) = crate::dates::parse_due_date(d) {
input["dueDate"] = json!(parsed);
} else {
input["dueDate"] = json!(d);
}
}
if let Some(e) = estimate {
if e == 0.0 {
input["estimate"] = json!(null);
} else {
input["estimate"] = json!(e);
}
}
if input.as_object().map(|o| o.is_empty()).unwrap_or(true) {
if !agent_opts.quiet {
println!("No updates specified.");
}
return Ok(());
}
if dry_run {
if output.is_json() || output.has_template() {
print_json_owned(
json!({
"dry_run": true,
"would_update": {
"id": id,
"input": input,
}
}),
output,
)?;
} else {
println!("{}", "[DRY RUN] Would update issue:".yellow().bold());
println!(" ID: {}", id);
}
return Ok(());
}
let mutation = r#"
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
identifier
title
}
}
}
"#;
let result = client
.mutate(mutation, Some(json!({ "id": id, "input": input })))
.await?;
if result["data"]["issueUpdate"]["success"].as_bool() == Some(true) {
let issue = &result["data"]["issueUpdate"]["issue"];
let identifier = issue["identifier"].as_str().unwrap_or("");
if agent_opts.id_only {
println!("{}", identifier);
return Ok(());
}
if output.is_json() || output.has_template() {
print_json(issue, output)?;
return Ok(());
}
if agent_opts.quiet {
println!("{}", identifier);
return Ok(());
}
println!(
"{} Updated issue: {} {}",
"+".green(),
identifier,
issue["title"].as_str().unwrap_or("")
);
} else {
anyhow::bail!("Failed to update issue");
}
Ok(())
}
fn read_json_data(data: Option<&str>) -> Result<Option<Value>> {
let Some(data) = data else { return Ok(None) };
let raw = if data == "-" {
let stdin = io::stdin();
let lines: Vec<String> = stdin.lock().lines().map_while(Result::ok).collect();
lines.join("\n")
} else {
data.to_string()
};
let value: Value = serde_json::from_str(&raw)?;
Ok(Some(value))
}
async fn delete_issue(id: &str, force: bool, agent_opts: AgentOptions) -> Result<()> {
if !force && !agent_opts.quiet {
let confirm = dialoguer::Confirm::new()
.with_prompt(format!("Delete issue {}? This cannot be undone", id))
.default(false)
.interact()?;
if !confirm {
println!("Cancelled.");
return Ok(());
}
} else if !force && agent_opts.quiet {
anyhow::bail!("Use --force to delete in quiet mode");
}
let client = LinearClient::new()?;
let mutation = r#"
mutation($id: String!) {
issueDelete(id: $id) {
success
}
}
"#;
let result = client.mutate(mutation, Some(json!({ "id": id }))).await?;
if result["data"]["issueDelete"]["success"].as_bool() == Some(true) {
if !agent_opts.quiet {
println!("{} Issue deleted", "+".green());
}
} else {
anyhow::bail!("Failed to delete issue");
}
Ok(())
}
fn branch_exists(branch: &str) -> bool {
run_git_command(&["rev-parse", "--verify", branch]).is_ok()
}
async fn start_issue(
id: &str,
checkout: bool,
custom_branch: Option<String>,
agent_opts: AgentOptions,
) -> Result<()> {
let client = LinearClient::new()?;
let query = r#"
query($id: String!) {
issue(id: $id) {
id
identifier
title
branchName
team {
id
states {
nodes {
id
name
type
}
}
}
}
viewer {
id
}
}
"#;
let result = client.query(query, Some(json!({ "id": id }))).await?;
let issue = &result["data"]["issue"];
if issue.is_null() {
anyhow::bail!("Issue not found: {}", id);
}
let identifier = issue["identifier"].as_str().unwrap_or("");
let title = issue["title"].as_str().unwrap_or("");
let linear_branch = issue["branchName"].as_str().unwrap_or("").to_string();
let viewer_id = result["data"]["viewer"]["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Could not fetch current user ID"))?;
let empty = vec![];
let states = issue["team"]["states"]["nodes"]
.as_array()
.unwrap_or(&empty);
let started_state = states
.iter()
.find(|s| s["type"].as_str() == Some("started"));
let state_id = match started_state {
Some(s) => s["id"].as_str().unwrap_or(""),
None => anyhow::bail!("No 'started' state found for this team"),
};
let state_name = started_state
.and_then(|s| s["name"].as_str())
.unwrap_or("In Progress");
let input = json!({
"stateId": state_id,
"assigneeId": viewer_id
});
let mutation = r#"
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
identifier
title
state { name }
assignee { name }
}
}
}
"#;
let result = client
.mutate(mutation, Some(json!({ "id": id, "input": input })))
.await?;
if result["data"]["issueUpdate"]["success"].as_bool() == Some(true) {
let updated = &result["data"]["issueUpdate"]["issue"];
let updated_id = updated["identifier"].as_str().unwrap_or("");
if agent_opts.id_only {
println!("{}", updated_id);
} else if !agent_opts.quiet {
println!(
"{} Started issue: {} {}",
"+".green(),
updated_id.cyan(),
updated["title"].as_str().unwrap_or("")
);
println!(
" State: {}",
updated["state"]["name"].as_str().unwrap_or(state_name)
);
println!(
" Assignee: {}",
updated["assignee"]["name"].as_str().unwrap_or("me")
);
}
} else {
anyhow::bail!("Failed to start issue");
}
if checkout {
let branch_name = custom_branch
.or(if linear_branch.is_empty() {
None
} else {
Some(linear_branch)
})
.unwrap_or_else(|| generate_branch_name(identifier, title));
if !agent_opts.quiet {
println!();
}
if branch_exists(&branch_name) {
if !agent_opts.quiet {
println!("Checking out existing branch: {}", branch_name.green());
}
run_git_command(&["checkout", &branch_name])?;
} else {
if !agent_opts.quiet {
println!("Creating and checking out branch: {}", branch_name.green());
}
run_git_command(&["checkout", "-b", &branch_name])?;
}
let current = run_git_command(&["rev-parse", "--abbrev-ref", "HEAD"])?;
if !agent_opts.quiet {
println!("{} Now on branch: {}", "+".green(), current);
}
}
Ok(())
}
async fn stop_issue(id: &str, unassign: bool, agent_opts: AgentOptions) -> Result<()> {
let client = LinearClient::new()?;
let query = r#"
query($id: String!) {
issue(id: $id) {
id
identifier
title
team {
id
states {
nodes {
id
name
type
}
}
}
}
}
"#;
let result = client.query(query, Some(json!({ "id": id }))).await?;
let issue = &result["data"]["issue"];
if issue.is_null() {
anyhow::bail!("Issue not found: {}", id);
}
let empty = vec![];
let states = issue["team"]["states"]["nodes"]
.as_array()
.unwrap_or(&empty);
let stop_state = states
.iter()
.find(|s| s["type"].as_str() == Some("backlog"))
.or_else(|| {
states
.iter()
.find(|s| s["type"].as_str() == Some("unstarted"))
});
let state_id = match stop_state {
Some(s) => s["id"].as_str().unwrap_or(""),
None => anyhow::bail!("No 'backlog' or 'unstarted' state found for this team"),
};
let state_name = stop_state
.and_then(|s| s["name"].as_str())
.unwrap_or("Backlog");
let mut input = json!({
"stateId": state_id
});
if unassign {
input["assigneeId"] = json!(null);
}
let mutation = r#"
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
identifier
title
state { name }
assignee { name }
}
}
}
"#;
let result = client
.mutate(mutation, Some(json!({ "id": id, "input": input })))
.await?;
if result["data"]["issueUpdate"]["success"].as_bool() == Some(true) {
let updated = &result["data"]["issueUpdate"]["issue"];
let updated_id = updated["identifier"].as_str().unwrap_or("");
if agent_opts.id_only {
println!("{}", updated_id);
} else if !agent_opts.quiet {
println!(
"{} Stopped issue: {} {}",
"+".green(),
updated_id.cyan(),
updated["title"].as_str().unwrap_or("")
);
println!(
" State: {}",
updated["state"]["name"].as_str().unwrap_or(state_name)
);
if unassign {
println!(" Assignee: (unassigned)");
} else if let Some(assignee) = updated["assignee"]["name"].as_str() {
println!(" Assignee: {}", assignee);
}
}
} else {
anyhow::bail!("Failed to stop issue");
}
Ok(())
}
async fn close_issue(id: &str) -> Result<()> {
let client = LinearClient::new()?;
let query = r#"
query($id: String!) {
issue(id: $id) {
id
identifier
title
team {
states {
nodes {
id
name
type
}
}
}
}
}
"#;
let result = client.query(query, Some(json!({ "id": id }))).await?;
let issue = &result["data"]["issue"];
if issue.is_null() {
anyhow::bail!("Issue not found: {}", id);
}
let empty = vec![];
let states = issue["team"]["states"]["nodes"]
.as_array()
.unwrap_or(&empty);
let done_state = states
.iter()
.find(|s| s["type"].as_str() == Some("completed"));
let state_id = match done_state {
Some(s) => s["id"].as_str().unwrap_or(""),
None => anyhow::bail!("No 'completed' state found for this team"),
};
let state_name = done_state
.and_then(|s| s["name"].as_str())
.unwrap_or("Done");
let mutation = r#"
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
identifier
title
state { name }
}
}
}
"#;
let issue_uuid = issue["id"].as_str().unwrap_or(id);
let result = client
.mutate(mutation, Some(json!({ "id": issue_uuid, "input": { "stateId": state_id } })))
.await?;
if result["data"]["issueUpdate"]["success"].as_bool() == Some(true) {
let updated = &result["data"]["issueUpdate"]["issue"];
println!(
"{} Closed issue: {} {}",
"+".green(),
updated["identifier"].as_str().unwrap_or("").cyan(),
updated["title"].as_str().unwrap_or("")
);
println!(
" State: {}",
updated["state"]["name"].as_str().unwrap_or(state_name)
);
} else {
anyhow::bail!("Failed to close issue");
}
Ok(())
}
async fn archive_issue(id: &str, archive: bool) -> Result<()> {
let client = LinearClient::new()?;
let mutation = if archive {
r#"
mutation($id: String!) {
issueArchive(id: $id) {
success
}
}
"#
} else {
r#"
mutation($id: String!) {
issueUnarchive(id: $id) {
success
}
}
"#
};
let result = client.mutate(mutation, Some(json!({ "id": id }))).await?;
let key = if archive { "issueArchive" } else { "issueUnarchive" };
if result["data"][key]["success"].as_bool() == Some(true) {
let action = if archive { "Archived" } else { "Unarchived" };
println!("{} {} issue: {}", "+".green(), action, id.cyan());
} else {
let action = if archive { "archive" } else { "unarchive" };
anyhow::bail!("Failed to {} issue: {}", action, id);
}
Ok(())
}
async fn comment_issue(id: &str, body: &str) -> Result<()> {
let client = LinearClient::new()?;
let actual_body = if body == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else {
body.to_string()
};
if actual_body.trim().is_empty() {
anyhow::bail!("Comment body cannot be empty");
}
let mutation = r#"
mutation($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
body
issue { identifier }
}
}
}
"#;
let result = client
.mutate(mutation, Some(json!({ "input": { "issueId": id, "body": actual_body } })))
.await?;
if result["data"]["commentCreate"]["success"].as_bool() == Some(true) {
let comment = &result["data"]["commentCreate"]["comment"];
let issue_id = comment["issue"]["identifier"].as_str().unwrap_or(id);
println!("{} Added comment to {}", "+".green(), issue_id.cyan());
} else {
anyhow::bail!("Failed to add comment to issue: {}", id);
}
Ok(())
}
async fn link_issue(id: &str) -> Result<()> {
let client = LinearClient::new()?;
let query = r#"
query($id: String!) {
issue(id: $id) {
url
}
}
"#;
let result = client.query(query, Some(json!({ "id": id }))).await?;
let issue = &result["data"]["issue"];
if issue.is_null() {
anyhow::bail!("Issue not found: {}", id);
}
let url = issue["url"].as_str().unwrap_or("");
if url.is_empty() {
anyhow::bail!("No URL for issue: {}", id);
}
println!("{}", url);
Ok(())
}
async fn assign_issue(id: &str, user: Option<String>) -> Result<()> {
let client = LinearClient::new()?;
let assignee_id = match &user {
Some(u) => {
let uid = crate::api::resolve_user_id(&client, u, &CacheOptions::default()).await?;
Some(uid)
}
None => None,
};
let mutation = r#"
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue { identifier assignee { name } }
}
}
"#;
let input = if let Some(ref uid) = assignee_id {
json!({ "assigneeId": uid })
} else {
json!({ "assigneeId": null })
};
let result = client
.mutate(mutation, Some(json!({ "id": id, "input": input })))
.await?;
if result["data"]["issueUpdate"]["success"].as_bool() == Some(true) {
let issue = &result["data"]["issueUpdate"]["issue"];
let ident = issue["identifier"].as_str().unwrap_or(id);
if let Some(name) = issue["assignee"]["name"].as_str() {
println!("{} Assigned {} to {}", "+".green(), ident.cyan(), name);
} else {
println!("{} Unassigned {}", "+".green(), ident.cyan());
}
} else {
anyhow::bail!("Failed to assign issue: {}", id);
}
Ok(())
}
async fn move_issue(id: &str, project: &str) -> Result<()> {
let client = LinearClient::new()?;
let project_id = crate::api::resolve_project_id(&client, project, &CacheOptions::default()).await?;
let mutation = r#"
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue { identifier project { name } }
}
}
"#;
let result = client
.mutate(
mutation,
Some(json!({ "id": id, "input": { "projectId": project_id } })),
)
.await?;
if result["data"]["issueUpdate"]["success"].as_bool() == Some(true) {
let issue = &result["data"]["issueUpdate"]["issue"];
let ident = issue["identifier"].as_str().unwrap_or(id);
let proj = issue["project"]["name"].as_str().unwrap_or(project);
println!("{} Moved {} to project {}", "+".green(), ident.cyan(), proj);
} else {
anyhow::bail!("Failed to move issue: {}", id);
}
Ok(())
}
async fn transfer_issue(id: &str, team: &str) -> Result<()> {
let client = LinearClient::new()?;
let team_id = crate::api::resolve_team_id(&client, team, &CacheOptions::default()).await?;
let mutation = r#"
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue { identifier team { name key } }
}
}
"#;
let result = client
.mutate(
mutation,
Some(json!({ "id": id, "input": { "teamId": team_id } })),
)
.await?;
if result["data"]["issueUpdate"]["success"].as_bool() == Some(true) {
let issue = &result["data"]["issueUpdate"]["issue"];
let ident = issue["identifier"].as_str().unwrap_or(id);
let team_name = issue["team"]["name"].as_str().unwrap_or(team);
println!(
"{} Transferred {} to team {}",
"+".green(),
ident.cyan(),
team_name
);
} else {
anyhow::bail!("Failed to transfer issue: {}", id);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_history_state_change() {
let entry = serde_json::json!({
"fromState": { "name": "Todo" },
"toState": { "name": "In Progress" }
});
assert_eq!(format_history_entry(&entry), "Status: Todo → In Progress");
}
#[test]
fn test_format_history_assignee_set() {
let entry = serde_json::json!({
"toAssignee": { "name": "Alice" }
});
assert_eq!(format_history_entry(&entry), "Assigned to Alice");
}
#[test]
fn test_format_history_assignee_changed() {
let entry = serde_json::json!({
"fromAssignee": { "name": "Alice" },
"toAssignee": { "name": "Bob" }
});
assert_eq!(format_history_entry(&entry), "Assignee: Alice → Bob");
}
#[test]
fn test_format_history_priority_change() {
let entry = serde_json::json!({
"fromPriority": 3.0,
"toPriority": 1.0
});
assert_eq!(format_history_entry(&entry), "Priority: Normal → Urgent");
}
#[test]
fn test_format_history_description_updated() {
let entry = serde_json::json!({
"updatedDescription": true
});
assert_eq!(format_history_entry(&entry), "Description updated");
}
#[test]
fn test_format_history_labels_added() {
let entry = serde_json::json!({
"addedLabels": [{ "name": "bug" }, { "name": "urgent" }]
});
assert_eq!(format_history_entry(&entry), "Added labels: bug, urgent");
}
#[test]
fn test_format_history_project_added() {
let entry = serde_json::json!({
"toProject": { "name": "FreshTrack" }
});
assert_eq!(format_history_entry(&entry), "Added to project FreshTrack");
}
#[test]
fn test_format_history_multiple_changes() {
let entry = serde_json::json!({
"fromState": { "name": "Todo" },
"toState": { "name": "Done" },
"updatedDescription": true
});
let result = format_history_entry(&entry);
assert!(result.contains("Status: Todo → Done"));
assert!(result.contains("Description updated"));
}
#[test]
fn test_format_history_empty() {
let entry = serde_json::json!({});
assert_eq!(format_history_entry(&entry), "");
}
#[test]
fn test_format_history_archived() {
let entry = serde_json::json!({ "archived": true });
assert_eq!(format_history_entry(&entry), "Archived");
}
}