From 33fab5a04f7cefa0d382e61074282a86205f7990 Mon Sep 17 00:00:00 2001 From: Shannon Hu Date: Tue, 19 May 2026 12:53:32 -0700 Subject: [PATCH] feat(monday-import): support sub-issue relationships and custom Linear statuses Adds two capabilities to the Monday importer to cover use cases the prior flow couldn't represent: 1. Parent/sub-issue relationships: when importing items as issues with subitems enabled (or in parentIssue mode), subitems are now created as real Linear sub-issues via parentId instead of being dropped. Adds a separate fieldMappings.subitem block so sub-issues can have their own field/status mapping distinct from their parents. 2. New issue and project statuses: configs can declare customIssueStates and customProjectStatuses; the importer ensures these exist in Linear (creating them if missing) before mapping items onto them, so Monday workflows with statuses not present in the target Linear team/workspace import cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/monday_import/README.md | 5 + scripts/monday_import/src/commands/init.ts | 147 ++++++++++++++--- scripts/monday_import/src/commands/run.ts | 17 +- scripts/monday_import/src/config/schema.ts | 18 ++ .../src/importer/monday-engine.ts | 53 +++++- scripts/monday_import/src/linear/client.ts | 156 ++++++++++++++++++ 6 files changed, 362 insertions(+), 34 deletions(-) diff --git a/scripts/monday_import/README.md b/scripts/monday_import/README.md index 333319f..e64e190 100644 --- a/scripts/monday_import/README.md +++ b/scripts/monday_import/README.md @@ -190,6 +190,11 @@ Controls how Monday.com items map to Linear entities. } ``` +`importAs` accepts: +- `"project"` — main items become Linear projects; subitems become issues inside the project. +- `"issue"` — main items become flat issues; subitems are ignored. +- `"parentIssue"` — main items become top-level issues and their Monday subitems are attached as **sub-issues** (via `parentId`) under that parent. Use this when you want Linear's native parent/child issue hierarchy instead of the project/issue split. + #### Field Mappings Map Monday.com columns to Linear fields. Separate mappings for projects and issues. diff --git a/scripts/monday_import/src/commands/init.ts b/scripts/monday_import/src/commands/init.ts index 71edb16..c595061 100644 --- a/scripts/monday_import/src/commands/init.ts +++ b/scripts/monday_import/src/commands/init.ts @@ -7,7 +7,104 @@ import { resolve } from 'path'; import { select, input, checkbox, confirm } from '@inquirer/prompts'; import { parseMondayExport, getBoardSummary, columnIndexToLetter, formatColumn } from '../parser/monday.js'; import type { MondayBoard } from '../parser/monday.js'; -import type { ImportConfig, FieldMapping, TransformType } from '../config/schema.js'; +import type { + ImportConfig, + FieldMapping, + TransformType, + CustomIssueStateDef, + CustomProjectStatusDef, + IssueStateType, + ProjectStatusType, +} from '../config/schema.js'; + +const CUSTOM_STATUS_SENTINEL = '__custom__'; +const ISSUE_STATE_TYPES: IssueStateType[] = ['backlog', 'unstarted', 'started', 'completed', 'canceled']; +const PROJECT_STATUS_TYPES: ProjectStatusType[] = ['backlog', 'planned', 'started', 'paused', 'completed', 'canceled']; + +function dedupeByName(defs: T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const d of defs) { + if (seen.has(d.name)) continue; + seen.add(d.name); + out.push(d); + } + return out; +} + +async function promptCustomStatus( + sourceStatus: string, + kind: 'issue' | 'project', +): Promise<{ name: string; type: T; color?: string }> { + const name = (await input({ + message: ` New Linear ${kind} status name for "${sourceStatus}":`, + validate: (v) => v.trim().length > 0 || 'Name is required', + })).trim(); + + const typeChoices = (kind === 'issue' ? ISSUE_STATE_TYPES : PROJECT_STATUS_TYPES) + .map((t) => ({ name: t, value: t })); + + const type = (await select({ + message: ` State category for "${name}":`, + choices: typeChoices, + })) as T; + + const color = (await input({ + message: ' Hex color (optional, e.g. #95A2B3):', + default: '#95A2B3', + })).trim() || undefined; + + return { name, type, color }; +} + +async function mapStatuses( + uniqueStatuses: Set, + builtIns: string[], + kind: 'issue' | 'project', +): Promise<{ + mappings: Record; + customIssue: CustomIssueStateDef[]; + customProject: CustomProjectStatusDef[]; +}> { + const mappings: Record = {}; + const customIssue: CustomIssueStateDef[] = []; + const customProject: CustomProjectStatusDef[] = []; + const alreadyDefined = new Set(); + + for (const status of uniqueStatuses) { + const choices = [ + ...builtIns.map((s) => ({ name: s, value: s })), + ...[...alreadyDefined] + .filter((n) => !builtIns.includes(n)) + .map((n) => ({ name: `${n} (custom)`, value: n })), + { name: '+ Add custom Linear status...', value: CUSTOM_STATUS_SENTINEL }, + ]; + + const choice = await select({ + message: ` "${status}" →`, + choices, + }); + + if (choice === CUSTOM_STATUS_SENTINEL) { + if (kind === 'issue') { + const def = await promptCustomStatus(status, 'issue'); + customIssue.push(def); + mappings[status] = def.name; + alreadyDefined.add(def.name); + } else { + const def = await promptCustomStatus(status, 'project'); + customProject.push(def); + mappings[status] = def.name; + alreadyDefined.add(def.name); + } + } else { + mappings[status] = choice; + alreadyDefined.add(choice); + } + } + + return { mappings, customIssue, customProject }; +} export interface InitOptions { output: string; @@ -91,7 +188,9 @@ export async function initCommand(excelPath: string, options: InitOptions): Prom let statusColumn: string | undefined; let statusMappings: Record = { '_default': 'Backlog' }; - + let customIssueStates: CustomIssueStateDef[] = []; + let customProjectStatuses: CustomProjectStatusDef[] = []; + if (hasStatus) { statusColumn = await select({ message: 'Which column contains the status?', @@ -107,19 +206,16 @@ export async function initCommand(excelPath: string, options: InitOptions): Prom } if (uniqueStatuses.size > 0 && uniqueStatuses.size <= 15) { - console.log('\nMap each status to a Linear project status:'); - // Linear project statuses (not issue statuses) + const kind: 'issue' | 'project' = importAs === 'project' ? 'project' : 'issue'; + console.log(`\nMap each status to a Linear ${kind} status (or add a custom one):`); const linearProjectStatuses = ['Backlog', 'Planned', 'Started', 'Paused', 'Completed', 'Canceled']; const linearIssueStatuses = ['Backlog', 'Todo', 'In Progress', 'In Review', 'Done', 'Canceled']; - const linearStatuses = importAs === 'project' ? linearProjectStatuses : linearIssueStatuses; - - for (const status of uniqueStatuses) { - const mapped = await select({ - message: ` "${status}" →`, - choices: linearStatuses.map(s => ({ name: s, value: s })), - }); - statusMappings[status] = mapped; - } + const builtIns = kind === 'project' ? linearProjectStatuses : linearIssueStatuses; + + const result = await mapStatuses(uniqueStatuses, builtIns, kind); + statusMappings = { ...statusMappings, ...result.mappings }; + customIssueStates = result.customIssue; + customProjectStatuses = result.customProject; } } @@ -302,16 +398,12 @@ export async function initCommand(excelPath: string, options: InitOptions): Prom } if (uniqueSubitemStatuses.size > 0 && uniqueSubitemStatuses.size <= 15) { - console.log('\nMap each subitem status to a Linear issue state:'); + console.log('\nMap each subitem status to a Linear issue state (or add a custom one):'); const linearIssueStates = ['Backlog', 'Todo', 'In Progress', 'In Review', 'Done', 'Canceled']; - - for (const status of uniqueSubitemStatuses) { - const mapped = await select({ - message: ` "${status}" →`, - choices: linearIssueStates.map(s => ({ name: s, value: s })), - }); - issueStatusMappings[status] = mapped; - } + + const result = await mapStatuses(uniqueSubitemStatuses, linearIssueStates, 'issue'); + issueStatusMappings = { ...issueStatusMappings, ...result.mappings }; + customIssueStates = [...customIssueStates, ...result.customIssue]; } } } @@ -437,12 +529,19 @@ export async function initCommand(excelPath: string, options: InitOptions): Prom if (importAs === 'project') { fieldMappings.project = buildProjectMappings(nameColumn, descriptionColumns, statusColumn, leadColumn, timelineColumn, startDateColumn, targetDateColumn); - // Add issue mappings for subitems if enabled + // When importing main items as projects, subitems become issues — their + // mappings go on fieldMappings.issue. if (importSubitems && Object.keys(subitemFieldMappings).length > 0) { fieldMappings.issue = subitemFieldMappings; } } else { fieldMappings.issue = buildIssueMappings(nameColumn, descriptionColumns, statusColumn, leadColumn); + // In parent-issue mode, subitems use their own header set (e.g. "Status + // of Work" instead of "Status") — keep them on fieldMappings.subitem so + // the parent mapping isn't overwritten. + if (importSubitems && Object.keys(subitemFieldMappings).length > 0) { + fieldMappings.subitem = subitemFieldMappings; + } } const config: ImportConfig = { @@ -472,6 +571,8 @@ export async function initCommand(excelPath: string, options: InitOptions): Prom fieldMappings, statusMapping: statusMappings, issueStatusMapping: importSubitems ? issueStatusMappings : undefined, + customIssueStates: customIssueStates.length > 0 ? dedupeByName(customIssueStates) : undefined, + customProjectStatuses: customProjectStatuses.length > 0 ? dedupeByName(customProjectStatuses) : undefined, priorityMapping: { '_default': 0 }, labels: labelColumns.map(col => ({ sourceColumn: col, diff --git a/scripts/monday_import/src/commands/run.ts b/scripts/monday_import/src/commands/run.ts index c0e0410..ce1900c 100644 --- a/scripts/monday_import/src/commands/run.ts +++ b/scripts/monday_import/src/commands/run.ts @@ -52,10 +52,16 @@ export async function runCommand(options: RunOptions): Promise { const { board, updates } = parseMondayExport(options.file); const summary = getBoardSummary(board); + const importAs = config.dataModel.items.importAs; + const subitemsEnabled = config.dataModel.items.subitems?.enabled === true; + const mainLabel = importAs === 'project' ? 'Projects' : importAs === 'parentIssue' || subitemsEnabled ? 'Parent issues' : 'Issues'; + const subLabel = importAs === 'project' ? 'Issues' : 'Sub-issues'; console.log(` Board name: ${board.name}`); console.log(` Board sections: ${summary.totalGroups}`); - console.log(` Projects to import: ${summary.totalMainItems}`); - console.log(` Issues to import: ${summary.totalSubitems}`); + console.log(` ${mainLabel} to import: ${summary.totalMainItems}`); + if (summary.totalSubitems > 0) { + console.log(` ${subLabel} to import: ${summary.totalSubitems}`); + } if (updates && updates.updates.length > 0) { console.log(` Updates to import: ${updates.updates.length}`); } @@ -113,6 +119,13 @@ export async function runCommand(options: RunOptions): Promise { // Re-discover with team context (for issue states) await linearClient.discoverWorkspace(teamKey); + // Ensure any custom Linear statuses defined in config exist (create if missing) + if (config.customIssueStates?.length || config.customProjectStatuses?.length) { + console.log('\nEnsuring custom Linear statuses exist...'); + await linearClient.ensureCustomProjectStatuses(config.customProjectStatuses, isDryRun); + await linearClient.ensureCustomIssueStates(teamId, config.customIssueStates, isDryRun); + } + // Fetch existing data for deduplication if (config.deduplication?.enabled) { await linearClient.fetchExistingProjects(teamId); diff --git a/scripts/monday_import/src/config/schema.ts b/scripts/monday_import/src/config/schema.ts index 15f084b..6ba47b4 100644 --- a/scripts/monday_import/src/config/schema.ts +++ b/scripts/monday_import/src/config/schema.ts @@ -11,6 +11,8 @@ export interface ImportConfig { fieldMappings: FieldMappingsConfig; statusMapping: Record; issueStatusMapping?: Record; + customIssueStates?: CustomIssueStateDef[]; + customProjectStatuses?: CustomProjectStatusDef[]; priorityMapping: Record; labels: LabelConfig[]; groups?: GroupsConfig; @@ -57,6 +59,7 @@ export type ImportAs = 'project' | 'issue' | 'parentIssue'; export interface FieldMappingsConfig { project?: Record; issue?: Record; + subitem?: Record; } export interface FieldMapping { @@ -142,6 +145,21 @@ export interface OptionsConfig { skipEmpty?: boolean; } +export type IssueStateType = 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled'; +export type ProjectStatusType = 'backlog' | 'planned' | 'started' | 'paused' | 'completed' | 'canceled'; + +export interface CustomIssueStateDef { + name: string; + type: IssueStateType; + color?: string; +} + +export interface CustomProjectStatusDef { + name: string; + type: ProjectStatusType; + color?: string; +} + export interface DeduplicationConfig { enabled: boolean; matchBy: string; diff --git a/scripts/monday_import/src/importer/monday-engine.ts b/scripts/monday_import/src/importer/monday-engine.ts index 0ecca80..0eea5a4 100644 --- a/scripts/monday_import/src/importer/monday-engine.ts +++ b/scripts/monday_import/src/importer/monday-engine.ts @@ -61,13 +61,18 @@ export async function runMondayImport( const importAs = config.dataModel.items.importAs; const isProject = importAs === 'project'; + // Treat issue-mode + enabled subitems as parent-issue mode so subitems + // get attached as sub-issues via parentId instead of being dropped. + const subitemsEnabled = config.dataModel.items.subitems?.enabled === true; + const isParentIssue = importAs === 'parentIssue' || (importAs === 'issue' && subitemsEnabled); // Phase 1: Prepare labels console.log(`\nPhase 1: Preparing labels...`); const labelCache = await prepareLabels(mainItems, board, config, linearClient, teamId, dryRun, result); // Phase 2: Import main items - console.log(`\nPhase 2: Importing ${mainItems.length} ${isProject ? 'projects' : 'issues'}...`); + const mainLabel = isProject ? 'projects' : isParentIssue ? 'parent issues' : 'issues'; + console.log(`\nPhase 2: Importing ${mainItems.length} ${mainLabel}...`); for (let i = 0; i < mainItems.length; i++) { const item = mainItems[i]; @@ -106,12 +111,13 @@ export async function runMondayImport( const labelIds = resolveLabelIds(item, board, config, labelCache, isProject); if (dryRun) { - console.log(` → Would create ${isProject ? 'project' : 'issue'}: ${itemName}`); + const kind = isProject ? 'project' : isParentIssue ? 'parent issue' : 'issue'; + console.log(` → Would create ${kind}: ${itemName}`); console.log(` Group: ${item.group || '(none)'}`); console.log(` Labels: ${labelIds.length}`); if (item.subitems && item.subitems.length > 0) { - console.log(` Subitems: ${item.subitems.length}`); - // Count subitems as issues + const subKind = isParentIssue ? 'sub-issues' : 'subitems'; + console.log(` ${subKind}: ${item.subitems.length}`); for (const subitem of item.subitems) { const subitemName = subitem.data['Name'] || subitem.data['name'] || 'Untitled Subitem'; if (subitemName && subitemName !== 'Untitled Subitem') { @@ -153,7 +159,7 @@ export async function runMondayImport( if (!subitemName || subitemName === 'Untitled Subitem') continue; try { - const subitemData = buildIssueData(subitem, config, linearClient, teamId); + const subitemData = buildIssueData(subitem, config, linearClient, teamId, true); const subCreated = await linearClient.createIssue({ ...subitemData, title: truncate(subitemName, 255), @@ -178,8 +184,32 @@ export async function runMondayImport( createdId = created.id; createdUrl = created.url; result.summary.issuesCreated++; - + console.log(` ✓ Created: ${created.identifier}`); + + // In parentIssue mode, attach subitems as sub-issues via parentId + if (isParentIssue && item.subitems && item.subitems.length > 0) { + for (const subitem of item.subitems) { + const subitemName = subitem.data['Name'] || subitem.data['name'] || 'Untitled Subitem'; + if (!subitemName || subitemName === 'Untitled Subitem') continue; + + try { + const subitemData = buildIssueData(subitem, config, linearClient, teamId, true); + const subCreated = await linearClient.createIssue({ + ...subitemData, + title: truncate(subitemName, 255), + teamId: teamId, + parentId: createdId, + }); + console.log(` ✓ Sub-issue: ${subCreated.identifier} - ${truncateDisplay(subitemName, 40)}`); + result.summary.issuesCreated++; + result.mapping[subitemName] = subCreated.id; + } catch (subError) { + const msg = subError instanceof Error ? subError.message : String(subError); + console.log(` ✗ Sub-issue failed: ${truncateDisplay(subitemName, 40)} — ${msg}`); + } + } + } } result.mapping[itemName] = createdId; @@ -516,6 +546,7 @@ function buildIssueData( config: ImportConfig, linearClient: LinearClientWrapper, teamId: string, + isSubitem: boolean = false, ): { title: string; description?: string; @@ -525,7 +556,9 @@ function buildIssueData( estimate?: number; dueDate?: string; } { - const mappings = config.fieldMappings?.issue || {}; + const mappings = (isSubitem && config.fieldMappings?.subitem) + ? config.fieldMappings.subitem + : (config.fieldMappings?.issue || {}); const getTitle = () => { if (mappings.title?.source || mappings.name?.source) { @@ -555,8 +588,10 @@ function buildIssueData( if (stateMapping?.source) { const rawState = item.data[stateMapping.source]; if (rawState) { - // Use issueStatusMapping if available, otherwise fall back to statusMapping - const statusMap = config.issueStatusMapping || config.statusMapping; + // Subitems use issueStatusMapping; main items use statusMapping. + const statusMap = isSubitem + ? (config.issueStatusMapping || config.statusMapping) + : config.statusMapping; const mappedState = statusMap[rawState] || statusMap['_default'] || rawState; return linearClient.resolveIssueStateId(mappedState); } diff --git a/scripts/monday_import/src/linear/client.ts b/scripts/monday_import/src/linear/client.ts index 636ae9b..e185819 100644 --- a/scripts/monday_import/src/linear/client.ts +++ b/scripts/monday_import/src/linear/client.ts @@ -3,6 +3,7 @@ */ import { LinearClient as LinearSDK, LinearDocument } from '@linear/sdk'; +import type { CustomIssueStateDef, CustomProjectStatusDef } from '../config/schema.js'; import type { Team, User, @@ -327,6 +328,161 @@ export class LinearClientWrapper { null; } + /** + * Ensure custom Linear workflow states exist on the given team. + * Looks up each by name in the existing cache; creates any that are missing. + */ + async ensureCustomIssueStates( + teamId: string, + defs: CustomIssueStateDef[] | undefined, + dryRun: boolean = false, + ): Promise { + if (!this.workspace) { + throw new Error('Workspace not discovered. Call discoverWorkspace first.'); + } + if (!defs || defs.length === 0) return; + + // Workflow states must be defined on the top-level (parent) team in Linear. + // Sub-teams inherit their parent's workflow states and cannot create their own. + // Resolve to the parent team id if the configured team is a sub-team, and also + // pull the parent's existing states into the cache so we don't try to recreate them. + const stateTeamId = await this.resolveWorkflowStateTeamId(teamId); + if (stateTeamId !== teamId) { + console.log(` ↑ Detected sub-team; creating workflow states on parent team`); + this.track(); + const parentTeam = await this.client.team(stateTeamId); + this.track(); + const parentStates = await parentTeam.states(); + for (const state of parentStates.nodes) { + if (!this.workspace.issueStates.has(state.name)) { + this.workspace.issueStates.set(state.name, state.id); + this.workspace.issueStates.set(state.name.toLowerCase(), state.id); + } + } + } + + for (const def of defs) { + const existing = + this.workspace.issueStates.get(def.name) ?? + this.workspace.issueStates.get(def.name.toLowerCase()); + if (existing) { + console.log(` ✓ Using existing issue state: ${def.name}`); + continue; + } + + if (dryRun) { + console.log(` [dry-run] Would create Linear issue state: ${def.name} (${def.type})`); + continue; + } + + console.log(` Creating issue state: ${def.name} (${def.type})`); + this.track(); + await this.delay(); + try { + const result = await this.client.createWorkflowState({ + teamId: stateTeamId, + name: def.name, + type: def.type, + color: def.color ?? '#95A2B3', + }); + const state = await result.workflowState; + if (state) { + this.workspace.issueStates.set(state.name, state.id); + this.workspace.issueStates.set(state.name.toLowerCase(), state.id); + console.log(` ✓ Created issue state: ${def.name}`); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to create workflow state "${def.name}": ${errorMsg}`); + } + } + } + + /** + * Return the team id where workflow states should be created. If the given + * team is a sub-team, returns the top-level parent team id; otherwise + * returns the input id unchanged. + */ + private async resolveWorkflowStateTeamId(teamId: string): Promise { + let currentId = teamId; + // Walk up the team hierarchy via raw GraphQL since the SDK Team type + // does not expose `parent` directly. + // eslint-disable-next-line no-constant-condition + while (true) { + this.track(); + const res = await this.client.client.request< + { team: { id: string; parent: { id: string } | null } | null }, + { id: string } + >( + `query TeamParent($id: String!) { team(id: $id) { id parent { id } } }`, + { id: currentId }, + ); + const parentId = res.team?.parent?.id; + if (!parentId) return currentId; + currentId = parentId; + } + } + + /** + * Ensure custom Linear project statuses exist (workspace-level). + * Looks up each by name in the existing cache; creates any that are missing. + */ + async ensureCustomProjectStatuses( + defs: CustomProjectStatusDef[] | undefined, + dryRun: boolean = false, + ): Promise { + if (!this.workspace) { + throw new Error('Workspace not discovered. Call discoverWorkspace first.'); + } + if (!defs || defs.length === 0) return; + + for (const def of defs) { + const existing = + this.workspace.projectStatuses.get(def.name) ?? + this.workspace.projectStatuses.get(def.name.toLowerCase()); + if (existing) { + console.log(` ✓ Using existing project status: ${def.name}`); + continue; + } + + if (dryRun) { + console.log(` [dry-run] Would create Linear project status: ${def.name} (${def.type})`); + continue; + } + + console.log(` Creating project status: ${def.name} (${def.type})`); + this.track(); + await this.delay(); + try { + const result = await this.client.client.request< + { projectStatusCreate: { success: boolean; projectStatus: { id: string; name: string } | null } }, + { input: { name: string; type: string; color?: string } } + >( + ` + mutation CreateProjectStatus($input: ProjectStatusCreateInput!) { + projectStatusCreate(input: $input) { + success + projectStatus { + id + name + } + } + } + `, + { input: { name: def.name, type: def.type, color: def.color ?? '#95A2B3' } }, + ); + const status = result.projectStatusCreate?.projectStatus; + if (status) { + this.workspace.projectStatuses.set(status.name, status.id); + console.log(` ✓ Created project status: ${def.name}`); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to create project status "${def.name}": ${errorMsg}`); + } + } + } + /** * Create or get a project label group (project labels are separate from issue labels) */