Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions scripts/monday_import/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
147 changes: 124 additions & 23 deletions scripts/monday_import/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { name: string }>(defs: T[]): T[] {
const seen = new Set<string>();
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<T extends IssueStateType | ProjectStatusType>(
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<string>,
builtIns: string[],
kind: 'issue' | 'project',
): Promise<{
mappings: Record<string, string>;
customIssue: CustomIssueStateDef[];
customProject: CustomProjectStatusDef[];
}> {
const mappings: Record<string, string> = {};
const customIssue: CustomIssueStateDef[] = [];
const customProject: CustomProjectStatusDef[] = [];
const alreadyDefined = new Set<string>();

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<IssueStateType>(status, 'issue');
customIssue.push(def);
mappings[status] = def.name;
alreadyDefined.add(def.name);
} else {
const def = await promptCustomStatus<ProjectStatusType>(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;
Expand Down Expand Up @@ -91,7 +188,9 @@ export async function initCommand(excelPath: string, options: InitOptions): Prom

let statusColumn: string | undefined;
let statusMappings: Record<string, string> = { '_default': 'Backlog' };

let customIssueStates: CustomIssueStateDef[] = [];
let customProjectStatuses: CustomProjectStatusDef[] = [];

if (hasStatus) {
statusColumn = await select({
message: 'Which column contains the status?',
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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];
}
}
}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 15 additions & 2 deletions scripts/monday_import/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ export async function runCommand(options: RunOptions): Promise<void> {
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}`);
}
Expand Down Expand Up @@ -113,6 +119,13 @@ export async function runCommand(options: RunOptions): Promise<void> {
// 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);
Expand Down
18 changes: 18 additions & 0 deletions scripts/monday_import/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface ImportConfig {
fieldMappings: FieldMappingsConfig;
statusMapping: Record<string, string>;
issueStatusMapping?: Record<string, string>;
customIssueStates?: CustomIssueStateDef[];
customProjectStatuses?: CustomProjectStatusDef[];
priorityMapping: Record<string, number>;
labels: LabelConfig[];
groups?: GroupsConfig;
Expand Down Expand Up @@ -57,6 +59,7 @@ export type ImportAs = 'project' | 'issue' | 'parentIssue';
export interface FieldMappingsConfig {
project?: Record<string, FieldMapping>;
issue?: Record<string, FieldMapping>;
subitem?: Record<string, FieldMapping>;
}

export interface FieldMapping {
Expand Down Expand Up @@ -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;
Expand Down
53 changes: 44 additions & 9 deletions scripts/monday_import/src/importer/monday-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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),
Expand All @@ -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}`);
}
}
Comment on lines +204 to +211
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sub-issue creation failures not tracked in result.failures or result.success

(High) When a sub-issue fails to create in the isParentIssue path, the catch block only logs to console and does not call result.failures.push(...) or increment result.summary.failed. This means result.success = result.failures.length === 0 (evaluated at line ~234) returns true even when sub-issues were silently dropped. Callers that check result.success or result.failures to decide whether to re-run or alert on failures will see a false all-green. The same gap exists in the pre-existing isProject subitem path, but the isParentIssue sub-issue loop is entirely new to this PR and should track failures consistently with the outer item loop.

}
}

result.mapping[itemName] = createdId;
Expand Down Expand Up @@ -516,6 +546,7 @@ function buildIssueData(
config: ImportConfig,
linearClient: LinearClientWrapper,
teamId: string,
isSubitem: boolean = false,
): {
title: string;
description?: string;
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
Loading