Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Migrate from Jira, Asana, GitHub Issues, or other tools to Linear. Use when planning a migration, executing data transfer, or mapping workflows between issue tracking tools. Trigger: "migrate to linear", "jira to linear", "asana to linear", "import to linear", "linear migration", "github issues to linear".
.claude/skills/jeremylongshore-linear-migration-deep-dive/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 134% | 0% |
| case-17 | ✗→✓ | ▲ Improved | 121% | 0% |
| case-22 | ✗→✓ | ▲ Improved | 76% | 0% |
| case-24 | ✗→✓ | ▲ Improved | 231% | 0% |
| case-19 | ✓→✗ | ▼ Worse | 162% | 0% |
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.
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: ___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) |
Jira Export:
typescript// 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:
typescriptfunction 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"); }
typescriptinterface 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, }; }
typescriptimport { 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 }; }
typescriptasync 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 }; }
[ ] 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| 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 |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-04 | fail→pass | 19,348 | 18,341 | -5% | 1 | 1 | 0% | 2,228 | 5,214 | +134% | 0 | 0 | — |
case-01 | fail→fail | 43,756 | 45,790 | +5% | 1 | 1 | 0% | 6,225 | 9,885 | +59% | 0 | 0 | — |
case-02 | fail→fail | 36,851 | 33,824 | -8% | 1 | 1 | 0% | 6,561 | 7,565 | +15% | 0 | 0 | — |
case-03 | fail→fail | 38,314 | 41,348 | +8% | 1 | 1 | 0% | 6,839 | 10,747 | +57% | 0 | 0 | — |
case-05 | pass→pass | 12,885 | 17,170 | +33% | 1 | 1 | 0% | 1,828 | 4,616 | +153% | 0 | 0 | — |
case-06 | pass→pass | 18,483 | 15,124 | -18% | 1 | 1 | 0% | 2,103 | 4,922 | +134% | 0 | 0 | — |
case-07 | pass→pass | 16,955 | 25,886 | +53% | 1 | 1 | 0% | 1,973 | 6,035 | +206% | 0 | 0 | — |
case-08 | pass→pass | 14,145 | 6,419 | -55% | 1 | 1 | 0% | 1,299 | 3,802 | +193% | 0 | 0 | — |
case-09 | pass→pass | 25,764 | 20,587 | -20% | 1 | 1 | 0% | 3,130 | 5,161 | +65% | 0 | 0 | — |
case-10 | pass→pass | 21,811 | 28,439 | +30% | 1 | 1 | 0% | 3,071 | 6,334 | +106% | 0 | 0 | — |
case-11 | pass→pass | 20,049 | 22,563 | +13% | 1 | 1 | 0% | 2,405 | 5,598 | +133% | 0 | 0 | — |
case-12 | pass→pass | 21,274 | 11,974 | -44% | 1 | 1 | 0% | 2,345 | 5,181 | +121% | 0 | 0 | — |
case-13 | fail→fail | 20,278 | 23,320 | +15% | 1 | 1 | 0% | 2,646 | 6,290 | +138% | 0 | 0 | — |
case-14 | fail→fail | 21,520 | 18,456 | -14% | 1 | 1 | 0% | 2,729 | 5,559 | +104% | 0 | 0 | — |
case-15 | fail→fail | 14,600 | 12,516 | -14% | 1 | 1 | 0% | 2,016 | 4,796 | +138% | 0 | 0 | — |
case-16 | pass→pass | 5,680 | 11,151 | +96% | 1 | 1 | 0% | 998 | 3,959 | +297% | 0 | 0 | — |
case-17 | fail→pass | 18,726 | 7,315 | -61% | 1 | 1 | 0% | 1,931 | 4,260 | +121% | 0 | 0 | — |
case-26 | pass→pass | 21,363 | 13,016 | -39% | 1 | 1 | 0% | 2,650 | 5,018 | +89% | 0 | 0 | — |
case-18 | pass→pass | 10,158 | 14,751 | +45% | 1 | 1 | 0% | 1,755 | 4,352 | +148% | 0 | 0 | — |
case-19 | pass→fail | 18,646 | 16,770 | -10% | 1 | 1 | 0% | 1,966 | 5,151 | +162% | 0 | 0 | — |
case-20 | pass→pass | 12,069 | 5,611 | -54% | 1 | 1 | 0% | 1,215 | 3,905 | +221% | 0 | 0 | — |
case-21 | pass→pass | 16,500 | 18,165 | +10% | 1 | 1 | 0% | 2,486 | 5,001 | +101% | 0 | 0 | — |
case-22 | fail→pass | 17,773 | 8,511 | -52% | 1 | 1 | 0% | 2,000 | 3,529 | +76% | 0 | 0 | — |
case-23 | pass→pass | 16,204 | 24,029 | +48% | 1 | 1 | 0% | 2,441 | 5,824 | +139% | 0 | 0 | — |
case-24 | fail→pass | 12,346 | 6,488 | -47% | 1 | 1 | 0% | 1,233 | 4,084 | +231% | 0 | 0 | — |
case-25 | pass→pass | 17,655 | 11,169 | -37% | 1 | 1 | 0% | 2,494 | 5,068 | +103% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 26 cases were attempted. The headline lift of +12 percentage points is the difference between those two pass rates over the 26 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.