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.mdsource 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 Status | Linear State (type) |
|---|---|
| To Do | Todo (unstarted) |
| In Progress | In Progress (started) |
| In Review | In Review (started) |
| Blocked | In Progress (started) + "Blocked" label |
| Done | Done (completed) |
| Won't Do | Canceled (canceled) |
| Jira Priority | Linear Priority |
|---|---|
| Highest/Blocker | 1 (Urgent) |
| High | 2 (High) |
| Medium | 3 (Medium) |
| Low/Lowest | 4 (Low) |
| Jira Issue Type | Linear Label |
|---|---|
| Bug | Bug |
| Story | Feature |
| Task | Task |
| Epic | (becomes Project or parent issue) |
Asana -> Linear:
| Asana Section | Linear State |
|---|---|
| Backlog | Backlog (backlog) |
| To Do | Todo (unstarted) |
| In Progress | In Progress (started) |
| Review | In Review (started) |
| Done | Done (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
| Issue | Cause | Solution |
|---|---|---|
| User not found | Unmapped email | Add to userMap |
| Rate limited | Too fast import | Increase delay to 200ms |
| State not found | Unmapped status | Update stateMap |
| Parent not found | Import order wrong | Sort parents before children |
| Markup broken | Incomplete conversion | Improve markdown converter |