md-linear-sync
Version:
Sync Linear tickets to local markdown files with status-based folder organization
309 lines ⢠12.9 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.pushCommand = pushCommand;
exports.pullCommand = pullCommand;
exports.pushSingleTicket = pushSingleTicket;
exports.pullSingleTicket = pullSingleTicket;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const client_1 = require("../client");
const config_1 = require("../config");
const parsers_1 = require("../parsers");
async function pushCommand(ticketId) {
console.log(`š Pushing ${ticketId ? `ticket ${ticketId}` : 'all changes'} to Linear...\n`);
try {
// Load configuration
const config = await config_1.ConfigManager.loadConfig();
const envConfig = config_1.ConfigManager.loadEnvironmentConfig();
// Initialize Linear client
const client = new client_1.LinearSyncClient(envConfig.linear.apiKey);
if (ticketId) {
// Push specific ticket
await pushSingleTicket(ticketId, client, config);
}
else {
// Push all modified tickets
await pushAllTickets(client, config);
}
}
catch (error) {
console.error('\nā Push failed:', error instanceof Error ? error.message : 'Unknown error');
process.exit(1);
}
}
async function pullCommand(ticketId) {
console.log(`š„ Pulling ${ticketId ? `ticket ${ticketId}` : 'all changes'} from Linear...\n`);
try {
// Load configuration
const config = await config_1.ConfigManager.loadConfig();
const envConfig = config_1.ConfigManager.loadEnvironmentConfig();
// Initialize Linear client
const client = new client_1.LinearSyncClient(envConfig.linear.apiKey);
if (ticketId) {
// Pull specific ticket
await pullSingleTicket(ticketId, client, config);
}
else {
// Pull all tickets
await pullAllTickets(client, config);
}
}
catch (error) {
console.error('\nā Pull failed:', error instanceof Error ? error.message : 'Unknown error');
process.exit(1);
}
}
async function pushSingleTicket(ticketId, client, config) {
// Find the file for this ticket
const filePath = findTicketFile(ticketId);
if (!filePath) {
throw new Error(`Ticket file for ${ticketId} not found`);
}
console.log(`š Processing ${path_1.default.basename(filePath)}...`);
// Parse the file
const content = fs_1.default.readFileSync(filePath, 'utf-8');
const ticket = parsers_1.TicketFileParser.parseFile(content);
// Validate file
const validation = parsers_1.TicketFileParser.validateFile(content);
if (!validation.valid) {
throw new Error(`Invalid ticket file: ${validation.errors.join(', ')}`);
}
// Get current status folder
const currentFolder = path_1.default.basename(path_1.default.dirname(filePath));
const newStateId = findStateIdForFolder(currentFolder, config);
if (!newStateId) {
throw new Error(`Cannot find Linear state ID for folder "${currentFolder}"`);
}
// Get Linear issue to compare
const linearIssue = await client.getIssue(ticket.frontmatter.linear_id);
// Build updates object
const updates = {};
// Check if status changed (file moved to different folder)
if (linearIssue.state.id !== newStateId) {
updates.stateId = newStateId;
console.log(` š Status: ${linearIssue.state.name} ā ${currentFolder}`);
}
// Check if title changed (prefer frontmatter title, fallback to filename)
const titleFromFrontmatter = ticket.frontmatter.title;
const filename = path_1.default.basename(filePath, '.md');
const titleFromFilename = extractTitleFromFilename(filename, ticket.frontmatter.linear_id);
const newTitle = titleFromFrontmatter || titleFromFilename;
if (newTitle && linearIssue.title !== newTitle) {
updates.title = newTitle;
console.log(` š Title: "${linearIssue.title}" ā "${newTitle}"`);
}
// Check if description changed
if (ticket.content && linearIssue.description !== ticket.content) {
updates.description = ticket.content;
console.log(` š Description updated`);
}
// Apply updates if any
if (Object.keys(updates).length > 0) {
await client.updateIssue(linearIssue.id, updates);
console.log(`ā
Updated ${ticket.frontmatter.linear_id} in Linear`);
// Update the local file's updated_at timestamp
ticket.frontmatter.updated_at = parsers_1.TimezoneUtils.now();
const updatedContent = parsers_1.TicketFileParser.generateFile(ticket);
fs_1.default.writeFileSync(filePath, updatedContent, 'utf-8');
}
else {
console.log(`ā¹ļø No changes detected for ${ticket.frontmatter.linear_id}`);
}
}
async function pushAllTickets(client, config) {
const tickets = getAllTicketFiles();
if (tickets.length === 0) {
console.log('ā¹ļø No ticket files found to push');
return;
}
console.log(`š Found ${tickets.length} ticket files to check for changes`);
let pushed = 0;
let skipped = 0;
let errors = 0;
for (const filePath of tickets) {
try {
const content = fs_1.default.readFileSync(filePath, 'utf-8');
const ticket = parsers_1.TicketFileParser.parseFile(content);
// Check if file was modified since last sync
const stats = fs_1.default.statSync(filePath);
const fileModifiedTime = new Date(stats.mtime);
const lastSyncTime = new Date(ticket.frontmatter.updated_at);
if (fileModifiedTime > lastSyncTime) {
await pushSingleTicket(ticket.frontmatter.linear_id, client, config);
pushed++;
}
else {
skipped++;
}
}
catch (error) {
errors++;
const filename = path_1.default.basename(filePath);
console.error(`ā ${filename}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
console.log(`\nš Push complete: ${pushed} updated, ${skipped} unchanged, ${errors} errors`);
}
async function pullSingleTicket(ticketId, client, config) {
// Get issue from Linear
const issue = await client.getIssue(ticketId);
if (!issue) {
throw new Error(`Ticket ${ticketId} not found in Linear`);
}
console.log(`š Pulling ${issue.identifier}: ${issue.title}`);
// Find current local file if it exists
const existingFilePath = findTicketFile(issue.identifier);
// Get status folder for current Linear state
const statusFolder = findStatusFolder(issue.state.name, config);
if (!statusFolder) {
throw new Error(`Unknown Linear state "${issue.state.name}" not in config`);
}
// Generate new filename and path
const filename = parsers_1.TicketFileParser.generateFilename(issue.identifier, issue.title, issue.parent?.identifier);
const newFilePath = path_1.default.join(process.cwd(), 'linear-tickets', statusFolder, filename);
// Get comments from issue response (already included in getIssue query)
const comments = issue.comments?.nodes || [];
// Convert to ticket format
const ticket = convertLinearIssueToTicket(issue, comments, config);
// Generate content
const content = parsers_1.TicketFileParser.generateFile(ticket);
// If file moved to different folder, remove old file
if (existingFilePath && existingFilePath !== newFilePath) {
fs_1.default.unlinkSync(existingFilePath);
console.log(` š Moved from ${path_1.default.relative(process.cwd(), existingFilePath)} to ${path_1.default.relative(process.cwd(), newFilePath)}`);
}
// Ensure directory exists
const dir = path_1.default.dirname(newFilePath);
if (!fs_1.default.existsSync(dir)) {
fs_1.default.mkdirSync(dir, { recursive: true });
}
// Write file
fs_1.default.writeFileSync(newFilePath, content, 'utf-8');
console.log(`ā
Updated ${issue.identifier}`);
}
async function pullAllTickets(client, config) {
// Get all issues from Linear
const result = await client.getIssues(config.teamId, config.projectId);
const issues = result.issues;
console.log(`š Found ${issues.length} issues in Linear to sync`);
let updated = 0;
let errors = 0;
for (const issue of issues) {
try {
await pullSingleTicket(issue.identifier, client, config);
updated++;
}
catch (error) {
errors++;
console.error(`ā ${issue.identifier}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
console.log(`\nš Pull complete: ${updated} updated, ${errors} errors`);
}
// Helper functions
function findTicketFile(ticketId) {
const linearDir = path_1.default.join(process.cwd(), 'linear-tickets');
if (!fs_1.default.existsSync(linearDir)) {
return null;
}
// Search all status folders
const statusFolders = fs_1.default.readdirSync(linearDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
for (const folder of statusFolders) {
const folderPath = path_1.default.join(linearDir, folder);
const files = fs_1.default.readdirSync(folderPath)
.filter(file => file.endsWith('.md'));
for (const file of files) {
const extractedId = parsers_1.TicketFileParser.extractLinearIdFromFilename(file);
if (extractedId && extractedId.toLowerCase() === ticketId.toLowerCase()) {
return path_1.default.join(folderPath, file);
}
}
}
return null;
}
function getAllTicketFiles() {
const linearDir = path_1.default.join(process.cwd(), 'linear-tickets');
const files = [];
if (!fs_1.default.existsSync(linearDir)) {
return files;
}
// Get all markdown files from all status folders
const statusFolders = fs_1.default.readdirSync(linearDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
for (const folder of statusFolders) {
const folderPath = path_1.default.join(linearDir, folder);
const folderFiles = fs_1.default.readdirSync(folderPath)
.filter(file => file.endsWith('.md') && file !== 'README.md')
.map(file => path_1.default.join(folderPath, file));
files.push(...folderFiles);
}
return files;
}
function findStateIdForFolder(folder, config) {
for (const [stateName, mapping] of Object.entries(config.statusMapping)) {
if (mapping.folder === folder) {
return mapping.id;
}
}
return null;
}
function findStatusFolder(stateName, config) {
for (const [configStateName, mapping] of Object.entries(config.statusMapping)) {
if (configStateName === stateName) {
return mapping.folder;
}
}
return null;
}
function extractTitleFromFilename(filename, linearId) {
// Remove Linear ID prefix and file extension
const prefix = `${linearId}-`;
if (!filename.startsWith(prefix)) {
return null;
}
const title = filename
.substring(prefix.length)
.replace(/-/g, ' ')
.replace(/\b\w/g, char => char.toUpperCase()); // Title case
return title || null;
}
function convertLinearIssueToTicket(issue, linearComments, config) {
// Convert timestamps to SGT
const createdAt = parsers_1.TimezoneUtils.utcToSGT(issue.createdAt);
const updatedAt = parsers_1.TimezoneUtils.utcToSGT(issue.updatedAt);
const dueDate = issue.dueDate ? parsers_1.TimezoneUtils.utcToSGT(issue.dueDate) : undefined;
// Build frontmatter metadata
const frontmatter = {
linear_id: issue.identifier,
title: issue.title,
status: issue.state.name,
assignee: issue.assignee?.email,
labels: issue.labels?.nodes?.map((label) => label.name) || [],
priority: issue.priority || 0,
due_date: dueDate,
url: issue.url,
created_at: createdAt,
updated_at: updatedAt
};
// Convert description
const content = issue.description || `# ${issue.title}\n\n*No description provided*`;
// Convert comments
const comments = linearComments.map(comment => ({
id: comment.id,
author: comment.user?.email || comment.user?.name || 'Unknown',
content: comment.body || '',
created_at: parsers_1.TimezoneUtils.utcToSGT(comment.createdAt)
}));
return {
frontmatter,
content,
comments
};
}
//# sourceMappingURL=sync.js.map