Claude-code-plugins-plus linear-migration-deep-dive

install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/linear-pack/skills/linear-migration-deep-dive" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-linear-migration-deep-dive && rm -rf "$T"
manifest: plugins/saas-packs/linear-pack/skills/linear-migration-deep-dive/SKILL.md
source content

Linear Migration Deep Dive

Overview

Comprehensive guide for migrating from Jira, Asana, or GitHub Issues to Linear. Covers assessment, workflow mapping, data export, transformation, batch import with hierarchy support, and post-migration validation. Linear also has a built-in importer (Settings > Import) for Jira, Asana, GitHub, and CSV.

Prerequisites

  • Admin access to source system (Jira/Asana/GitHub)
  • Linear workspace with admin access
  • API keys for both source and Linear
  • Migration timeline and rollback plan

Instructions

Step 1: Migration Assessment Checklist

Data Volume
[ ] Total issues/tasks: ___
[ ] Projects/boards: ___
[ ] Users to map: ___
[ ] Attachments: ___
[ ] Custom fields: ___
[ ] Comments: ___

Workflow Analysis
[ ] Source statuses documented
[ ] Status-to-state mapping defined
[ ] Priority mapping defined
[ ] Issue type-to-label mapping defined
[ ] Automations to recreate: ___

Timeline
[ ] Migration window: ___
[ ] Parallel run period: ___
[ ] Cutover date: ___
[ ] Rollback deadline: ___

Step 2: Workflow Mapping

Jira -> Linear:

Jira StatusLinear State (type)
To DoTodo (unstarted)
In ProgressIn Progress (started)
In ReviewIn Review (started)
BlockedIn Progress (started) + "Blocked" label
DoneDone (completed)
Won't DoCanceled (canceled)
Jira PriorityLinear Priority
Highest/Blocker1 (Urgent)
High2 (High)
Medium3 (Medium)
Low/Lowest4 (Low)
Jira Issue TypeLinear Label
BugBug
StoryFeature
TaskTask
Epic(becomes Project or parent issue)

Asana -> Linear:

Asana SectionLinear State
BacklogBacklog (backlog)
To DoTodo (unstarted)
In ProgressIn Progress (started)
ReviewIn Review (started)
DoneDone (completed)

Step 3: Export from Source System

Jira Export:

// src/migration/jira-exporter.ts
interface JiraIssue {
  key: string;
  summary: string;
  description: string;
  status: string;
  priority: string;
  issuetype: string;
  assignee?: string;
  labels: string[];
  storyPoints?: number;
  parent?: string;
  subtasks: string[];
}

async function exportJiraProject(
  baseUrl: string,
  projectKey: string,
  authToken: string
): Promise<JiraIssue[]> {
  const issues: JiraIssue[] = [];
  let startAt = 0;
  const maxResults = 100;

  while (true) {
    const jql = `project = ${projectKey} ORDER BY created ASC`;
    const response = await fetch(
      `${baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}&fields=summary,description,status,priority,issuetype,assignee,labels,customfield_10016,parent,subtasks`,
      { headers: { Authorization: `Basic ${authToken}`, Accept: "application/json" } }
    );

    const data = await response.json();
    for (const issue of data.issues) {
      issues.push({
        key: issue.key,
        summary: issue.fields.summary,
        description: issue.fields.description?.content
          ? convertAtlassianDocToMarkdown(issue.fields.description)
          : issue.fields.description ?? "",
        status: issue.fields.status.name,
        priority: issue.fields.priority?.name ?? "Medium",
        issuetype: issue.fields.issuetype.name,
        assignee: issue.fields.assignee?.emailAddress,
        labels: issue.fields.labels ?? [],
        storyPoints: issue.fields.customfield_10016,
        parent: issue.fields.parent?.key,
        subtasks: issue.fields.subtasks?.map((s: any) => s.key) ?? [],
      });
    }

    startAt += maxResults;
    if (startAt >= data.total) break;
  }

  console.log(`Exported ${issues.length} issues from Jira ${projectKey}`);
  return issues;
}

Jira Markup -> Markdown Converter:

function convertJiraToMarkdown(text: string): string {
  if (!text) return "";
  return text
    .replace(/h([1-6])\.\s/g, (_, level) => "#".repeat(parseInt(level)) + " ")
    .replace(/\*([^*]+)\*/g, "**$1**")
    .replace(/_([^_]+)_/g, "*$1*")
    .replace(/\{code(?::([^}]*))?\}([\s\S]*?)\{code\}/g, "```$1\n$2\n```")
    .replace(/\{noformat\}([\s\S]*?)\{noformat\}/g, "```\n$1\n```")
    .replace(/^\*\s/gm, "- ")
    .replace(/^#\s/gm, "1. ")
    .replace(/\[([^|]+)\|([^\]]+)\]/g, "[$1]($2)");
}

Step 4: Transform to Linear Format

interface LinearImportIssue {
  title: string;
  description: string;
  priority: number;
  stateId: string;
  assigneeId?: string;
  labelIds: string[];
  estimate?: number;
  parentId?: string;
  sourceId: string; // Original ID for tracking
}

async function transformJiraIssue(
  jiraIssue: JiraIssue,
  stateMap: Map<string, string>,
  userMap: Map<string, string>,
  labelMap: Map<string, string>
): Promise<LinearImportIssue> {
  // Priority mapping
  const priorityMap: Record<string, number> = {
    Highest: 1, Blocker: 1,
    High: 2,
    Medium: 3,
    Low: 4, Lowest: 4,
  };

  // Map labels
  const labelIds: string[] = [];
  // Issue type becomes a label
  const typeLabel = labelMap.get(jiraIssue.issuetype);
  if (typeLabel) labelIds.push(typeLabel);
  // Original Jira labels
  for (const label of jiraIssue.labels) {
    const mapped = labelMap.get(label);
    if (mapped) labelIds.push(mapped);
  }

  return {
    title: jiraIssue.summary,
    description: convertJiraToMarkdown(jiraIssue.description),
    priority: priorityMap[jiraIssue.priority] ?? 3,
    stateId: stateMap.get(jiraIssue.status) ?? stateMap.get("Todo")!,
    assigneeId: jiraIssue.assignee ? userMap.get(jiraIssue.assignee) : undefined,
    labelIds,
    estimate: jiraIssue.storyPoints ?? undefined,
    sourceId: jiraIssue.key,
  };
}

Step 5: Import to Linear

import { LinearClient } from "@linear/sdk";

async function importToLinear(
  client: LinearClient,
  teamId: string,
  issues: JiraIssue[],
  stateMap: Map<string, string>,
  userMap: Map<string, string>,
  labelMap: Map<string, string>
): Promise<{ created: number; errors: number; idMap: Map<string, string> }> {
  const idMap = new Map<string, string>(); // sourceId -> linearId
  let created = 0;
  let errors = 0;

  // Sort: parents first, then children
  const sorted = [...issues].sort((a, b) => {
    if (a.subtasks.length > 0 && !a.parent) return -1; // Parents first
    if (b.subtasks.length > 0 && !b.parent) return 1;
    return 0;
  });

  for (const jiraIssue of sorted) {
    try {
      const transformed = await transformJiraIssue(jiraIssue, stateMap, userMap, labelMap);

      // Set parent if it was already imported
      if (jiraIssue.parent && idMap.has(jiraIssue.parent)) {
        transformed.parentId = idMap.get(jiraIssue.parent);
      }

      const result = await client.createIssue({
        teamId,
        title: transformed.title,
        description: `${transformed.description}\n\n---\n*Migrated from ${jiraIssue.key}*`,
        priority: transformed.priority,
        stateId: transformed.stateId,
        assigneeId: transformed.assigneeId,
        labelIds: transformed.labelIds,
        estimate: transformed.estimate,
        parentId: transformed.parentId,
      });

      if (result.success) {
        const issue = await result.issue;
        idMap.set(jiraIssue.key, issue!.id);
        created++;
        if (created % 25 === 0) console.log(`Imported ${created}/${sorted.length}`);
      }

      // Rate limit: 100ms between requests
      await new Promise(r => setTimeout(r, 100));
    } catch (error: any) {
      console.error(`Failed to import ${jiraIssue.key}: ${error.message}`);
      errors++;
    }
  }

  console.log(`Import complete: ${created} created, ${errors} errors`);
  return { created, errors, idMap };
}

Step 6: Post-Migration Validation

async function validateMigration(
  client: LinearClient,
  teamId: string,
  sourceIssues: JiraIssue[],
  idMap: Map<string, string>
): Promise<{ valid: boolean; issues: string[] }> {
  const problems: string[] = [];

  // Check all issues were imported
  if (idMap.size < sourceIssues.length) {
    problems.push(`Missing: ${sourceIssues.length - idMap.size} issues not imported`);
  }

  // Sample validation: check 50 random issues
  const sample = sourceIssues.slice(0, 50);
  for (const source of sample) {
    const linearId = idMap.get(source.key);
    if (!linearId) {
      problems.push(`${source.key}: not imported`);
      continue;
    }

    try {
      const issue = await client.issue(linearId);
      if (issue.title !== source.summary) {
        problems.push(`${source.key}: title mismatch`);
      }
    } catch {
      problems.push(`${source.key}: not found in Linear (${linearId})`);
    }

    await new Promise(r => setTimeout(r, 50));
  }

  return { valid: problems.length === 0, issues: problems };
}

Post-Migration Checklist

[ ] All issues imported and validated
[ ] Parent/child relationships correct
[ ] Labels and priorities mapped correctly
[ ] User assignments transferred
[ ] Integrations reconfigured (GitHub, Slack)
[ ] Team workflows customized in Linear
[ ] Team trained on Linear
[ ] Source system set to read-only
[ ] Parallel run period started (2 weeks recommended)
[ ] Archive source system after parallel run

Error Handling

IssueCauseSolution
User not foundUnmapped emailAdd to userMap
Rate limitedToo fast importIncrease delay to 200ms
State not foundUnmapped statusUpdate stateMap
Parent not foundImport order wrongSort parents before children
Markup brokenIncomplete conversionImprove markdown converter

Resources