linear-cmd
Version:
A GitHub CLI-like tool for Linear - manage issues, accounts, and more
365 lines (364 loc) • 17.3 kB
JavaScript
import { LinearClient } from '@linear/sdk';
import chalk from 'chalk';
import { Command } from 'commander';
import inquirer from 'inquirer';
import { ConfigManager } from '../../lib/config-manager.js';
import { getLinearClientForAccount, handleValidationError, LinearAPIClient, ValidationError } from '../../lib/linear-client.js';
import { Logger } from '../../lib/logger.js';
import { linearIssueUpdatePayloadSchema } from '../../types/linear.js';
function getPriorityName(priority) {
switch (priority) {
case 1:
return '🔴 Urgent';
case 2:
return '🟠 High';
case 3:
return '🟡 Medium';
case 4:
return '🔵 Low';
default:
return '⚪ None';
}
}
export function createUpdateIssueCommand() {
return new Command('update')
.description('Update a Linear issue')
.argument('<issue>', 'issue ID or URL')
.option('-a, --account <account>', 'specify account to use')
.option('-t, --title <title>', 'new title')
.option('-d, --description <description>', 'new description')
.option('-s, --state <state>', 'new state (e.g., "In Progress", "Done")')
.option('--assignee <assignee>', 'assignee email or "unassign"')
.option('--project <project>', 'project name or "none" to remove')
.option('-p, --priority <priority>', 'priority (0: none, 1: urgent, 2: high, 3: medium, 4: low)')
.option('--add-label <label>', 'add a label')
.option('--remove-label <label>', 'remove a label')
.action(async (issueIdOrUrl, options) => {
const configManager = new ConfigManager();
try {
// Parse issue identifier first
const linearClient = new LinearAPIClient();
const issueId = linearClient.parseIssueIdentifier(issueIdOrUrl);
if (!issueId) {
console.error(chalk.red('❌ Invalid issue ID or URL'));
return;
}
// For update, we'll try to find the account that has access to this issue
// if not specified
let account;
let client;
if (options.account) {
const result = await getLinearClientForAccount(configManager, options.account);
client = result.client;
account = result.account;
}
else {
// Try to find which account can access this issue
const accounts = configManager.getAllAccounts();
let foundAccount = null;
for (const acc of accounts) {
try {
const testClient = new LinearClient({ apiKey: acc.api_key });
await testClient.issue(issueId);
foundAccount = acc;
client = testClient;
break;
}
catch {
// This account can't access the issue, try next
}
}
if (!foundAccount || !client) {
throw new ValidationError('Could not find an account with access to this issue', [
'Use --account flag to specify which account to use',
'Run `linear account list` to see available accounts'
]);
}
account = foundAccount;
}
// Fetch the issue
const issue = await client.issue(issueId);
if (!issue) {
console.error(chalk.red(`❌ Issue ${issueId} not found`));
return;
}
// Build update payload
const updatePayload = {};
let hasUpdates = false;
// Handle title update
if (options.title !== undefined) {
updatePayload.title = options.title;
hasUpdates = true;
}
// Handle description update
if (options.description !== undefined) {
updatePayload.description = options.description;
hasUpdates = true;
}
// Handle state update
if (options.state) {
const team = await issue.team;
if (!team) {
console.error(chalk.red('❌ Unable to get issue team'));
return;
}
const states = await client.workflowStates({
filter: {
name: { eq: options.state },
team: { id: { eq: team.id } }
}
});
if (states.nodes.length > 0) {
updatePayload.stateId = states.nodes[0].id;
hasUpdates = true;
}
else {
console.error(chalk.red(`❌ State '${options.state}' not found`));
return;
}
}
// Handle assignee update
if (options.assignee) {
if (options.assignee.toLowerCase() === 'unassign') {
updatePayload.assigneeId = null;
hasUpdates = true;
}
else {
const users = await client.users({ filter: { email: { eq: options.assignee } } });
if (users.nodes.length > 0) {
updatePayload.assigneeId = users.nodes[0].id;
hasUpdates = true;
}
else {
console.error(chalk.red(`❌ User '${options.assignee}' not found`));
return;
}
}
}
// Handle project update
if (options.project) {
if (options.project.toLowerCase() === 'none') {
updatePayload.projectId = null;
hasUpdates = true;
}
else {
const projects = await client.projects({ filter: { name: { eq: options.project } } });
if (projects.nodes.length > 0) {
updatePayload.projectId = projects.nodes[0].id;
hasUpdates = true;
}
else {
console.error(chalk.red(`❌ Project '${options.project}' not found`));
return;
}
}
}
// Handle priority update
if (options.priority !== undefined) {
const priority = parseInt(options.priority);
if (priority >= 0 && priority <= 4) {
updatePayload.priority = priority;
hasUpdates = true;
}
}
// Handle label additions
if (options.addLabel) {
const labels = await client.issueLabels({ filter: { name: { eq: options.addLabel } } });
if (labels.nodes.length > 0) {
const currentLabels = await issue.labels();
const currentLabelIds = currentLabels.nodes.map((l) => l.id);
if (!currentLabelIds.includes(labels.nodes[0].id)) {
updatePayload.labelIds = [...currentLabelIds, labels.nodes[0].id];
hasUpdates = true;
}
else {
Logger.warning(`Label '${options.addLabel}' already added`);
}
}
else {
console.error(chalk.red(`❌ Label '${options.addLabel}' not found`));
return;
}
}
// Handle label removals
if (options.removeLabel) {
const currentLabels = await issue.labels();
const labelToRemove = currentLabels.nodes.find((l) => l.name === options.removeLabel);
if (labelToRemove) {
updatePayload.labelIds = currentLabels.nodes.filter((l) => l.id !== labelToRemove.id).map((l) => l.id);
hasUpdates = true;
}
else {
Logger.warning(`Label '${options.removeLabel}' not found on issue`);
}
}
// If no updates specified, show interactive prompt
if (!hasUpdates) {
const currentState = await issue.state;
const currentAssignee = await issue.assignee;
const issueTeam = await issue.team;
// Fetch states and users once
const states = issueTeam
? await client.workflowStates({
filter: { team: { id: { eq: issueTeam.id } } }
})
: null;
const users = await client.users();
const pendingUpdates = {};
let continueEditing = true;
while (continueEditing) {
// Build menu choices showing current vs updated values
const choices = [
{
name: `Title: ${pendingUpdates.title ? `${issue.title} → ${pendingUpdates.title}` : issue.title}`,
value: 'title'
},
{
name: `Description: ${pendingUpdates.description !== undefined
? `${issue.description || '(empty)'} → ${pendingUpdates.description || '(empty)'}`
: issue.description || '(empty)'}`,
value: 'description'
},
{
name: `State: ${pendingUpdates.stateId
? `${currentState?.name} → ${states?.nodes.find((s) => s.id === pendingUpdates.stateId)?.name}`
: currentState?.name || 'Unknown'}`,
value: 'state'
},
{
name: `Assignee: ${pendingUpdates.assigneeId !== undefined
? `${currentAssignee?.name || 'Unassigned'} → ${pendingUpdates.assigneeId ? users.nodes.find((u) => u.id === pendingUpdates.assigneeId)?.name : 'Unassigned'}`
: currentAssignee?.name || 'Unassigned'}`,
value: 'assignee'
},
{
name: `Priority: ${pendingUpdates.priority !== undefined
? `${getPriorityName(issue.priority)} → ${getPriorityName(pendingUpdates.priority)}`
: getPriorityName(issue.priority)}`,
value: 'priority'
},
new inquirer.Separator(),
{ name: 'Apply changes', value: 'apply' },
{ name: 'Cancel', value: 'cancel' }
];
const answer = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: 'What would you like to update?',
choices: choices
}
]);
switch (answer.action) {
case 'title': {
const titleAnswer = await inquirer.prompt([
{
type: 'input',
name: 'title',
message: 'New title:',
default: pendingUpdates.title || issue.title
}
]);
pendingUpdates.title = titleAnswer.title;
break;
}
case 'description': {
const descAnswer = await inquirer.prompt([
{
type: 'input',
name: 'description',
message: 'New description:',
default: pendingUpdates.description !== undefined ? pendingUpdates.description : issue.description || ''
}
]);
pendingUpdates.description = descAnswer.description;
break;
}
case 'state': {
if (!states) {
console.error(chalk.red('❌ Unable to get team states'));
break;
}
const stateAnswer = await inquirer.prompt([
{
type: 'list',
name: 'state',
message: 'New state:',
choices: states.nodes.map((s) => ({
name: s.name,
value: s.id
})),
default: pendingUpdates.stateId || currentState?.id
}
]);
pendingUpdates.stateId = stateAnswer.state;
break;
}
case 'assignee': {
const assigneeChoices = [
{ name: 'Unassigned', value: null },
...users.nodes.map((u) => ({
name: `${u.name} (${u.email})`,
value: u.id
}))
];
const assigneeAnswer = await inquirer.prompt([
{
type: 'list',
name: 'assignee',
message: 'New assignee:',
choices: assigneeChoices,
default: pendingUpdates.assigneeId !== undefined ? pendingUpdates.assigneeId : currentAssignee?.id
}
]);
pendingUpdates.assigneeId = assigneeAnswer.assignee;
break;
}
case 'priority': {
const priorityAnswer = await inquirer.prompt([
{
type: 'list',
name: 'priority',
message: 'New priority:',
choices: [
{ name: '🔴 Urgent', value: 1 },
{ name: '🟠 High', value: 2 },
{ name: '🟡 Medium', value: 3 },
{ name: '🔵 Low', value: 4 },
{ name: '⚪ None', value: 0 }
],
default: pendingUpdates.priority !== undefined ? pendingUpdates.priority : issue.priority || 0
}
]);
pendingUpdates.priority = priorityAnswer.priority;
break;
}
case 'apply': {
Object.assign(updatePayload, pendingUpdates);
continueEditing = false;
break;
}
case 'cancel': {
console.log(chalk.dim('Update cancelled'));
return;
}
}
}
}
// Validate and update the issue
const validPayload = linearIssueUpdatePayloadSchema.parse(updatePayload);
Logger.loading(`Updating issue in account: ${account?.name || 'unknown'}...`);
await client.updateIssue(issue.id, validPayload);
Logger.success(`Issue ${issue.identifier} updated successfully!`);
Logger.link(issue.url);
}
catch (error) {
if (error instanceof ValidationError) {
handleValidationError(error);
}
else {
Logger.error('Error updating issue', error);
}
}
});
}