md-linear-sync
Version:
Sync Linear tickets to local markdown files with status-based folder organization
322 lines ⢠14.1 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createCommand = createCommand;
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const config_1 = require("../config");
const client_1 = require("../client");
const contentProcessor_1 = require("../utils/contentProcessor");
async function createCommand(directory, options) {
try {
const targetDir = directory || '.';
console.log(`š« Creating Linear tickets from markdown files in: ${targetDir}`);
// Load configuration and environment
const config = await config_1.ConfigManager.loadConfig();
const envConfig = config_1.ConfigManager.loadEnvironmentConfig();
if (!envConfig.linear?.apiKey) {
console.error('ā LINEAR_API_KEY not found in environment');
process.exit(1);
}
// Find all markdown files in target directory
const markdownFiles = await findMarkdownFiles(targetDir);
if (markdownFiles.length === 0) {
console.log(`ā¹ļø No markdown files found in ${targetDir}`);
return;
}
console.log(`š Found ${markdownFiles.length} markdown files`);
// Parse all files and build ticket objects
const ticketFiles = [];
for (const filePath of markdownFiles) {
try {
const metadata = await parseTicketFile(filePath);
const fileContent = await promises_1.default.readFile(filePath, 'utf-8');
const bodyContent = (0, contentProcessor_1.extractBodyContent)(fileContent, metadata.title);
// Validate metadata
const validationErrors = validateMetadata(metadata, config);
if (validationErrors.length > 0) {
console.error(`ā Validation errors in ${filePath}:`);
validationErrors.forEach(error => console.error(` ⢠${error}`));
continue;
}
// Extract dependencies
const dependencies = extractDependencies(metadata, filePath);
ticketFiles.push({
filePath,
metadata,
bodyContent,
dependencies
});
}
catch (error) {
console.error(`ā Failed to parse ${filePath}:`, error instanceof Error ? error.message : 'Unknown error');
}
}
if (ticketFiles.length === 0) {
console.error('ā No valid ticket files found');
process.exit(1);
}
// Build dependency graph and determine creation order
const creationOrder = resolveDependencyOrder(ticketFiles);
console.log(`š Resolved dependency order: ${creationOrder.map(t => path_1.default.basename(t.filePath)).join(' ā ')}`);
if (options.dryRun) {
showDryRunOutput(creationOrder, config);
return;
}
// Create tickets in dependency order
const client = new client_1.LinearSyncClient(envConfig.linear.apiKey);
await createTicketsInOrder(creationOrder, config, client);
}
catch (error) {
console.error('ā Failed to create tickets:', error instanceof Error ? error.message : 'Unknown error');
process.exit(1);
}
}
async function findMarkdownFiles(directory) {
const files = await promises_1.default.readdir(directory);
return files
.filter(file => file.endsWith('.md') && !file.startsWith('.'))
.map(file => path_1.default.join(directory, file));
}
async function parseTicketFile(filePath) {
const content = await promises_1.default.readFile(filePath, 'utf-8');
// Parse frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
throw new Error('File must have YAML frontmatter section');
}
const frontmatter = frontmatterMatch[1];
const metadata = {};
// Simple YAML parsing
const lines = frontmatter.split('\n');
for (const line of lines) {
const match = line.match(/^(\w+):\s*(.+)$/);
if (match) {
const [, key, value] = match;
if (key === 'labels') {
const arrayMatch = value.match(/^\[(.*)\]$/);
if (arrayMatch) {
metadata[key] = arrayMatch[1]
.split(',')
.map(label => label.trim().replace(/['"`]/g, ''))
.filter(label => label.length > 0);
}
else {
metadata[key] = [];
}
}
else if (key === 'priority') {
metadata[key] = parseInt(value, 10);
}
else {
metadata[key] = value.trim().replace(/['"`]/g, '');
}
}
}
if (!metadata.title) {
throw new Error('Title is required in frontmatter');
}
return metadata;
}
function validateMetadata(metadata, config) {
const errors = [];
// Validate status
if (metadata.status && !config.statusMapping[metadata.status]) {
errors.push(`Invalid status "${metadata.status}". Available: ${Object.keys(config.statusMapping).join(', ')}`);
}
// Validate priority
if (metadata.priority !== undefined && (metadata.priority < 0 || metadata.priority > 4)) {
errors.push('Priority must be between 0-4');
}
// Validate labels
if (metadata.labels) {
const availableLabels = Object.keys(config.labelMapping);
const invalidLabels = metadata.labels.filter(label => !availableLabels.includes(label));
if (invalidLabels.length > 0) {
errors.push(`Invalid labels: ${invalidLabels.join(', ')}. Available: ${availableLabels.join(', ')}`);
}
}
return errors;
}
function extractDependencies(metadata, filePath) {
const dependencies = [];
if (metadata.parent_id) {
const linearIdPattern = /^[A-Z]+-\d+$/;
if (!linearIdPattern.test(metadata.parent_id)) {
// It's a file path - resolve it relative to current file
const resolvedPath = path_1.default.resolve(path_1.default.dirname(filePath), metadata.parent_id);
const relativePath = path_1.default.relative('.', resolvedPath);
dependencies.push(relativePath);
}
}
return dependencies;
}
function resolveDependencyOrder(ticketFiles) {
const fileMap = new Map();
const visited = new Set();
const visiting = new Set();
const result = [];
// Create file map
for (const file of ticketFiles) {
fileMap.set(file.filePath, file);
}
// Topological sort with cycle detection
function visit(filePath) {
if (visiting.has(filePath)) {
throw new Error(`Circular dependency detected involving ${filePath}`);
}
if (visited.has(filePath)) {
return;
}
const file = fileMap.get(filePath);
if (!file) {
console.warn(`ā ļø Dependency file not found: ${filePath}`);
return;
}
visiting.add(filePath);
// Visit dependencies first
for (const dep of file.dependencies) {
visit(dep);
}
visiting.delete(filePath);
visited.add(filePath);
result.push(file);
}
// Process all files
for (const file of ticketFiles) {
visit(file.filePath);
}
return result;
}
function showDryRunOutput(ticketFiles, config) {
console.log('\nš DRY RUN - Would create tickets in this order:');
for (let i = 0; i < ticketFiles.length; i++) {
const file = ticketFiles[i];
console.log(`\n${i + 1}. ${path_1.default.basename(file.filePath)}`);
console.log(` Title: ${file.metadata.title}`);
if (file.metadata.status) {
console.log(` Status: ${file.metadata.status}`);
}
if (file.metadata.priority !== undefined) {
const priorityLabels = ['No priority', 'Urgent', 'High', 'Normal', 'Low'];
console.log(` Priority: ${file.metadata.priority} (${priorityLabels[file.metadata.priority]})`);
}
if (file.metadata.labels && file.metadata.labels.length > 0) {
console.log(` Labels: ${file.metadata.labels.join(', ')}`);
}
if (file.metadata.parent_id) {
console.log(` Parent: ${file.metadata.parent_id}`);
}
if (file.dependencies.length > 0) {
console.log(` Dependencies: ${file.dependencies.map(d => path_1.default.basename(d)).join(', ')}`);
}
}
}
async function createTicketsInOrder(ticketFiles, config, client) {
const createdTickets = new Map();
for (let i = 0; i < ticketFiles.length; i++) {
const file = ticketFiles[i];
console.log(`\nš Creating ticket ${i + 1}/${ticketFiles.length}: ${path_1.default.basename(file.filePath)}`);
try {
// Resolve parent ID if it's a file dependency
let parentId;
if (file.metadata.parent_id) {
const linearIdPattern = /^[A-Z]+-\d+$/;
if (linearIdPattern.test(file.metadata.parent_id)) {
// It's already a Linear ticket ID - verify it exists
const parentTicket = await client.findIssueByIdentifier(file.metadata.parent_id);
if (parentTicket) {
parentId = parentTicket.id;
}
else {
console.warn(`ā ļø Parent ticket ${file.metadata.parent_id} not found - creating without parent`);
}
}
else {
// It's a file path - look up the created ticket
const parentFilePath = path_1.default.resolve(path_1.default.dirname(file.filePath), file.metadata.parent_id);
const relativePath = path_1.default.relative('.', parentFilePath);
const parentTicket = createdTickets.get(relativePath);
if (parentTicket) {
parentId = parentTicket.id;
}
else {
console.warn(`ā ļø Parent file ${relativePath} not processed yet - creating without parent`);
}
}
}
// Create the ticket
const ticket = await createTicket(file, config, client, parentId);
createdTickets.set(file.filePath, ticket);
// Update file with Linear metadata
await updateFileWithLinearMetadata(file.filePath, ticket);
// Move file to status folder
await moveFileToStatusFolder(file.filePath, file.metadata.status || 'Todo', config, ticket);
console.log(`ā
Created: ${ticket.identifier} - ${ticket.title}`);
console.log(`š ${ticket.url}`);
}
catch (error) {
console.error(`ā Failed to create ticket for ${file.filePath}:`, error instanceof Error ? error.message : 'Unknown error');
}
}
}
async function createTicket(file, config, client, parentId) {
// Resolve label IDs
const labelIds = file.metadata.labels
? file.metadata.labels.map(labelName => config.labelMapping[labelName]?.id).filter(Boolean)
: [];
// Resolve status ID
const stateId = file.metadata.status
? config.statusMapping[file.metadata.status]?.id
: undefined;
const ticket = await client.createIssue(config.teamId, file.metadata.title, file.bodyContent, stateId, config.projectId, labelIds, parentId, file.metadata.priority);
return ticket;
}
async function updateFileWithLinearMetadata(filePath, ticket) {
const content = await promises_1.default.readFile(filePath, 'utf-8');
// Update frontmatter with Linear data
const linearMetadata = [
`linear_id: ${ticket.identifier}`,
`created_at: '${ticket.createdAt}'`,
`updated_at: '${ticket.updatedAt}'`,
`url: ${ticket.url}`
].join('\n');
const frontmatterMatch = content.match(/^(---\n)([\s\S]*?)(\n---)/);
if (frontmatterMatch) {
const updatedFrontmatter = frontmatterMatch[2] + '\n' + linearMetadata;
const updatedContent = content.replace(/^---\n[\s\S]*?\n---/, `---\n${updatedFrontmatter}\n---`);
await promises_1.default.writeFile(filePath, updatedContent);
}
}
async function moveFileToStatusFolder(filePath, status, config, ticket) {
const statusInfo = config.statusMapping[status];
if (!statusInfo)
return;
// Generate filename using the same logic as import/sync
const sanitizedTitle = ticket.title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
.substring(0, 50); // Limit length
let filename;
if (ticket.parent?.identifier) {
// Child ticket: PAP-434.447-child-task.md
const childNumber = ticket.identifier.split('-')[1];
filename = `${ticket.parent.identifier}.${childNumber}-${sanitizedTitle}.md`;
}
else {
// Regular ticket: PAP-447-implement-feature.md
filename = `${ticket.identifier}-${sanitizedTitle}.md`;
}
const statusFolder = path_1.default.join('linear-tickets', statusInfo.folder);
const newPath = path_1.default.join(statusFolder, filename);
// Create status folder if it doesn't exist
await promises_1.default.mkdir(statusFolder, { recursive: true });
// Move file to status folder
await promises_1.default.rename(filePath, newPath);
}
//# sourceMappingURL=create.js.map