use super::issues::{
build_list_filter_from_list_cmd, get_issue, list_issues, print_issue_table, update_issue,
LinearIssue, UpdateIssueInput,
};
use super::meta::{
list_cycles, list_labels, list_projects, list_users, list_workflow_states, resolve_assignee_id,
resolve_cycle_id, resolve_project_id, resolve_state_id,
};
use super::{parse_priority_arg, priority_label, require_interactive};
use crate::cli::commands::{LinearBulkCmd, LinearListCmd};
use crate::cli::ui::Loader;
use crate::commands::cloudflare_config::is_interactive_terminal;
use crate::commands::text_util::truncate_chars;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, MultiSelect, Select};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BulkAction {
Assign,
Status,
Labels,
Project,
Cycle,
Priority,
}
impl BulkAction {
fn from_flags(args: &LinearBulkCmd) -> Result<Option<Self>, String> {
let mut chosen = Vec::new();
if args.assign.is_some() {
chosen.push(Self::Assign);
}
if args.set_state.is_some() {
chosen.push(Self::Status);
}
if !args.add_labels.is_empty() {
chosen.push(Self::Labels);
}
if args.project.is_some() {
chosen.push(Self::Project);
}
if args.cycle.is_some() {
chosen.push(Self::Cycle);
}
if args.priority.is_some() {
chosen.push(Self::Priority);
}
match chosen.as_slice() {
[] => Ok(None),
[one] => Ok(Some(*one)),
_ => Err(
"Pass only one bulk action flag at a time (`--assign`, `--set-state`, `--add-label`, `--project`, `--cycle`, or `--priority`)."
.into(),
),
}
}
fn label(self) -> &'static str {
match self {
Self::Assign => "Assign to someone",
Self::Status => "Change status",
Self::Labels => "Add labels",
Self::Project => "Set project",
Self::Cycle => "Set cycle",
Self::Priority => "Change priority",
}
}
}
pub async fn run_bulk(api_key: &str, args: LinearBulkCmd) -> Result<(), String> {
let issues = resolve_target_issues(api_key, &args).await?;
if issues.is_empty() {
println!("{}", "No issues selected.".dimmed());
return Ok(());
}
println!();
println!(
"{} {} issue(s) selected",
"Bulk edit".bright_cyan().bold(),
issues.len()
);
print_issue_table(&issues);
let action = match BulkAction::from_flags(&args)? {
Some(a) => a,
None => {
require_interactive("Bulk Linear updates")?;
pick_action()?
}
};
let plan = build_plan(api_key, &args, action, &issues).await?;
if plan.is_empty() {
println!("{}", "Nothing to apply.".dimmed());
return Ok(());
}
println!();
println!(
"{} {} on {} issue(s)",
"Action".bright_cyan().bold(),
plan.summary.bright_white(),
issues.len()
);
for line in &plan.preview_lines {
println!(" {}", line.dimmed());
}
if args.dry_run {
println!("{}", "Dry run — no changes written.".bright_yellow());
return Ok(());
}
let auto_yes = args.yes || !is_interactive_terminal();
if !auto_yes {
require_interactive("Confirming bulk Linear updates")?;
let ok = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Apply `{}` to {} issue(s)?",
plan.summary,
issues.len()
))
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if !ok {
println!("{}", "Cancelled.".dimmed());
return Ok(());
}
}
let mut ok_count = 0usize;
let mut err_count = 0usize;
for issue in &issues {
let input = plan.input_for(issue)?;
let label = format!("Updating {}", issue.identifier);
let loader = Loader::start(label.as_str());
match update_issue(api_key, &issue.id, input).await {
Ok(updated) => {
let msg = format_success(&plan, &updated);
loader.success_with(&msg);
ok_count += 1;
}
Err(e) => {
loader.fail(&e);
err_count += 1;
}
}
}
println!();
println!(
"{} {} updated, {} failed",
"Done".bright_green().bold(),
ok_count,
err_count
);
if err_count > 0 {
return Err(format!(
"Bulk update finished with {err_count} failure(s) out of {}.",
issues.len()
));
}
Ok(())
}
async fn resolve_target_issues(
api_key: &str,
args: &LinearBulkCmd,
) -> Result<Vec<LinearIssue>, String> {
if !args.ids.is_empty() {
let mut out = Vec::new();
for id in &args.ids {
let id = id.trim();
if id.is_empty() {
continue;
}
out.push(get_issue(api_key, id).await?);
}
return Ok(out);
}
let list_args = LinearListCmd {
me: args.me,
team: args.team.clone(),
state: args.state.clone(),
assignee: args.assignee.clone(),
query: args.query.clone(),
labels: args.filter_labels.clone(),
include_completed: args.include_completed,
sort: args.sort.clone(),
asc: args.asc,
limit: args.limit,
tui: false,
};
let filter = build_list_filter_from_list_cmd(api_key, &list_args).await?;
let loader = Loader::start("Fetching Linear issues");
let issues = match list_issues(api_key, filter).await {
Ok(v) => {
loader.success();
v
}
Err(e) => {
loader.fail(&e);
return Err(e);
}
};
if issues.is_empty() {
return Ok(issues);
}
if !is_interactive_terminal() {
return Err(
"Bulk issue selection needs a TTY, or pass explicit `--id` values."
.into(),
);
}
require_interactive("Selecting Linear issues for bulk edit")?;
let labels: Vec<String> = issues
.iter()
.map(|i| {
format!(
"{} [{}] {} — {}",
i.identifier,
i.state_name.as_deref().unwrap_or("-"),
i.assignee_name.as_deref().unwrap_or("-"),
truncate_chars(&i.title, 50)
)
})
.collect();
let selected = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select issues (space to toggle, enter to confirm)")
.items(&labels)
.defaults(&vec![false; labels.len()])
.interact()
.map_err(|e| e.to_string())?;
Ok(selected.into_iter().map(|i| issues[i].clone()).collect())
}
fn pick_action() -> Result<BulkAction, String> {
let actions = [
BulkAction::Assign,
BulkAction::Status,
BulkAction::Labels,
BulkAction::Project,
BulkAction::Cycle,
BulkAction::Priority,
];
let labels: Vec<&str> = actions.iter().map(|a| a.label()).collect();
let idx = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Bulk action")
.items(&labels)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
Ok(actions[idx])
}
struct BulkPlan {
summary: String,
preview_lines: Vec<String>,
kind: BulkPlanKind,
}
enum BulkPlanKind {
Assign {
assignee_id: Option<String>,
},
Status {
state_ids_by_team: std::collections::BTreeMap<String, String>,
state_name: String,
},
Labels {
label_ids: Vec<String>,
},
Project {
project_id: Option<String>,
label: String,
},
Cycle {
cycle_id: Option<String>,
label: String,
},
Priority {
priority: i32,
},
}
impl BulkPlan {
fn is_empty(&self) -> bool {
match &self.kind {
BulkPlanKind::Labels { label_ids, .. } => label_ids.is_empty(),
_ => false,
}
}
fn input_for(&self, issue: &LinearIssue) -> Result<UpdateIssueInput, String> {
match &self.kind {
BulkPlanKind::Assign { assignee_id } => Ok(UpdateIssueInput {
assignee_id: Some(assignee_id.clone()),
..Default::default()
}),
BulkPlanKind::Status {
state_ids_by_team,
state_name,
} => {
let team_id = issue.team_id.as_deref().ok_or_else(|| {
format!(
"{} has no team; cannot set status `{}`.",
issue.identifier, state_name
)
})?;
let state_id = state_ids_by_team.get(team_id).ok_or_else(|| {
format!(
"{}: no workflow state `{}` for team {}",
issue.identifier, state_name, team_id
)
})?;
Ok(UpdateIssueInput {
state_id: Some(state_id.clone()),
..Default::default()
})
}
BulkPlanKind::Labels { label_ids } => {
let mut current: Vec<String> =
issue.labels.iter().map(|(id, _)| id.clone()).collect();
for id in label_ids {
if !current.contains(id) {
current.push(id.clone());
}
}
Ok(UpdateIssueInput {
label_ids: Some(current),
..Default::default()
})
}
BulkPlanKind::Project { project_id, .. } => Ok(UpdateIssueInput {
project_id: Some(project_id.clone()),
..Default::default()
}),
BulkPlanKind::Cycle { cycle_id, .. } => Ok(UpdateIssueInput {
cycle_id: Some(cycle_id.clone()),
..Default::default()
}),
BulkPlanKind::Priority { priority } => Ok(UpdateIssueInput {
priority: Some(*priority),
..Default::default()
}),
}
}
}
async fn build_plan(
api_key: &str,
args: &LinearBulkCmd,
action: BulkAction,
issues: &[LinearIssue],
) -> Result<BulkPlan, String> {
match action {
BulkAction::Assign => {
let assignee = match args.assign.as_deref() {
Some(a) if !a.trim().is_empty() => a.to_string(),
_ => {
require_interactive("Choosing bulk assignee")?;
pick_assignee(api_key).await?
}
};
let assignee_id = resolve_assignee_id(api_key, &assignee).await?;
let label = if assignee.eq_ignore_ascii_case("none") {
"(unassign)".to_string()
} else if assignee.eq_ignore_ascii_case("me") {
"me".to_string()
} else {
assignee
};
Ok(BulkPlan {
summary: format!("assign → {label}"),
preview_lines: vec![format!("Set assignee on all selected issues to {label}")],
kind: BulkPlanKind::Assign { assignee_id },
})
}
BulkAction::Status => {
let state_name = match args.set_state.as_deref() {
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
require_interactive("Choosing bulk status")?;
pick_status(api_key, issues).await?
}
};
let teams: Vec<String> = issues
.iter()
.filter_map(|i| i.team_id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
if teams.is_empty() {
return Err("Selected issues have no team; cannot change status.".into());
}
let mut state_ids_by_team = std::collections::BTreeMap::new();
for team_id in &teams {
let id = resolve_state_id(api_key, team_id, &state_name).await?;
state_ids_by_team.insert(team_id.clone(), id);
}
let preview = if teams.len() == 1 {
vec![format!("Set status → {state_name}")]
} else {
vec![format!(
"Set status → {state_name} across {} teams",
teams.len()
)]
};
Ok(BulkPlan {
summary: format!("status → {state_name}"),
preview_lines: preview,
kind: BulkPlanKind::Status {
state_ids_by_team,
state_name,
},
})
}
BulkAction::Labels => {
let names = if !args.add_labels.is_empty() {
args.add_labels.clone()
} else {
require_interactive("Choosing bulk labels")?;
pick_labels_to_add(api_key, issues).await?
};
if names.is_empty() {
return Ok(BulkPlan {
summary: "add labels (none)".into(),
preview_lines: vec!["No labels selected".into()],
kind: BulkPlanKind::Labels {
label_ids: Vec::new(),
},
});
}
let team_id = majority_team_id(issues);
let known = list_labels(api_key, team_id.as_deref()).await?;
let mut label_ids = Vec::new();
let mut resolved_names = Vec::new();
for name in &names {
let name = name.trim();
if name.is_empty() {
continue;
}
if name.len() >= 32 && name.contains('-') {
label_ids.push(name.to_string());
resolved_names.push(name.to_string());
continue;
}
if let Some(found) = known
.iter()
.find(|l| l.id == name || l.name.eq_ignore_ascii_case(name))
{
if !label_ids.contains(&found.id) {
label_ids.push(found.id.clone());
resolved_names.push(found.name.clone());
}
} else {
return Err(format!("No Linear label matching `{name}`."));
}
}
Ok(BulkPlan {
summary: format!("add labels → {}", resolved_names.join(", ")),
preview_lines: vec![format!(
"Merge labels [{}] onto each selected issue",
resolved_names.join(", ")
)],
kind: BulkPlanKind::Labels { label_ids },
})
}
BulkAction::Project => {
let project = match args.project.as_deref() {
Some(p) if !p.trim().is_empty() => p.to_string(),
_ => {
require_interactive("Choosing bulk project")?;
pick_project(api_key).await?
}
};
let project_id = resolve_project_id(api_key, &project).await?;
let label = if project_id.is_none() {
"(none)".to_string()
} else if project.eq_ignore_ascii_case("none") {
"(none)".to_string()
} else {
project
};
Ok(BulkPlan {
summary: format!("project → {label}"),
preview_lines: vec![format!("Set project on all selected issues to {label}")],
kind: BulkPlanKind::Project { project_id, label },
})
}
BulkAction::Cycle => {
let cycle_key = match args.cycle.as_deref() {
Some(c) if !c.trim().is_empty() => c.to_string(),
_ => {
require_interactive("Choosing bulk cycle")?;
pick_cycle(api_key, issues).await?
}
};
let teams: Vec<String> = issues
.iter()
.filter_map(|i| i.team_id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
if cycle_key.eq_ignore_ascii_case("none") || cycle_key.trim().is_empty() {
return Ok(BulkPlan {
summary: "cycle → (none)".into(),
preview_lines: vec!["Clear cycle on all selected issues".into()],
kind: BulkPlanKind::Cycle {
cycle_id: None,
label: "(none)".into(),
},
});
}
if teams.len() != 1 {
return Err(
"Bulk cycle changes require all selected issues to share one team (pass a team filter or reselect)."
.into(),
);
}
let cycle_id = resolve_cycle_id(api_key, &teams[0], &cycle_key).await?;
let label = cycle_key.clone();
Ok(BulkPlan {
summary: format!("cycle → {label}"),
preview_lines: vec![format!("Set cycle on all selected issues to {label}")],
kind: BulkPlanKind::Cycle { cycle_id, label },
})
}
BulkAction::Priority => {
let priority = match args.priority.as_deref() {
Some(p) => parse_priority_arg(p)?,
None => {
require_interactive("Choosing bulk priority")?;
let options = ["None", "Urgent", "High", "Medium", "Low"];
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Priority for selected issues")
.items(&options)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
idx as i32
}
};
Ok(BulkPlan {
summary: format!("priority → {}", priority_label(priority)),
preview_lines: vec![format!(
"Set priority on all selected issues to {}",
priority_label(priority)
)],
kind: BulkPlanKind::Priority { priority },
})
}
}
}
async fn pick_assignee(api_key: &str) -> Result<String, String> {
let users = 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("Assignee for selected issues")
.items(&labels)
.default(1)
.interact()
.map_err(|e| e.to_string())?;
if idx == 0 {
Ok("none".into())
} else if idx == 1 {
Ok("me".into())
} else {
Ok(users[idx - 2].id.clone())
}
}
async fn pick_status(api_key: &str, issues: &[LinearIssue]) -> Result<String, String> {
let team_id = majority_team_id(issues)
.ok_or_else(|| "Selected issues have no team; cannot pick status.".to_string())?;
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("Status for selected issues")
.items(&labels)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
Ok(states[idx].name.clone())
}
async fn pick_labels_to_add(api_key: &str, issues: &[LinearIssue]) -> Result<Vec<String>, String> {
let team_id = majority_team_id(issues);
let known = 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 selected = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Labels to add to all selected issues")
.items(&names)
.defaults(&vec![false; names.len()])
.interact()
.map_err(|e| e.to_string())?;
Ok(selected.into_iter().map(|i| known[i].name.clone()).collect())
}
async fn pick_project(api_key: &str) -> Result<String, String> {
let projects = list_projects(api_key).await?;
let mut labels = vec!["(none — clear project)".to_string()];
labels.extend(projects.iter().map(|p| {
if let Some(state) = p.state.as_deref() {
format!("{} ({})", p.name, state)
} else {
p.name.clone()
}
}));
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Project for selected issues")
.items(&labels)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
if idx == 0 {
Ok("none".into())
} else {
Ok(projects[idx - 1].id.clone())
}
}
async fn pick_cycle(api_key: &str, issues: &[LinearIssue]) -> Result<String, String> {
let teams: Vec<String> = issues
.iter()
.filter_map(|i| i.team_id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
if teams.len() != 1 {
return Err(
"Bulk cycle picker requires all selected issues to share one team.".into(),
);
}
let cycles = list_cycles(api_key, &teams[0]).await?;
let mut labels = vec!["(none — clear cycle)".to_string()];
labels.extend(cycles.iter().map(|c| c.display_label()));
if labels.len() == 1 {
return Err("No cycles found for this team.".into());
}
let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Cycle for selected issues")
.items(&labels)
.default(0)
.interact()
.map_err(|e| e.to_string())?;
if idx == 0 {
Ok("none".into())
} else {
Ok(cycles[idx - 1].id.clone())
}
}
fn majority_team_id(issues: &[LinearIssue]) -> Option<String> {
let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for issue in issues {
if let Some(team_id) = &issue.team_id {
*counts.entry(team_id.clone()).or_default() += 1;
}
}
counts.into_iter().max_by_key(|(_, n)| *n).map(|(id, _)| id)
}
fn format_success(plan: &BulkPlan, updated: &LinearIssue) -> String {
match &plan.kind {
BulkPlanKind::Assign { .. } => format!(
"{} → {}",
updated.identifier,
updated.assignee_name.as_deref().unwrap_or("(none)")
),
BulkPlanKind::Status { .. } => format!(
"{} → {}",
updated.identifier,
updated.state_name.as_deref().unwrap_or("?")
),
BulkPlanKind::Labels { .. } => {
let labels = updated
.labels
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(", ");
format!(
"{} labels → {}",
updated.identifier,
if labels.is_empty() { "(none)" } else { &labels }
)
}
BulkPlanKind::Project { label, .. } => {
format!("{} project → {label}", updated.identifier)
}
BulkPlanKind::Cycle { label, .. } => {
format!("{} cycle → {label}", updated.identifier)
}
BulkPlanKind::Priority { .. } => format!(
"{} priority → {}",
updated.identifier,
priority_label(updated.priority)
),
}
}
pub async fn run_bulk_interactive(api_key: &str) -> Result<(), String> {
run_bulk(
api_key,
LinearBulkCmd {
ids: Vec::new(),
me: false,
team: None,
state: None,
assignee: None,
query: None,
filter_labels: Vec::new(),
include_completed: false,
sort: "updated".into(),
asc: false,
limit: 100,
assign: None,
set_state: None,
add_labels: Vec::new(),
project: None,
cycle: None,
priority: None,
yes: false,
dry_run: false,
},
)
.await
}