UNPKG

linear-cmd

Version:

A GitHub CLI-like tool for Linear - manage issues, accounts, and more

294 lines (293 loc) • 11.5 kB
import { LinearClient } from '@linear/sdk'; import chalk from 'chalk'; 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) { if (!accountName) { throw new ValidationError('Account is required', [ 'Use --account flag to specify which account to use', 'Run `linear account list` to see available accounts' ]); } const account = configManager.getAccount(accountName); if (!account) { throw new ValidationError(`Account '${accountName}' not found`, [ 'Run `linear account list` to see available accounts' ]); } return { client: new LinearClient({ apiKey: account.api_key }), account }; } export function handleValidationError(error) { Logger.error(error.message); error.hints.forEach((hint) => { Logger.dim(hint); }); } // ==================== MAIN CLIENT CLASS ==================== export class LinearAPIClient { client = null; configManager; constructor() { this.configManager = new ConfigManager(); } async initialize(accountName) { if (!accountName) { throw new Error('Account name is required. Please specify which account to use.'); } const account = this.configManager.getAccount(accountName); if (!account) { throw new Error(`Account '${accountName}' not found. Please check your accounts using "linear account list"`); } this.client = new LinearClient({ apiKey: account.api_key }); } ensureClient() { if (!this.client) { throw new Error('Linear client not initialized. Call initialize() first.'); } return this.client; } 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() ]); // Generate suggested branch name const branchName = this.generateBranchName(issue.identifier, issue.title); // 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 branch: 'unknown' }); } } } } catch (_error) { // Attachments might not be available } // Build issue data const issueData = { id: issue.id, identifier: issue.identifier, title: issue.title, description: issue.description, branchName, 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, issueId) { 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 issue for (const account of accounts) { try { const client = new LinearClient({ apiKey: account.api_key }); await client.issue(issueId); // 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; } parseIssueUrl(idOrUrl) { // If it's a URL, extract workspace and issue identifier const urlMatch = idOrUrl.match(/linear\.app\/([^/]+)\/issue\/([A-Z]+-\d+)/); if (urlMatch) { return { workspace: urlMatch[1], issueId: urlMatch[2] }; } // Otherwise, assume it's already an issue identifier return { workspace: null, issueId: idOrUrl }; } 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}`; } async testConnection() { try { const client = this.ensureClient(); await client.viewer; return true; } catch (_error) { return false; } } // ==================== 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(chalk.bold.blue(`\nšŸŽÆ ${issue.identifier}: ${issue.title}`)); output.push(chalk.dim(`${issue.url}`)); output.push(''); // State and assignee output.push(`${chalk.bold('Status:')} ${chalk.hex(issue.state.color)(issue.state.name)}`); if (issue.assignee) { output.push(`${chalk.bold('Assignee:')} ${issue.assignee.name} (${issue.assignee.email})`); } // Branch name output.push(`${chalk.bold('Suggested Branch:')} ${chalk.green(issue.branchName)}`); // Labels if (issue.labels.length > 0) { const labelStrings = issue.labels.map((label) => chalk.hex(label.color)(label.name)); output.push(`${chalk.bold('Labels:')} ${labelStrings.join(', ')}`); } // Pull Requests if (issue.pullRequests.length > 0) { output.push(`${chalk.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(` ${chalk.dim(pr.url)}`); }); } output.push(''); // Description if (issue.description) { output.push(chalk.bold('Description:')); output.push(this.formatMarkdown(issue.description)); output.push(''); } // Comments if (issue.comments.length > 0) { output.push(chalk.bold('Comments:')); issue.comments.forEach((comment, index) => { output.push(`\n${chalk.bold(`Comment ${index + 1}:`)} ${comment.user.name} (${comment.user.email})`); output.push(chalk.dim(`${comment.createdAt.toLocaleString()}`)); output.push(this.formatMarkdown(comment.body)); }); } // Timestamps output.push(''); output.push(chalk.dim(`Created: ${issue.createdAt.toLocaleString()}`)); output.push(chalk.dim(`Updated: ${issue.updatedAt.toLocaleString()}`)); return output.join('\n'); } formatMarkdown(text) { // Basic markdown formatting return text .replace(/\*\*(.*?)\*\*/g, chalk.bold('$1')) .replace(/\*(.*?)\*/g, chalk.italic('$1')) .replace(/`(.*?)`/g, chalk.cyan('$1')) .replace(/^#{1,6}\s*(.*$)/gm, chalk.bold.underline('$1')) .replace(/^-\s*(.*$)/gm, ` • $1`) .replace(/^\d+\.\s*(.*$)/gm, ` $1`); } }