linear-cmd
Version:
A GitHub CLI-like tool for Linear - manage issues, accounts, and more
670 lines (669 loc) ⢠28.4 kB
JavaScript
import { LinearClient } from '@linear/sdk';
import { colors } from './colors.js';
import { ConfigManager } from './config-manager.js';
import { logger } from './logger.js';
// ==================== VALIDATION ERROR CLASS ====================
export class ValidationError extends Error {
hints;
constructor(message, hints = []) {
super(message);
this.hints = hints;
this.name = 'ValidationError';
}
}
// ==================== HELPER FUNCTIONS ====================
export async function getLinearClientForAccount(configManager, accountName) {
let account = null;
if (accountName) {
account = configManager.getAccount(accountName);
if (!account) {
throw new ValidationError(`Account '${accountName}' not found`, [
'Run `linear account list` to see available accounts'
]);
}
}
else {
account = configManager.getActiveAccount();
if (!account) {
throw new ValidationError('No active account found', [
'Run `linear account add` to add an account',
'Run `linear account select` to select an active account',
'Or use --account flag to specify which account to use'
]);
}
}
return {
client: new LinearClient({ apiKey: account.api_key }),
account
};
}
export function handleValidationError(error) {
logger.error(error.message);
error.hints.forEach((hint) => {
logger.dim(hint);
});
}
export async function findAccountForIssue(configManager, issueId) {
const accounts = configManager.getAllAccounts();
for (const account of accounts) {
try {
const client = new LinearClient({ apiKey: account.api_key });
await client.issue(issueId);
return { client, account };
}
catch {
// This account can't access the issue, try next
}
}
return null;
}
export async function findAccountForProject(configManager, projectIdOrUrl) {
const accounts = configManager.getAllAccounts();
const linearClient = new LinearAPIClient();
for (const account of accounts) {
try {
const client = new LinearClient({ apiKey: account.api_key });
const { projectId } = linearClient.parseProjectUrl(projectIdOrUrl);
await client.project(projectId);
return { client, account };
}
catch {
// This account can't access the project, try next
}
}
return null;
}
// ==================== MAIN CLIENT CLASS ====================
export class LinearAPIClient {
configManager;
constructor() {
this.configManager = new ConfigManager();
}
async getIssueByIdOrUrl(idOrUrl) {
// Extract workspace and issue ID from URL
const { workspace, issueId } = this.parseIssueUrl(idOrUrl);
// Try to find the right account for this workspace
const account = await this.findAccountForWorkspace(workspace, issueId);
if (!account) {
throw new Error(`No account found that can access this issue. Please check your accounts and API keys.`);
}
// Initialize client with the correct account
const client = new LinearClient({ apiKey: account.api_key });
// Fetch issue with all related data
const issue = await client.issue(issueId);
const [state, assignee, labels, comments] = await Promise.all([
issue.state,
issue.assignee,
issue.labels(),
issue.comments()
]);
// Fetch pull requests if available
const pullRequests = [];
try {
const attachments = await issue.attachments();
for (const attachment of attachments.nodes) {
if (attachment.url?.includes('github.com') && attachment.url.includes('/pull/')) {
// Extract PR info from GitHub URL
const prMatch = attachment.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
if (prMatch) {
pullRequests.push({
id: attachment.id,
url: attachment.url,
title: attachment.title || 'Pull Request',
number: parseInt(prMatch[2]),
draft: false, // Can't determine from Linear
merged: false // Can't determine from Linear
});
}
}
}
}
catch (_error) {
// Attachments might not be available
}
// Build issue data
const issueData = {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
state: {
name: state?.name || 'Unknown',
color: state?.color || '#000000'
},
assignee: assignee
? {
name: assignee.name,
email: assignee.email
}
: undefined,
labels: labels.nodes.map((label) => ({
name: label.name,
color: label.color
})),
comments: await Promise.all(comments.nodes.map(async (comment) => {
const user = await comment.user;
return {
id: comment.id,
body: comment.body,
user: {
name: user?.name || 'Unknown',
email: user?.email || ''
},
createdAt: comment.createdAt
};
})),
pullRequests,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
url: issue.url
};
return issueData;
}
async findAccountForWorkspace(workspace, entityId, entityType = 'issue') {
const accounts = this.configManager.getAllAccounts();
if (!accounts.length) {
throw new Error('No accounts configured. Please add an account first using "linear account add"');
}
// If we have a workspace, try to find an account that has access to it
if (workspace) {
const accountByWorkspace = this.configManager.findAccountByWorkspace(workspace);
if (accountByWorkspace) {
return accountByWorkspace;
}
}
// Try each account until we find one that can access this entity
for (const account of accounts) {
try {
const client = new LinearClient({ apiKey: account.api_key });
// Try to access the entity based on type
if (entityType === 'issue') {
await client.issue(entityId);
}
else if (entityType === 'project') {
// Try by ID first, then by slugId
try {
await client.project(entityId);
}
catch {
const projects = await client.projects({ filter: { slugId: { eq: entityId } } });
if (projects.nodes.length === 0)
throw new Error('Project not found');
}
}
else if (entityType === 'document') {
// Try by ID first, then by slugId
try {
await client.document(entityId);
}
catch {
const documents = await client.documents({ filter: { slugId: { eq: entityId } } });
if (documents.nodes.length === 0)
throw new Error('Document not found');
}
}
// Update workspace cache for this account
if (workspace && !account.workspaces?.includes(workspace)) {
const workspaces = account.workspaces || [];
workspaces.push(workspace);
await this.configManager.updateAccountWorkspaces(account.name, workspaces);
}
return account;
}
catch (_error) { }
}
return null;
}
parseLinearUrl(idOrUrl, entityType) {
if (entityType === 'issue') {
const urlMatch = idOrUrl.match(/linear\.app\/([^/]+)\/issue\/([A-Z]+-\d+)/);
if (urlMatch) {
return { workspace: urlMatch[1], id: urlMatch[2] };
}
return { workspace: null, id: idOrUrl };
}
// For project and document - extract the slugId (hash after last hyphen)
const urlMatch = idOrUrl.match(new RegExp(`linear\\.app/([^/]+)/${entityType}/([^/?]+)`));
if (urlMatch) {
const fullSlug = urlMatch[2];
// Extract the hash part after the last hyphen (e.g., "project-name-abc123" -> "abc123")
const parts = fullSlug.split('-');
const slugId = parts[parts.length - 1];
return { workspace: urlMatch[1], id: slugId };
}
return { workspace: null, id: idOrUrl };
}
parseIssueUrl(idOrUrl) {
const { workspace, id } = this.parseLinearUrl(idOrUrl, 'issue');
return { workspace, issueId: id };
}
generateBranchName(identifier, title) {
// Convert title to kebab-case
const cleanTitle = title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
// Truncate if too long
const maxLength = 50;
const truncatedTitle = cleanTitle.length > maxLength ? cleanTitle.substring(0, maxLength).replace(/-$/, '') : cleanTitle;
return `${identifier.toLowerCase()}/${truncatedTitle}`;
}
// ==================== ISSUE UTILITY METHODS ====================
parseIssueIdentifier(input) {
if (!input)
return null;
// Handle direct issue ID (e.g., "WORK-123")
const issueIdPattern = /^[A-Z]+-\d+$/;
if (issueIdPattern.test(input)) {
return input;
}
// Handle Linear URL
const urlPattern = /linear\.app\/[^/]+\/issue\/([A-Z]+-\d+)/;
const match = input.match(urlPattern);
if (match?.[1]) {
return match[1];
}
return null;
}
// ==================== FORMATTING METHODS ====================
formatIssue(issue) {
const output = [];
// Header
output.push(colors.boldBlue(`\nšÆ ${issue.identifier}: ${issue.title}`));
output.push(colors.dim(`${issue.url}`));
output.push('');
// State and assignee
output.push(`${colors.bold('Status:')} ${colors.hex(issue.state.color)(issue.state.name)}`);
if (issue.assignee) {
output.push(`${colors.bold('Assignee:')} ${issue.assignee.name} (${issue.assignee.email})`);
}
// Branch name (generated on-demand)
const branchName = this.generateBranchName(issue.identifier, issue.title);
output.push(`${colors.bold('Suggested Branch:')} ${colors.green(branchName)}`);
// Labels
if (issue.labels.length > 0) {
const labelStrings = issue.labels.map((label) => colors.hex(label.color)(label.name));
output.push(`${colors.bold('Labels:')} ${labelStrings.join(', ')}`);
}
// Pull Requests
if (issue.pullRequests.length > 0) {
output.push(`${colors.bold('Pull Requests:')}`);
issue.pullRequests.forEach((pr) => {
const prStatus = pr.merged ? 'ā
Merged' : pr.draft ? 'š Draft' : 'š Open';
output.push(` ${prStatus} #${pr.number}: ${pr.title}`);
output.push(` ${colors.dim(pr.url)}`);
});
}
output.push('');
// Description
if (issue.description) {
output.push(colors.bold('Description:'));
output.push(this.formatMarkdown(issue.description));
output.push('');
}
// Comments
if (issue.comments.length > 0) {
output.push(colors.bold('Comments:'));
issue.comments.forEach((comment, index) => {
output.push(`\n${colors.bold(`Comment ${index + 1}:`)} ${comment.user.name} (${comment.user.email})`);
output.push(colors.dim(`${comment.createdAt.toLocaleString()}`));
output.push(this.formatMarkdown(comment.body));
});
}
// Timestamps
output.push('');
output.push(colors.dim(`Created: ${issue.createdAt.toLocaleString()}`));
output.push(colors.dim(`Updated: ${issue.updatedAt.toLocaleString()}`));
return output.join('\n');
}
formatMarkdown(text) {
// Basic markdown formatting
return text
.replace(/\*\*(.*?)\*\*/g, colors.bold('$1'))
.replace(/\*(.*?)\*/g, colors.italic('$1'))
.replace(/`(.*?)`/g, colors.cyan('$1'))
.replace(/^#{1,6}\s*(.*$)/gm, colors.boldUnderline('$1'))
.replace(/^-\s*(.*$)/gm, ` ⢠$1`)
.replace(/^\d+\.\s*(.*$)/gm, ` $1`);
}
// ==================== PROJECT METHODS ====================
parseProjectUrl(idOrUrl) {
const { workspace, id } = this.parseLinearUrl(idOrUrl, 'project');
return { workspace, projectId: id };
}
async getProjectByIdOrUrl(idOrUrl) {
const { workspace, projectId } = this.parseProjectUrl(idOrUrl);
const account = await this.findAccountForWorkspace(workspace, projectId, 'project');
if (!account) {
throw new Error(`No account found that can access this project. Please check your accounts and API keys.`);
}
const client = new LinearClient({ apiKey: account.api_key });
// Try to get project by ID first, if fails, try by slugId
let project;
try {
project = await client.project(projectId);
}
catch {
// If ID lookup fails, try searching by slugId
const projects = await client.projects({
filter: { slugId: { eq: projectId } }
});
if (projects.nodes.length === 0) {
throw new Error(`Project not found with ID or slug: ${projectId}`);
}
project = projects.nodes[0];
}
const [lead, content] = await Promise.all([project.lead, project.content]);
return {
id: project.id,
name: project.name,
description: content || project.description,
state: project.state,
startDate: project.startDate ? new Date(project.startDate) : undefined,
targetDate: project.targetDate ? new Date(project.targetDate) : undefined,
lead: lead
? {
name: lead.name,
email: lead.email
}
: undefined,
progress: project.progress,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
url: project.url
};
}
async getProjectIssues(idOrUrl) {
const { workspace, projectId } = this.parseProjectUrl(idOrUrl);
const account = await this.findAccountForWorkspace(workspace, projectId, 'project');
if (!account) {
throw new Error(`No account found that can access this project. Please check your accounts and API keys.`);
}
const client = new LinearClient({ apiKey: account.api_key });
const project = await client.project(projectId);
const issues = await project.issues();
return await Promise.all(issues.nodes.map(async (issue) => {
const [state, assignee, project, labels, attachments, children] = await Promise.all([
issue.state,
issue.assignee,
issue.project,
issue.labels(),
issue.attachments(),
issue.children()
]);
const pullRequests = [];
for (const attachment of attachments.nodes) {
if (attachment.url?.includes('github.com') && attachment.url.includes('/pull/')) {
const prMatch = attachment.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
if (prMatch) {
pullRequests.push({
id: attachment.id,
url: attachment.url,
title: attachment.title || 'Pull Request',
number: parseInt(prMatch[2]),
draft: false,
merged: false
});
}
}
}
const subIssues = await Promise.all(children.nodes.map(async (child) => {
const childState = await child.state;
return {
identifier: child.identifier,
title: child.title,
completed: childState?.type === 'completed' || false
};
}));
const projectMilestones = project ? await project.projectMilestones() : null;
const projectMilestone = projectMilestones && projectMilestones.nodes.length > 0 ? projectMilestones.nodes[0] : null;
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
state: {
name: state?.name || 'Unknown',
color: state?.color || '#000000'
},
assignee: assignee
? {
name: assignee.name,
email: assignee.email
}
: undefined,
dueDate: issue.dueDate ? new Date(issue.dueDate) : undefined,
project: project
? {
name: project.name,
milestone: projectMilestone?.name
}
: undefined,
priority: issue.priority,
labels: labels.nodes.map((label) => ({
name: label.name,
color: label.color
})),
pullRequests,
subIssues,
url: issue.url
};
}));
}
formatProject(project) {
const output = [];
output.push(colors.boldBlue(`\nš ${project.name}`));
output.push(colors.dim(`${project.url}`));
output.push('');
output.push(`${colors.bold('State:')} ${project.state}`);
if (project.progress !== undefined) {
const progressPercent = Math.round(project.progress * 100);
output.push(`${colors.bold('Progress:')} ${progressPercent}%`);
}
if (project.lead) {
output.push(`${colors.bold('Lead:')} ${project.lead.name} (${project.lead.email})`);
}
if (project.startDate) {
output.push(`${colors.bold('Start Date:')} ${project.startDate.toLocaleDateString()}`);
}
if (project.targetDate) {
output.push(`${colors.bold('Target Date:')} ${project.targetDate.toLocaleDateString()}`);
}
output.push('');
if (project.description) {
output.push(colors.bold('Description:'));
output.push(this.formatMarkdown(project.description));
output.push('');
}
output.push(colors.dim(`Created: ${project.createdAt.toLocaleString()}`));
output.push(colors.dim(`Updated: ${project.updatedAt.toLocaleString()}`));
return output.join('\n');
}
formatProjectIssues(issues) {
const output = [];
output.push(colors.boldBlue(`\nš Project Issues (${issues.length})`));
output.push('');
if (issues.length === 0) {
output.push(colors.dim('No issues found in this project.'));
return output.join('\n');
}
const issuesByStatus = new Map();
for (const issue of issues) {
const statusName = issue.state.name;
if (!issuesByStatus.has(statusName)) {
issuesByStatus.set(statusName, []);
}
issuesByStatus.get(statusName)?.push(issue);
}
const statusOrder = ['Product Review', 'In Progress', 'Todo', 'Done', 'Canceled'];
const sortedStatuses = Array.from(issuesByStatus.keys()).sort((a, b) => {
const indexA = statusOrder.indexOf(a);
const indexB = statusOrder.indexOf(b);
if (indexA !== -1 && indexB !== -1)
return indexA - indexB;
if (indexA !== -1)
return -1;
if (indexB !== -1)
return 1;
return a.localeCompare(b);
});
for (const statusName of sortedStatuses) {
const statusIssues = issuesByStatus.get(statusName) || [];
const statusColor = statusIssues[0]?.state.color || '#000000';
output.push(colors.hexBold(statusColor)(`${statusName} (${statusIssues.length})`));
output.push('');
for (const issue of statusIssues) {
const identifier = colors.bold(issue.identifier);
output.push(` ${identifier} ${issue.title}`);
const details = [];
if (issue.assignee) {
details.push(`Assigned: ${issue.assignee.name}`);
}
else {
details.push(`Assigned: ${colors.dim('Unassigned')}`);
}
if (issue.priority !== undefined) {
const priorityLabels = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
details.push(`Priority: ${priorityLabels[issue.priority] || 'Unknown'}`);
}
if (issue.dueDate) {
details.push(`Due: ${issue.dueDate.toLocaleDateString()}`);
}
if (issue.project?.milestone) {
details.push(`Milestone: ${issue.project.milestone}`);
}
output.push(colors.dim(` ${details.join(' ⢠')}`));
if (issue.labels.length > 0) {
const labelStrings = issue.labels.map((label) => colors.hex(label.color)(label.name));
output.push(colors.dim(` Labels: ${labelStrings.join(', ')}`));
}
if (issue.pullRequests.length > 0) {
output.push(colors.dim(` PRs: ${issue.pullRequests.length}`));
issue.pullRequests.forEach((pr) => {
const prStatus = pr.merged ? 'ā
' : pr.draft ? 'š' : 'š';
output.push(colors.dim(` ${prStatus} #${pr.number}: ${pr.title}`));
output.push(colors.dim(` ${pr.url}`));
});
}
if (issue.subIssues.length > 0) {
const completed = issue.subIssues.filter((sub) => sub.completed).length;
output.push(colors.dim(` Sub-issues: ${completed}/${issue.subIssues.length} completed`));
issue.subIssues.forEach((sub) => {
const status = sub.completed ? 'ā
' : 'ā¬';
output.push(colors.dim(` ${status} ${sub.identifier}: ${sub.title}`));
});
}
output.push(colors.dim(` ${issue.url}`));
output.push('');
}
output.push('');
}
return output.join('\n');
}
// ==================== DOCUMENT METHODS ====================
parseDocumentUrl(idOrUrl) {
const { workspace, id } = this.parseLinearUrl(idOrUrl, 'document');
return { workspace, documentId: id };
}
async getDocumentByIdOrUrl(idOrUrl) {
const { workspace, documentId } = this.parseDocumentUrl(idOrUrl);
const account = await this.findAccountForWorkspace(workspace, documentId, 'document');
if (!account) {
throw new Error(`No account found that can access this document. Please check your accounts and API keys.`);
}
const client = new LinearClient({ apiKey: account.api_key });
const document = await client.document(documentId);
const creator = await document.creator;
const updatedBy = await document.updatedBy;
return {
id: document.id,
title: document.title,
content: document.content,
createdBy: creator
? {
name: creator.name,
email: creator.email
}
: undefined,
updatedBy: updatedBy
? {
name: updatedBy.name,
email: updatedBy.email
}
: undefined,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
url: document.url
};
}
formatDocument(document) {
const output = [];
output.push(colors.boldBlue(`\nš ${document.title}`));
output.push(colors.dim(`${document.url}`));
output.push('');
if (document.createdBy) {
output.push(`${colors.bold('Created by:')} ${document.createdBy.name} (${document.createdBy.email})`);
}
if (document.updatedBy) {
output.push(`${colors.bold('Last updated by:')} ${document.updatedBy.name} (${document.updatedBy.email})`);
}
output.push('');
if (document.content) {
output.push(colors.bold('Content:'));
output.push(this.formatMarkdown(document.content));
output.push('');
}
output.push(colors.dim(`Created: ${document.createdAt.toLocaleString()}`));
output.push(colors.dim(`Updated: ${document.updatedAt.toLocaleString()}`));
return output.join('\n');
}
async createDocument(accountName, title, options) {
const account = this.configManager.getAccount(accountName);
if (!account) {
throw new Error(`Account '${accountName}' not found`);
}
const client = new LinearClient({ apiKey: account.api_key });
const input = {
title,
...(options.content && { content: options.content }),
...(options.projectId && { projectId: options.projectId })
};
const payload = await client.createDocument(input);
const document = await payload.document;
if (!document) {
throw new Error('Failed to create document');
}
const creator = await document.creator;
const updatedBy = await document.updatedBy;
return {
id: document.id,
title: document.title,
content: document.content,
createdBy: creator
? {
name: creator.name,
email: creator.email
}
: undefined,
updatedBy: updatedBy
? {
name: updatedBy.name,
email: updatedBy.email
}
: undefined,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
url: document.url
};
}
async deleteDocument(idOrUrl) {
const { workspace, documentId } = this.parseDocumentUrl(idOrUrl);
const account = await this.findAccountForWorkspace(workspace, documentId, 'document');
if (!account) {
throw new Error(`No account found that can access this document. Please check your accounts and API keys.`);
}
const client = new LinearClient({ apiKey: account.api_key });
await client.deleteDocument(documentId);
}
}