├── src ├── importers │ ├── index.ts │ ├── github │ │ ├── client.ts │ │ ├── index.ts │ │ └── GithubImporter.ts │ ├── pivotalCsv │ │ ├── index.ts │ │ └── PivotalCsvImporter.ts │ ├── trelloJson │ │ ├── index.ts │ │ └── TrelloJsonImporter.ts │ ├── asanaCsv │ │ ├── index.ts │ │ └── AsanaCsvImporter.ts │ ├── clubhouseCsv │ │ ├── index.ts │ │ └── ClubhouseCsvImporter.ts │ └── jiraCsv │ │ ├── index.ts │ │ └── JiraCsvImporter.ts ├── index.ts ├── utils │ ├── replaceAsync.ts │ ├── getTeamProjects.ts │ └── replaceImages.ts ├── client │ ├── types.ts │ └── index.ts ├── types.ts ├── cli.ts └── importIssues.ts ├── bin └── linear-import.js ├── cli-tsconfig.json ├── .gitignore ├── README.md ├── tsconfig.json ├── LICENSE └── package.json /src/importers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './github'; 2 | -------------------------------------------------------------------------------- /bin/linear-import.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../dist'); 4 | -------------------------------------------------------------------------------- /cli-tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .rts2_cache_cjs 5 | .rts2_cache_esm 6 | .rts2_cache_umd 7 | dist 8 | *.csv 9 | .vscode -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './importers'; 2 | export * from './client'; 3 | export * from './types'; 4 | export * from './importIssues'; 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Linear CLI importer has moved to a [new repository](https://github.com/linear/linear/tree/master/packages/import) 2 | Please file all issues and pull requests against that repository. This one will soon be archived. 3 | -------------------------------------------------------------------------------- /src/utils/replaceAsync.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * String replace with async function. 3 | */ 4 | export const replaceAsync = async ( 5 | str: string, 6 | regex: RegExp, 7 | asyncFn: (match: any) => Promise 8 | ) => { 9 | const promises: Promise[] = []; 10 | // @ts-ignore 11 | str.replace(regex, (match, ...args) => { 12 | // @ts-ignore 13 | const promise = asyncFn(match, ...args); 14 | promises.push(promise); 15 | }); 16 | const data = await Promise.all(promises); 17 | return str.replace(regex, () => data.shift() as string); 18 | }; 19 | -------------------------------------------------------------------------------- /src/importers/github/client.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | const GITHUB_API = 'https://api.github.com/graphql'; 4 | 5 | export const githubClient = (apiKey: string) => { 6 | return async (query: string, variables?: { [key: string]: any }) => { 7 | const res = await fetch(GITHUB_API, { 8 | method: 'POST', 9 | headers: { 10 | authorization: `token ${apiKey}`, 11 | }, 12 | body: JSON.stringify({ 13 | query, 14 | variables, 15 | }), 16 | }); 17 | 18 | const data = await res.json(); 19 | return data.data; 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/importers/pivotalCsv/index.ts: -------------------------------------------------------------------------------- 1 | import * as inquirer from 'inquirer'; 2 | import { Importer } from '../../types'; 3 | import { PivotalCsvImporter } from './PivotalCsvImporter'; 4 | 5 | const BASE_PATH = process.cwd(); 6 | 7 | export const pivotalCsvImport = async (): Promise => { 8 | const answers = await inquirer.prompt(questions); 9 | const pivotalImporter = new PivotalCsvImporter(answers.pivotalFilePath); 10 | return pivotalImporter; 11 | }; 12 | 13 | interface PivotalImportAnswers { 14 | pivotalFilePath: string; 15 | } 16 | 17 | const questions = [ 18 | { 19 | basePath: BASE_PATH, 20 | type: 'filePath', 21 | name: 'pivotalFilePath', 22 | message: 'Select your exported CSV file of Pivotal stories', 23 | }, 24 | ]; 25 | -------------------------------------------------------------------------------- /src/utils/getTeamProjects.ts: -------------------------------------------------------------------------------- 1 | interface Team { 2 | id: string; 3 | name: string; 4 | key: string; 5 | projects: { 6 | nodes: { 7 | id: string; 8 | name: string; 9 | key: string; 10 | }[]; 11 | }; 12 | } 13 | 14 | /** 15 | * Given a list of teams and a team id, get the list of projects 16 | * associated with that team. 17 | * 18 | * @param teamId id of the team to get projects from 19 | * @param teams list of teams to check for the given team id 20 | * 21 | * @returns list of projects for the given team 22 | */ 23 | export const getTeamProjects = (teamId: string, teams: Team[]) => { 24 | const teamIndex = teams.findIndex(team => team.id === teamId); 25 | const projects = teamIndex >= 0 ? teams[teamIndex].projects.nodes : []; 26 | return projects; 27 | }; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/importers/github/index.ts: -------------------------------------------------------------------------------- 1 | import { GithubImporter } from './GithubImporter'; 2 | import * as inquirer from 'inquirer'; 3 | import { Importer } from '../../types'; 4 | 5 | export const githubImport = async (): Promise => { 6 | const answers = await inquirer.prompt(questions); 7 | 8 | const [owner, repo] = answers.repo.split('/'); 9 | const githubImporter = new GithubImporter(answers.githubApiKey, owner, repo); 10 | return githubImporter; 11 | }; 12 | 13 | interface GithubImportAnswers { 14 | githubApiKey: string; 15 | linearApiKey: string; 16 | repo: string; 17 | } 18 | 19 | const questions = [ 20 | { 21 | type: 'input', 22 | name: 'githubApiKey', 23 | message: 24 | 'Input your personal GitHub access token (https://github.com/settings/tokens, select `repo` scope)', 25 | }, 26 | { 27 | type: 'input', 28 | name: 'repo', 29 | message: 30 | 'From which repo do you want to import issues from (e.g. "facebook/react")', 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /src/importers/trelloJson/index.ts: -------------------------------------------------------------------------------- 1 | import { TrelloJsonImporter } from './TrelloJsonImporter'; 2 | import * as inquirer from 'inquirer'; 3 | import { Importer } from '../../types'; 4 | 5 | const BASE_PATH = process.cwd(); 6 | 7 | export const trelloJsonImport = async (): Promise => { 8 | const answers = await inquirer.prompt(questions); 9 | const trelloImporter = new TrelloJsonImporter( 10 | answers.trelloFilePath, 11 | answers.discardArchived 12 | ); 13 | return trelloImporter; 14 | }; 15 | 16 | interface TrelloImportAnswers { 17 | trelloFilePath: string; 18 | discardArchived: boolean; 19 | } 20 | 21 | const questions = [ 22 | { 23 | basePath: BASE_PATH, 24 | type: 'filePath', 25 | name: 'trelloFilePath', 26 | message: 'Select your exported JSON file of Trello cards', 27 | }, 28 | { 29 | type: 'confirm', 30 | name: 'discardArchived', 31 | message: 'Would you like to discard the archived cards?', 32 | default: true, 33 | }, 34 | ]; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Linear Orbit, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/importers/asanaCsv/index.ts: -------------------------------------------------------------------------------- 1 | import * as inquirer from 'inquirer'; 2 | import { Importer } from '../../types'; 3 | import { AsanaCsvImporter } from './AsanaCsvImporter'; 4 | 5 | const BASE_PATH = process.cwd(); 6 | 7 | const ASANA_URL_REGEX = /(^https?:\/\/app.asana.com\/0\/\d+\/)list/; 8 | 9 | export const asanaCsvImport = async (): Promise => { 10 | const answers = await inquirer.prompt(questions); 11 | const orgSlug = answers.asanaUrlName.match(ASANA_URL_REGEX)![1]; 12 | const asanaImporter = new AsanaCsvImporter(answers.asanaFilePath, orgSlug); 13 | return asanaImporter; 14 | }; 15 | 16 | interface AsanaImportAnswers { 17 | asanaFilePath: string; 18 | asanaUrlName: string; 19 | } 20 | 21 | const questions = [ 22 | { 23 | basePath: BASE_PATH, 24 | type: 'filePath', 25 | name: 'asanaFilePath', 26 | message: 'Select your exported CSV file of Asana issues', 27 | }, 28 | { 29 | type: 'input', 30 | name: 'asanaUrlName', 31 | message: 32 | 'Input the URL of your Asana board (e.g. https://app.asana.com/0/123456789/list):', 33 | validate: (input: string) => { 34 | return !!input.match(ASANA_URL_REGEX); 35 | }, 36 | }, 37 | ]; 38 | -------------------------------------------------------------------------------- /src/importers/clubhouseCsv/index.ts: -------------------------------------------------------------------------------- 1 | import * as inquirer from 'inquirer'; 2 | import { Importer } from '../../types'; 3 | import { ClubhouseCsvImporter } from './ClubhouseCsvImporter'; 4 | 5 | const BASE_PATH = process.cwd(); 6 | 7 | export const clubhouseCsvImport = async (): Promise => { 8 | const answers = await inquirer.prompt(questions); 9 | const clubhouseImporter = new ClubhouseCsvImporter( 10 | answers.clubhouseFilePath, 11 | answers.clubhouseWorkspaceSlug, 12 | answers.clubhouseAPIToken 13 | ); 14 | return clubhouseImporter; 15 | }; 16 | 17 | interface ClubhouseImportAnswers { 18 | clubhouseFilePath: string; 19 | clubhouseWorkspaceSlug: string; 20 | clubhouseAPIToken: string; 21 | } 22 | 23 | const questions = [ 24 | { 25 | basePath: BASE_PATH, 26 | type: 'filePath', 27 | name: 'clubhouseFilePath', 28 | message: 'Select your exported CSV file of Clubhouse stories', 29 | }, 30 | { 31 | type: 'input', 32 | name: 'clubhouseWorkspaceSlug', 33 | message: 'Input the slug of your Clubhouse workspace (e.g. acme):', 34 | }, 35 | { 36 | type: 'input', 37 | name: 'clubhouseAPIToken', 38 | message: 39 | 'To transfer files from Clubhouse, enter a Clubhouse API token (https://app.clubhouse.io/settings/account/api-tokens):', 40 | }, 41 | ]; 42 | -------------------------------------------------------------------------------- /src/client/types.ts: -------------------------------------------------------------------------------- 1 | export type Variables = { [key: string]: any }; 2 | 3 | export interface GraphQLError { 4 | message: string; 5 | locations: { line: number; column: number }[]; 6 | path: string[]; 7 | } 8 | 9 | export interface GraphQLResponse { 10 | data?: any; 11 | errors?: GraphQLError[]; 12 | extensions?: any; 13 | status: number; 14 | [key: string]: any; 15 | } 16 | 17 | export interface GraphQLRequestContext { 18 | query: string; 19 | variables?: Variables; 20 | } 21 | 22 | export type GraphQLClientRequest = ( 23 | query: string, 24 | variables?: Variables | undefined 25 | ) => Promise; 26 | 27 | export class ClientError extends Error { 28 | response: GraphQLResponse; 29 | request: GraphQLRequestContext; 30 | 31 | constructor(response: GraphQLResponse, request: GraphQLRequestContext) { 32 | const message = `${ClientError.extractMessage(response)}: ${JSON.stringify({ 33 | response, 34 | request, 35 | })}`; 36 | 37 | super(message); 38 | 39 | this.response = response; 40 | this.request = request; 41 | 42 | // this is needed as Safari doesn't support .captureStackTrace 43 | if (typeof Error.captureStackTrace === 'function') { 44 | Error.captureStackTrace(this, ClientError); 45 | } 46 | } 47 | 48 | private static extractMessage(response: GraphQLResponse): string { 49 | try { 50 | return response.errors![0].message; 51 | } catch (e) { 52 | return `GraphQL Error (Code: ${response.status})`; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/importers/jiraCsv/index.ts: -------------------------------------------------------------------------------- 1 | import { JiraCsvImporter } from './JiraCsvImporter'; 2 | import * as inquirer from 'inquirer'; 3 | import { Importer } from '../../types'; 4 | 5 | const BASE_PATH = process.cwd(); 6 | 7 | const JIRA_URL_REGEX = /^https?:\/\/(\S+).atlassian.net/; 8 | 9 | export const jiraCsvImport = async (): Promise => { 10 | const answers = await inquirer.prompt(questions); 11 | let orgSlug = ''; 12 | if (answers.jiraUrlName) { 13 | orgSlug = answers.jiraUrlName.match(JIRA_URL_REGEX)![1]; 14 | } 15 | const jiraImporter = new JiraCsvImporter( 16 | answers.jiraFilePath, 17 | orgSlug, 18 | answers.customJiraUrl 19 | ); 20 | return jiraImporter; 21 | }; 22 | 23 | interface JiraImportAnswers { 24 | jiraFilePath: string; 25 | isCloud: boolean; 26 | customJiraUrl: string; 27 | jiraUrlName: string; 28 | } 29 | 30 | const questions = [ 31 | { 32 | basePath: BASE_PATH, 33 | type: 'filePath', 34 | name: 'jiraFilePath', 35 | message: 'Select your exported CSV file of Jira issues', 36 | }, 37 | { 38 | type: 'confirm', 39 | name: 'isCloud', 40 | message: 41 | 'Is your Jira installation on Jira Cloud (url similar to https://acme.atlassian.net)?', 42 | default: true, 43 | }, 44 | { 45 | type: 'input', 46 | name: 'customJiraUrl', 47 | message: 48 | 'Input the URL of your on-prem Jira installation (e.g. https://jira.mydomain.com):', 49 | when: (answers: JiraImportAnswers) => { 50 | return !answers.isCloud; 51 | }, 52 | validate: (input: string) => { 53 | return input !== ''; 54 | }, 55 | }, 56 | { 57 | type: 'input', 58 | name: 'jiraUrlName', 59 | message: 60 | 'Input the URL of your Jira Cloud installation (e.g. https://acme.atlassian.net):', 61 | when: (answers: JiraImportAnswers) => { 62 | return answers.isCloud; 63 | }, 64 | validate: (input: string) => { 65 | return !!input.match(JIRA_URL_REGEX); 66 | }, 67 | }, 68 | ]; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@linear/import", 3 | "version": "0.2.3", 4 | "main": "dist/index.js", 5 | "module": "dist/linear-import.esm.js", 6 | "typings": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "bin": { 11 | "linear-import": "bin/linear-import.js" 12 | }, 13 | "scripts": { 14 | "cli": "ts-node --project cli-tsconfig.json src/cli", 15 | "start": "tsdx watch", 16 | "build": "tsdx build --entry src/cli.ts", 17 | "test": "tsdx test", 18 | "postinstall": "yarn build" 19 | }, 20 | "peerDependencies": {}, 21 | "husky": { 22 | "hooks": { 23 | "pre-commit": "pretty-quick --staged" 24 | } 25 | }, 26 | "prettier": { 27 | "printWidth": 80, 28 | "tabWidth": 2, 29 | "semi": true, 30 | "singleQuote": true, 31 | "trailingComma": "es5" 32 | }, 33 | "devDependencies": { 34 | "@types/jest": "24.0.17", 35 | "@types/node": "^14.0.1", 36 | "husky": "3.0.3", 37 | "prettier": "1.18.2", 38 | "pretty-quick": "1.11.1", 39 | "tsdx": "0.7.2", 40 | "tslib": "1.10.0", 41 | "typescript": "3.5.3" 42 | }, 43 | "dependencies": { 44 | "@types/chalk": "2.2.0", 45 | "@types/csvtojson": "^1.1.5", 46 | "@types/inquirer": "6.5.0", 47 | "@types/lodash": "^4.14.149", 48 | "@types/node-fetch": "2.5.0", 49 | "chalk": "2.4.2", 50 | "csvtojson": "2.0.10", 51 | "inquirer": "6.5.1", 52 | "inquirer-file-path": "1.0.1", 53 | "jira2md": "^2.0.4", 54 | "lodash": "^4.17.19", 55 | "node-fetch": "2.6.1", 56 | "ts-node": "8.3.0", 57 | "tsdx": "0.7.2" 58 | }, 59 | "description": "Import your tasks into Linear", 60 | "repository": { 61 | "type": "git", 62 | "url": "git+https://github.com/linearapp/linear-import.git" 63 | }, 64 | "keywords": [ 65 | "linear", 66 | "import", 67 | "task", 68 | "issue", 69 | "tracking" 70 | ], 71 | "license": "MIT", 72 | "bugs": { 73 | "url": "https://github.com/linearapp/linear-import/issues" 74 | }, 75 | "homepage": "https://github.com/linearapp/linear-import#readme" 76 | } 77 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** Issue. */ 2 | export interface Issue { 3 | /** Issue title */ 4 | title: string; 5 | /** Description in markdown */ 6 | description?: string; 7 | /** Status */ 8 | status?: string; 9 | /** Assigned user */ 10 | assigneeId?: string; 11 | /** Issue's priority from 0-4, with 0 being the most important. Undefined for non-prioritized. */ 12 | priority?: number; 13 | /** Issue's comments */ 14 | comments?: Comment[]; 15 | /** Issue's label IDs */ 16 | labels?: string[]; 17 | /** Link to original issue. */ 18 | url?: string; 19 | /** When the issue was created. */ 20 | createdAt?: Date; 21 | } 22 | 23 | /** Issue comment */ 24 | export interface Comment { 25 | /** Comment's body in markdown */ 26 | body?: string; 27 | /** User who posted the comments */ 28 | userId: string; 29 | /** When the comment was created. */ 30 | createdAt?: Date; 31 | } 32 | 33 | export type IssueStatus = 34 | | 'backlog' 35 | | 'unstarted' 36 | | 'started' 37 | | 'completed' 38 | | 'canceled'; 39 | 40 | /** Import response. */ 41 | export interface ImportResult { 42 | issues: Issue[]; 43 | statuses?: { 44 | [id: string]: { 45 | name: string; 46 | color?: string; 47 | type?: IssueStatus; 48 | }; 49 | }; 50 | users: { 51 | [id: string]: { 52 | name: string; 53 | email?: string; 54 | avatarUrl?: string; 55 | }; 56 | }; 57 | labels: { 58 | [id: string]: { 59 | name: string; 60 | color?: string; 61 | description?: string; 62 | }; 63 | }; 64 | /// A suffix to be appended to each resource URL (e.g. to authenticate requests) 65 | resourceURLSuffix?: string; 66 | } 67 | 68 | /** 69 | * Generic importer interface. 70 | */ 71 | export interface Importer { 72 | // Import source name (e.g. 'GitHub') 73 | name: string; 74 | // Default team name (used in the prompt) 75 | defaultTeamName?: string; 76 | // Gets issues from import source 77 | import(): Promise; 78 | } 79 | 80 | export interface ImportAnswers { 81 | // Linear's API key 82 | linearApiKey: string; 83 | // Import service type 84 | service: string; 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/replaceImages.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClientRequest } from '../client/types'; 2 | import { replaceAsync } from './replaceAsync'; 3 | 4 | const IMAGE_MD_REGEX = /(?:!\[(.*?)\]\((https?:\/\/.*?)\))/; 5 | const IMAGE_TAG_REGEX = /(?:)/; 6 | 7 | /** 8 | * Replace markdown image URLs with Linear uploaded ones. 9 | * 10 | * Also supports image tags used by GitHub. 11 | * 12 | * @param client Linear API client. 13 | * @param text Markdown content. 14 | * @param urlSuffix A suffix to append to each image URL, such as to perform authentication 15 | * @returns Markdown content with images using the new Linear URLs 16 | */ 17 | export const replaceImagesInMarkdown = async ( 18 | client: GraphQLClientRequest, 19 | text: string, 20 | urlSuffix?: string 21 | ) => { 22 | let result = text; 23 | const effectiveURLSuffix = urlSuffix || ''; 24 | 25 | // Markdown tags 26 | result = await replaceAsync( 27 | result, 28 | IMAGE_MD_REGEX, 29 | async (match, ...args: string[]) => { 30 | match; 31 | const title = args[0]; 32 | const url = args[1]; 33 | let uploadedUrl = await replaceImageUrl(client, url + effectiveURLSuffix); 34 | return `![${title}](${uploadedUrl})`; 35 | } 36 | ); 37 | 38 | // HTML tags 39 | result = await replaceAsync( 40 | result, 41 | IMAGE_TAG_REGEX, 42 | async (match, ...args: string[]) => { 43 | match; 44 | const url = args[0]; 45 | let uploadedUrl = await replaceImageUrl(client, url + effectiveURLSuffix); 46 | return `![](${uploadedUrl})`; 47 | } 48 | ); 49 | 50 | return result; 51 | }; 52 | 53 | /** 54 | * Downloads image and upload it to 55 | * 56 | * @param client Linear API client 57 | * @param url URL of the source image 58 | * @returns URL of the uploaded image 59 | */ 60 | const replaceImageUrl = async (client: GraphQLClientRequest, url: string) => { 61 | try { 62 | const res = await client<{ 63 | imageUploadFromUrl: { success: boolean; url?: string }; 64 | }>( 65 | `mutation uploadImage($url: String!) { 66 | imageUploadFromUrl(url: $url) { 67 | success 68 | url 69 | } 70 | } 71 | `, 72 | { 73 | url, 74 | } 75 | ); 76 | if (res.imageUploadFromUrl.url) { 77 | return res.imageUploadFromUrl.url; 78 | } 79 | } catch (err) { 80 | console.error(`Failed to replace image`, err.message); 81 | } 82 | 83 | return url; 84 | }; 85 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | import { Variables, ClientError, GraphQLClientRequest } from './types'; 2 | import fetch from 'node-fetch'; 3 | import chalk from 'chalk'; 4 | 5 | interface ClientOptions { 6 | url?: string; 7 | headers?: { [key: string]: any }; 8 | } 9 | 10 | /** 11 | * Linear GraphQL client. 12 | */ 13 | export class GraphQLClient { 14 | constructor(apiKey: string, options: ClientOptions = {}) { 15 | this.apiKey = apiKey; 16 | this.url = options.url || 'https://api.linear.app/graphql'; 17 | this.options = options || {}; 18 | } 19 | 20 | public request = async ( 21 | query: string, 22 | variables?: Variables 23 | ): Promise => { 24 | const { headers, ...others } = this.options; 25 | 26 | const body = JSON.stringify({ 27 | query, 28 | variables: variables ? variables : undefined, 29 | }); 30 | 31 | const response = await fetch(this.url, { 32 | method: 'POST', 33 | headers: { 34 | Authorization: this.apiKey, 35 | 'Content-Type': 'application/json', 36 | ...headers, 37 | }, 38 | body, 39 | ...others, 40 | }); 41 | 42 | const result = await response.json(); 43 | 44 | if (response.ok && !result.errors && result.data) { 45 | return result.data; 46 | } else { 47 | const errorResult = 48 | typeof result === 'string' ? { error: result } : result; 49 | 50 | if ( 51 | response.status === 200 && 52 | result.errors && 53 | result.errors.length > 0 54 | ) { 55 | console.log(chalk.red(`Error occurred while importing:\n`)); 56 | console.log(chalk.blue(JSON.stringify(result, undefined, 2))); 57 | } 58 | 59 | throw new ClientError( 60 | { ...errorResult, status: response.status }, 61 | { query, variables } 62 | ); 63 | } 64 | }; 65 | 66 | // -- Private interface 67 | 68 | private apiKey: string; 69 | private url: string; 70 | private options: ClientOptions; 71 | } 72 | 73 | /** 74 | * Create a new Linear API client. 75 | * 76 | * Example: 77 | * 78 | * ``` 79 | * import linearClient from '.'; 80 | * const linear = linearClient(LINEAR_API_KEY); 81 | * linear(` 82 | * query { ... } 83 | * `, { ...attrs }); 84 | * ``` 85 | */ 86 | export default ( 87 | apiKey: string, 88 | options?: ClientOptions 89 | ): GraphQLClientRequest => { 90 | const client = new GraphQLClient(apiKey, options); 91 | return client.request; 92 | }; 93 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import * as inquirer from 'inquirer'; 3 | import { ImportAnswers } from './types'; 4 | import { importIssues } from './importIssues'; 5 | import { githubImport } from './importers/github'; 6 | import { jiraCsvImport } from './importers/jiraCsv'; 7 | import { asanaCsvImport } from './importers/asanaCsv'; 8 | import { pivotalCsvImport } from './importers/pivotalCsv'; 9 | import { clubhouseCsvImport } from './importers/clubhouseCsv'; 10 | import { trelloJsonImport } from './importers/trelloJson'; 11 | 12 | inquirer.registerPrompt('filePath', require('inquirer-file-path')); 13 | 14 | (async () => { 15 | try { 16 | const importAnswers = await inquirer.prompt([ 17 | { 18 | type: 'input', 19 | name: 'linearApiKey', 20 | message: 'Input your Linear API key (https://linear.app/settings/api)', 21 | }, 22 | { 23 | type: 'list', 24 | name: 'service', 25 | message: 'Which service would you like to import from?', 26 | choices: [ 27 | { 28 | name: 'GitHub', 29 | value: 'github', 30 | }, 31 | { 32 | name: 'Jira (CSV export)', 33 | value: 'jiraCsv', 34 | }, 35 | { 36 | name: 'Asana (CSV export)', 37 | value: 'asanaCsv', 38 | }, 39 | { 40 | name: 'Pivotal (CSV export)', 41 | value: 'pivotalCsv', 42 | }, 43 | { 44 | name: 'Clubhouse (CSV export)', 45 | value: 'clubhouseCsv', 46 | }, 47 | { 48 | name: 'Trello (JSON export)', 49 | value: 'trelloJson', 50 | }, 51 | ], 52 | }, 53 | ]); 54 | 55 | // TODO: Validate Linear API 56 | let importer; 57 | switch (importAnswers.service) { 58 | case 'github': 59 | importer = await githubImport(); 60 | break; 61 | case 'jiraCsv': 62 | importer = await jiraCsvImport(); 63 | break; 64 | case 'asanaCsv': 65 | importer = await asanaCsvImport(); 66 | break; 67 | case 'pivotalCsv': 68 | importer = await pivotalCsvImport(); 69 | break; 70 | case 'clubhouseCsv': 71 | importer = await clubhouseCsvImport(); 72 | break; 73 | case 'trelloJson': 74 | importer = await trelloJsonImport(); 75 | break; 76 | default: 77 | console.log(chalk.red(`Invalid importer`)); 78 | return; 79 | } 80 | 81 | if (importer) { 82 | await importIssues(importAnswers.linearApiKey, importer); 83 | } 84 | } catch (e) { 85 | // Deal with the fact the chain failed 86 | console.error(e); 87 | } 88 | })(); 89 | -------------------------------------------------------------------------------- /src/importers/trelloJson/TrelloJsonImporter.ts: -------------------------------------------------------------------------------- 1 | import { Importer, ImportResult } from '../../types'; 2 | const fs = require('fs'); 3 | 4 | type TrelloLabelColor = 5 | | 'green' 6 | | 'yellow' 7 | | 'orange' 8 | | 'red' 9 | | 'purple' 10 | | 'blue' 11 | | 'sky' 12 | | 'lime' 13 | | 'pink' 14 | | 'black'; 15 | 16 | interface TrelloCard { 17 | name: string; 18 | desc: string; 19 | shortUrl: string; 20 | closed: boolean; 21 | labels: { 22 | id: string; 23 | idBoard: string; 24 | name: string; 25 | color: TrelloLabelColor; 26 | }[]; 27 | } 28 | 29 | export class TrelloJsonImporter implements Importer { 30 | public constructor(filePath: string, discardArchived: boolean) { 31 | this.filePath = filePath; 32 | this.discardArchived = discardArchived; 33 | } 34 | 35 | public get name() { 36 | return 'Trello (JSON)'; 37 | } 38 | 39 | public get defaultTeamName() { 40 | return 'Trello'; 41 | } 42 | 43 | public import = async (): Promise => { 44 | const bytes = fs.readFileSync(this.filePath); 45 | const data = JSON.parse(bytes); 46 | 47 | const importData: ImportResult = { 48 | issues: [], 49 | labels: {}, 50 | users: {}, 51 | statuses: {}, 52 | }; 53 | 54 | for (const card of data.cards as TrelloCard[]) { 55 | const url = card.shortUrl; 56 | const mdDesc = card.desc; 57 | const description = `${mdDesc}\n\n[View original card in Trello](${url})`; 58 | const labels = card.labels.map(l => l.id); 59 | 60 | if (this.discardArchived && card.closed) continue; 61 | 62 | importData.issues.push({ 63 | title: card.name, 64 | description, 65 | url, 66 | labels, 67 | }); 68 | 69 | const allLabels = card.labels.map(label => ({ 70 | id: label.id, 71 | color: mapLabelColor(label.color), 72 | name: label.name, 73 | })); 74 | 75 | for (const label of allLabels) { 76 | const { id, ...labelData } = label; 77 | importData.labels[id] = labelData; 78 | } 79 | } 80 | 81 | return importData; 82 | }; 83 | 84 | // -- Private interface 85 | private filePath: string; 86 | private discardArchived: boolean; 87 | } 88 | 89 | // Maps Trello colors to Linear branded colors 90 | const mapLabelColor = (color: TrelloLabelColor): string => { 91 | const colorMap = { 92 | green: '#0F783C', 93 | yellow: '#F2C94C', 94 | orange: '#DB6E1F', 95 | red: '#C52828', 96 | purple: '#5E6AD2', 97 | blue: '#0F7488', 98 | sky: '#26B5CE', 99 | lime: '#4CB782', 100 | pink: '#EB5757', 101 | black: '#ffffff', // black is the new white ¯\_(ツ)_/¯ 102 | }; 103 | return colorMap[color]; 104 | }; 105 | -------------------------------------------------------------------------------- /src/importers/pivotalCsv/PivotalCsvImporter.ts: -------------------------------------------------------------------------------- 1 | import { Importer, ImportResult } from '../../types'; 2 | const csv = require('csvtojson'); 3 | const j2m = require('jira2md'); 4 | 5 | type PivotalStoryType = 'epic' | 'feature' | 'bug' | 'chore' | 'release'; 6 | 7 | interface PivotalIssueType { 8 | Id: string; 9 | Title: string; 10 | Labels: string; 11 | Iteration: string; 12 | 'Iteration Start': string; 13 | 'Iteration End': string; 14 | Type: PivotalStoryType; 15 | Estimate: string; 16 | 'Current State': string; 17 | 'Created at': Date; 18 | 'Accepted at': Date; 19 | Deadline: string; 20 | 'Requested By': string; 21 | Description: string; 22 | URL: string; 23 | 'Owned By': string; 24 | Blocker: string; 25 | 'Blocker Status': string; 26 | Comment: string; 27 | } 28 | 29 | /** 30 | * Import issues from an Pivotal Tracker CSV export. 31 | * 32 | * @param filePath path to csv file 33 | * @param orgSlug base Pivotal project url 34 | */ 35 | export class PivotalCsvImporter implements Importer { 36 | public constructor(filePath: string) { 37 | this.filePath = filePath; 38 | } 39 | 40 | public get name() { 41 | return 'Pivotal (CSV)'; 42 | } 43 | 44 | public get defaultTeamName() { 45 | return 'Pivotal'; 46 | } 47 | 48 | public import = async (): Promise => { 49 | const data = (await csv().fromFile(this.filePath)) as PivotalIssueType[]; 50 | 51 | const importData: ImportResult = { 52 | issues: [], 53 | labels: {}, 54 | users: {}, 55 | statuses: {}, 56 | }; 57 | 58 | const assignees = Array.from(new Set(data.map(row => row['Owned By']))); 59 | 60 | for (const user of assignees) { 61 | importData.users[user] = { 62 | name: user, 63 | }; 64 | } 65 | 66 | for (const row of data) { 67 | const type = row['Type']; 68 | if (type === 'epic' || type === 'release') continue; 69 | 70 | const title = row['Title']; 71 | if (!title) continue; 72 | 73 | const url = row['URL']; 74 | const mdDesc = j2m.to_markdown(row['Description']); 75 | const description = url 76 | ? `${mdDesc}\n\n[View original issue in Pivotal](${url})` 77 | : mdDesc; 78 | 79 | // const priority = parseInt(row['Estimate']) || undefined; 80 | 81 | const tags = row['Labels'].split(','); 82 | 83 | const assigneeId = 84 | row['Owned By'] && row['Owned By'].length > 0 85 | ? row['Owned By'] 86 | : undefined; 87 | 88 | const status = !!row['Accepted at'] ? 'Done' : 'Todo'; 89 | 90 | const labels = tags.filter(tag => !!tag); 91 | 92 | const createdAt = row['Created at']; 93 | 94 | importData.issues.push({ 95 | title, 96 | description, 97 | status, 98 | url, 99 | assigneeId, 100 | labels, 101 | createdAt, 102 | }); 103 | 104 | for (const lab of labels) { 105 | if (!importData.labels[lab]) { 106 | importData.labels[lab] = { 107 | name: lab, 108 | }; 109 | } 110 | } 111 | } 112 | 113 | return importData; 114 | }; 115 | 116 | // -- Private interface 117 | 118 | private filePath: string; 119 | } 120 | -------------------------------------------------------------------------------- /src/importers/asanaCsv/AsanaCsvImporter.ts: -------------------------------------------------------------------------------- 1 | import { Importer, ImportResult } from '../../types'; 2 | const csv = require('csvtojson'); 3 | const j2m = require('jira2md'); 4 | 5 | type AsanaPriority = 'High' | 'Med' | 'Low'; 6 | 7 | interface AsanaIssueType { 8 | 'Task ID': string; 9 | 'Created At': string; 10 | 'Completed At': string; 11 | 'Last Modified': string; 12 | Name: string; 13 | Assignee: string; 14 | 'Assignee Email': string; 15 | 'Start Date': string; 16 | 'Due Date': string; 17 | Tags: string; 18 | Notes: string; 19 | Projects: string; 20 | 'Parent Task': string; 21 | Priority: AsanaPriority; 22 | } 23 | 24 | /** 25 | * Import issues from an Asana CSV export. 26 | * 27 | * @param filePath path to csv file 28 | * @param orgSlug base Asana project url 29 | */ 30 | export class AsanaCsvImporter implements Importer { 31 | public constructor(filePath: string, orgSlug: string) { 32 | this.filePath = filePath; 33 | this.organizationName = orgSlug; 34 | } 35 | 36 | public get name() { 37 | return 'Asana (CSV)'; 38 | } 39 | 40 | public get defaultTeamName() { 41 | return 'Asana'; 42 | } 43 | 44 | public import = async (): Promise => { 45 | const data = (await csv().fromFile(this.filePath)) as AsanaIssueType[]; 46 | 47 | const importData: ImportResult = { 48 | issues: [], 49 | labels: {}, 50 | users: {}, 51 | statuses: {}, 52 | }; 53 | 54 | const assignees = Array.from(new Set(data.map(row => row['Assignee']))); 55 | 56 | for (const user of assignees) { 57 | importData.users[user] = { 58 | name: user, 59 | }; 60 | } 61 | 62 | for (const row of data) { 63 | const title = row['Name']; 64 | if (!title) continue; 65 | 66 | const url = this.organizationName 67 | ? `${this.organizationName}${row['Task ID']}` 68 | : undefined; 69 | const mdDesc = j2m.to_markdown(row['Notes']); 70 | const description = url 71 | ? `${mdDesc}\n\n[View original issue in Asana](${url})` 72 | : mdDesc; 73 | 74 | const priority = mapPriority(row['Priority']); 75 | 76 | const tags = row['Tags'].split(','); 77 | 78 | const assigneeId = 79 | row['Assignee'] && row['Assignee'].length > 0 80 | ? row['Assignee'] 81 | : undefined; 82 | 83 | const status = !!row['Completed At'] ? 'Done' : 'Todo'; 84 | 85 | const labels = tags.filter(tag => !!tag); 86 | 87 | importData.issues.push({ 88 | title, 89 | description, 90 | status, 91 | priority, 92 | url, 93 | assigneeId, 94 | labels, 95 | }); 96 | 97 | for (const lab of labels) { 98 | if (!importData.labels[lab]) { 99 | importData.labels[lab] = { 100 | name: lab, 101 | }; 102 | } 103 | } 104 | } 105 | 106 | return importData; 107 | }; 108 | 109 | // -- Private interface 110 | 111 | private filePath: string; 112 | private organizationName?: string; 113 | } 114 | 115 | const mapPriority = (input: AsanaPriority): number => { 116 | const priorityMap = { 117 | High: 2, 118 | Med: 3, 119 | Low: 4, 120 | }; 121 | return priorityMap[input] || 0; 122 | }; 123 | -------------------------------------------------------------------------------- /src/importers/jiraCsv/JiraCsvImporter.ts: -------------------------------------------------------------------------------- 1 | import { Importer, ImportResult } from '../../types'; 2 | const csv = require('csvtojson'); 3 | const j2m = require('jira2md'); 4 | 5 | type JiraPriority = 'Highest' | 'High' | 'Medium' | 'Low' | 'Lowest'; 6 | 7 | interface JiraIssueType { 8 | Description: string; 9 | Status: string; 10 | 'Issue key': string; 11 | 'Issue Type': string; 12 | Priority: JiraPriority; 13 | 'Project key': string; 14 | Summary: string; 15 | Assignee: string; 16 | Created: string; 17 | Release: string; 18 | 'Custom field (Story Points)'?: string; 19 | } 20 | 21 | /** 22 | * Import issues from a Jira CSV export. 23 | * 24 | * @param apiKey GitHub api key for authentication 25 | */ 26 | export class JiraCsvImporter implements Importer { 27 | public constructor(filePath: string, orgSlug: string, customUrl: string) { 28 | this.filePath = filePath; 29 | this.organizationName = orgSlug; 30 | this.customJiraUrl = customUrl; 31 | } 32 | 33 | public get name() { 34 | return 'Jira (CSV)'; 35 | } 36 | 37 | public get defaultTeamName() { 38 | return 'Jira'; 39 | } 40 | 41 | public import = async (): Promise => { 42 | const data = (await csv().fromFile(this.filePath)) as JiraIssueType[]; 43 | 44 | const importData: ImportResult = { 45 | issues: [], 46 | labels: {}, 47 | users: {}, 48 | statuses: {}, 49 | }; 50 | 51 | const statuses = Array.from(new Set(data.map(row => row['Status']))); 52 | const assignees = Array.from(new Set(data.map(row => row['Assignee']))); 53 | 54 | for (const user of assignees) { 55 | importData.users[user] = { 56 | name: user, 57 | }; 58 | } 59 | for (const status of statuses) { 60 | importData.statuses![status] = { 61 | name: status, 62 | }; 63 | } 64 | 65 | for (const row of data) { 66 | const url = this.organizationName 67 | ? `https://${this.organizationName}.atlassian.net/browse/${row['Issue key']}` 68 | : `${this.customJiraUrl}/browse/${row['Issue key']}`; 69 | const mdDesc = row['Description'] 70 | ? j2m.to_markdown(row['Description']) 71 | : undefined; 72 | const description = 73 | mdDesc && url 74 | ? `${mdDesc}\n\n[View original issue in Jira](${url})` 75 | : url 76 | ? `[View original issue in Jira](${url})` 77 | : undefined; 78 | const priority = mapPriority(row['Priority']); 79 | const type = `Type: ${row['Issue Type']}`; 80 | const release = 81 | row['Release'] && row['Release'].length > 0 82 | ? `Release: ${row['Release']}` 83 | : undefined; 84 | const assigneeId = 85 | row['Assignee'] && row['Assignee'].length > 0 86 | ? row['Assignee'] 87 | : undefined; 88 | const status = row['Status']; 89 | 90 | const labels = [type]; 91 | if (release) { 92 | labels.push(release); 93 | } 94 | 95 | importData.issues.push({ 96 | title: row['Summary'], 97 | description, 98 | status, 99 | priority, 100 | url, 101 | assigneeId, 102 | labels, 103 | }); 104 | 105 | for (const lab of labels) { 106 | if (!importData.labels[lab]) { 107 | importData.labels[lab] = { 108 | name: lab, 109 | }; 110 | } 111 | } 112 | } 113 | 114 | return importData; 115 | }; 116 | 117 | // -- Private interface 118 | 119 | private filePath: string; 120 | private customJiraUrl: string; 121 | private organizationName?: string; 122 | } 123 | 124 | const mapPriority = (input: JiraPriority): number => { 125 | const priorityMap = { 126 | Highest: 1, 127 | High: 2, 128 | Medium: 3, 129 | Low: 4, 130 | Lowest: 0, 131 | }; 132 | return priorityMap[input] || 0; 133 | }; 134 | -------------------------------------------------------------------------------- /src/importers/clubhouseCsv/ClubhouseCsvImporter.ts: -------------------------------------------------------------------------------- 1 | import { Importer, ImportResult } from '../../types'; 2 | const csv = require('csvtojson'); 3 | 4 | type ClubhouseStoryType = 'feature' | 'bug' | 'chore'; 5 | 6 | interface ClubhouseIssueType { 7 | id: string; 8 | name: string; 9 | type: ClubhouseStoryType; 10 | requestor: string; 11 | owners: string[]; 12 | description: string; 13 | is_completed: boolean; 14 | created_at: Date; 15 | started_at: Date; 16 | updated_at: Date; 17 | moved_at: Date; 18 | completed_at: Date; 19 | estimate: number; 20 | external_ticket_count: number; 21 | external_tickets: string[]; 22 | is_blocked: boolean; 23 | is_a_blocker: boolean; 24 | due_date: Date; 25 | labels: string[]; 26 | epic_labels: string[]; 27 | tasks: string[]; 28 | state: string; 29 | epic_id: string; 30 | epic: string; 31 | project_id: string; 32 | project: string; 33 | iteration_id: string; 34 | iteration: string; 35 | is_archived: boolean; 36 | } 37 | 38 | const parseBooleanColumn = (item: string) => item == 'TRUE'; 39 | const parseStringArrayColumn = (item: string) => 40 | item.split(';').filter(s => s.length > 0); 41 | const parseInt = (item: string) => Number.parseInt(item) || 0; 42 | const parseDate = (item: string, _: any, __: any, row: string[]) => { 43 | if (item.length <= 0) return null; 44 | // Inoptimal method for finding the timezone UTC offset, we parse it from the UTC offset column in this row each time 45 | const utcOffset = 46 | row.find(c => /[+-]([01]\d|2[0-4])(:?[0-5]\d)?/g.test(c)) || ''; 47 | return new Date(item + ' ' + utcOffset); 48 | }; 49 | 50 | const colParser = { 51 | owners: parseStringArrayColumn, 52 | is_completed: parseBooleanColumn, 53 | created_at: parseDate, 54 | started_at: parseDate, 55 | updated_at: parseDate, 56 | moved_at: parseDate, 57 | completed_at: parseDate, 58 | estimate: parseInt, 59 | external_ticket_count: parseInt, 60 | external_tickets: parseStringArrayColumn, 61 | is_blocked: parseBooleanColumn, 62 | is_a_blocker: parseBooleanColumn, 63 | due_date: parseDate, 64 | labels: parseStringArrayColumn, 65 | epic_labels: parseStringArrayColumn, 66 | tasks: parseStringArrayColumn, 67 | is_archived: parseBooleanColumn, 68 | // we parse dates using it, so to avoid confusion, leave it out of parsed rows 69 | utc_offset: 'omit', 70 | }; 71 | 72 | /** 73 | * Import issues from an Clubhouse CSV export. 74 | * 75 | * @param filePath path to csv file 76 | * @param workspaceSlug Clubhouse workspace slug (https://app.clubhouse.io/[THIS]) 77 | * @param apiToken A Clubhouse API token (https://app.clubhouse.io/settings/account/api-tokens) 78 | */ 79 | export class ClubhouseCsvImporter implements Importer { 80 | public constructor( 81 | private filePath: string, 82 | workspaceSlug: string, 83 | private apiToken: string 84 | ) { 85 | this.clubhouseBaseURL = 'https://app.clubhouse.io/' + workspaceSlug; 86 | } 87 | 88 | public get name() { 89 | return 'Clubhouse (CSV)'; 90 | } 91 | 92 | public get defaultTeamName() { 93 | return 'Clubhouse'; 94 | } 95 | 96 | public import = async (): Promise => { 97 | const data = (await csv({ colParser, checkTypes: true }).fromFile( 98 | this.filePath 99 | )) as ClubhouseIssueType[]; 100 | 101 | const importData: ImportResult = { 102 | issues: [], 103 | labels: {}, 104 | users: {}, 105 | statuses: {}, 106 | resourceURLSuffix: '?token=' + this.apiToken, 107 | }; 108 | 109 | const assignees = Array.from(new Set(data.map(row => row.owners).flat())); 110 | 111 | for (const user of assignees) { 112 | importData.users[user] = { 113 | name: user, 114 | email: user, 115 | }; 116 | } 117 | 118 | for (const row of data) { 119 | const title = row.name; 120 | if (!title) continue; 121 | 122 | const url = this.clubhouseBaseURL + '/story/' + row.id; 123 | const descriptionParts = [ 124 | row.description, 125 | row.tasks.map(t => `- ${t}`).join('\n'), 126 | row.external_tickets 127 | .map(url => `* **External Link:** ${url}`) 128 | .join('\n'), 129 | `[View original issue in Clubhouse](${url})`, 130 | ]; 131 | const description = descriptionParts 132 | .filter(s => s.length > 0) 133 | .join('\n\n'); 134 | 135 | var tags = row.labels; 136 | tags.push(row.type); 137 | 138 | const assigneeId = row.owners[0]; 139 | 140 | const status = mapStatus(row.state); 141 | 142 | const labels = tags.filter(tag => !!tag); 143 | 144 | const createdAt = row.created_at; 145 | 146 | importData.issues.push({ 147 | title, 148 | description, 149 | status, 150 | url, 151 | assigneeId, 152 | labels, 153 | createdAt, 154 | }); 155 | 156 | for (const lab of labels) { 157 | if (!importData.labels[lab]) { 158 | importData.labels[lab] = { 159 | name: lab, 160 | }; 161 | } 162 | } 163 | } 164 | 165 | return importData; 166 | }; 167 | 168 | // -- Private interface 169 | 170 | private clubhouseBaseURL: string; 171 | } 172 | 173 | const mapStatus = (input: string): string => { 174 | const priorityMap: { [chState: string]: string } = { 175 | // 'Standard' workflow template 176 | Unscheduled: 'Backlog', 177 | 'Ready for Development': 'Todo', 178 | 'In Development': 'In Progress', 179 | 'Ready for Review': 'In Review', 180 | 'Ready for Deploy': 'Done', 181 | Completed: 'Done', 182 | 183 | // 'Simple' workflow template 184 | 'To Do': 'Todo', 185 | Doing: 'In Progress', 186 | Done: 'Done', 187 | }; 188 | return priorityMap[input] || 'Todo'; 189 | }; 190 | -------------------------------------------------------------------------------- /src/importers/github/GithubImporter.ts: -------------------------------------------------------------------------------- 1 | import { githubClient } from './client'; 2 | import { Importer, ImportResult } from '../../types'; 3 | 4 | interface GITHUB_ISSUE { 5 | id: string; 6 | title: string; 7 | body: string; 8 | url: string; 9 | createdAt: string; 10 | labels: { 11 | nodes?: { 12 | id: string; 13 | color: string; 14 | name: string; 15 | description?: string; 16 | }[]; 17 | }; 18 | comments: { 19 | nodes?: { 20 | id: string; 21 | body: string; 22 | createdAt: string; 23 | url: string; 24 | author: { 25 | login: string; 26 | avatarUrl: string; 27 | id?: string; 28 | name?: string; 29 | email?: string; 30 | }; 31 | }[]; 32 | }; 33 | } 34 | 35 | /** 36 | * Fetch and paginate through all Github issues. 37 | * 38 | * @param apiKey GitHub api key for authentication 39 | */ 40 | export class GithubImporter implements Importer { 41 | public constructor(apiKey: string, owner: string, repo: string) { 42 | this.apiKey = apiKey; 43 | this.owner = owner; 44 | this.repo = repo; 45 | } 46 | 47 | public get name() { 48 | return 'GitHub'; 49 | } 50 | 51 | public get defaultTeamName() { 52 | return this.repo; 53 | } 54 | 55 | public import = async (): Promise => { 56 | let issueData: GITHUB_ISSUE[] = []; 57 | let cursor = undefined; 58 | const github = githubClient(this.apiKey); 59 | 60 | while (true) { 61 | try { 62 | const data = (await github( 63 | `query lastIssues($owner: String!, $repo: String!, $num: Int, $cursor: String) { 64 | repository(owner:$owner, name:$repo) { 65 | issues(first:$num, after: $cursor, states:OPEN) { 66 | edges { 67 | node { 68 | id 69 | title 70 | body 71 | url 72 | createdAt 73 | labels(first:100) { 74 | nodes{ 75 | id 76 | color 77 | name 78 | description 79 | } 80 | } 81 | comments(first: 100) { 82 | nodes { 83 | id 84 | body 85 | createdAt 86 | url 87 | author { 88 | login 89 | avatarUrl(size: 255) 90 | ... on User { 91 | id 92 | name 93 | email 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | pageInfo { 101 | hasNextPage 102 | endCursor 103 | } 104 | } 105 | } 106 | }`, 107 | { 108 | owner: this.owner, 109 | repo: this.repo, 110 | num: 25, 111 | cursor, 112 | } 113 | )) as any; 114 | 115 | // User didn't select repo scope 116 | if (!data || !data.repository) { 117 | throw new Error( 118 | `Unable to find repo ${this.owner}/${this.repo}. Did you select \`repo\` scope for your GitHub token?` 119 | ); 120 | } 121 | 122 | cursor = data.repository.issues.pageInfo.endCursor; 123 | const fetchedIssues = data.repository.issues.edges.map( 124 | (data: any) => data.node 125 | ) as GITHUB_ISSUE[]; 126 | issueData = issueData.concat(fetchedIssues); 127 | 128 | if (!data.repository.issues.pageInfo.hasNextPage) { 129 | break; 130 | } 131 | } catch (err) { 132 | console.error(err); 133 | } 134 | } 135 | 136 | const importData: ImportResult = { 137 | issues: [], 138 | labels: {}, 139 | users: {}, 140 | }; 141 | 142 | for (const issue of issueData) { 143 | importData.issues.push({ 144 | title: issue.title, 145 | description: `${issue.body}\n\n[View original issue in GitHub](${issue.url})`, 146 | url: issue.url, 147 | comments: issue.comments.nodes 148 | ? issue.comments.nodes 149 | .filter(comment => comment.author.id) 150 | .map(comment => ({ 151 | body: comment.body, 152 | userId: comment.author.id as string, 153 | createdAt: new Date(comment.createdAt), 154 | })) 155 | : [], 156 | labels: issue.labels.nodes 157 | ? issue.labels.nodes.map(label => label.id) 158 | : [], 159 | createdAt: new Date(issue.createdAt), 160 | }); 161 | 162 | const users = issue.comments.nodes 163 | ? issue.comments.nodes.map(comment => ({ 164 | id: comment.author.id, 165 | name: comment.author.login, 166 | avatarUrl: comment.author.avatarUrl, 167 | email: comment.author.email, 168 | })) 169 | : []; 170 | for (const user of users) { 171 | const { id, email, ...userData } = user; 172 | if (id) { 173 | importData.users[id] = { 174 | ...userData, 175 | email: email && email.length > 0 ? email : undefined, 176 | }; 177 | } 178 | } 179 | 180 | const labels = issue.labels.nodes 181 | ? issue.labels.nodes.map(label => ({ 182 | id: label.id, 183 | color: `#${label.color}`, 184 | name: label.name, 185 | description: label.description, 186 | })) 187 | : []; 188 | for (const label of labels) { 189 | const { id, ...labelData } = label; 190 | importData.labels[id] = labelData; 191 | } 192 | } 193 | 194 | return importData; 195 | }; 196 | 197 | // -- Private interface 198 | 199 | private apiKey: string; 200 | private owner: string; 201 | private repo: string; 202 | } 203 | -------------------------------------------------------------------------------- /src/importIssues.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClientRequest } from './client/types'; 2 | import { replaceImagesInMarkdown } from './utils/replaceImages'; 3 | import { getTeamProjects } from './utils/getTeamProjects'; 4 | import { Importer, ImportResult, Comment } from './types'; 5 | import linearClient from './client'; 6 | import chalk from 'chalk'; 7 | import * as inquirer from 'inquirer'; 8 | import _ from 'lodash'; 9 | 10 | interface ImportAnswers { 11 | newTeam: boolean; 12 | includeComments?: boolean; 13 | includeProject?: string; 14 | selfAssign?: boolean; 15 | targetAssignee?: string; 16 | targetProjectId?: boolean; 17 | targetTeamId?: string; 18 | teamName?: string; 19 | } 20 | 21 | interface QueryResponse { 22 | teams: { 23 | nodes: { 24 | id: string; 25 | name: string; 26 | key: string; 27 | projects: { 28 | nodes: { 29 | id: string; 30 | name: string; 31 | key: string; 32 | }[]; 33 | }; 34 | }[]; 35 | }; 36 | users: { 37 | nodes: { 38 | id: string; 39 | name: string; 40 | active: boolean; 41 | }[]; 42 | }; 43 | viewer: { 44 | id: string; 45 | }; 46 | } 47 | 48 | interface TeamInfoResponse { 49 | team: { 50 | labels: { 51 | nodes: { 52 | id: string; 53 | name: string; 54 | }[]; 55 | }; 56 | states: { 57 | nodes: { 58 | id: string; 59 | name: string; 60 | }[]; 61 | }; 62 | }; 63 | } 64 | 65 | interface LabelCreateResponse { 66 | issueLabelCreate: { 67 | issueLabel: { 68 | id: string; 69 | }; 70 | success: boolean; 71 | }; 72 | } 73 | 74 | /** 75 | * Import issues into Linear via the API. 76 | */ 77 | export const importIssues = async (apiKey: string, importer: Importer) => { 78 | const linear = linearClient(apiKey); 79 | const importData = await importer.import(); 80 | 81 | const queryInfo = (await linear(` 82 | query { 83 | teams { 84 | nodes { 85 | id 86 | name 87 | key 88 | projects { 89 | nodes { 90 | id 91 | name 92 | } 93 | } 94 | } 95 | } 96 | viewer { 97 | id 98 | } 99 | users { 100 | nodes { 101 | id 102 | name 103 | active 104 | } 105 | } 106 | } 107 | `)) as QueryResponse; 108 | 109 | const teams = queryInfo.teams.nodes; 110 | const users = queryInfo.users.nodes.filter(user => user.active); 111 | const me = queryInfo.viewer.id; 112 | 113 | // Prompt the user to either get or create a team 114 | const importAnswers = await inquirer.prompt([ 115 | { 116 | type: 'confirm', 117 | name: 'newTeam', 118 | message: 'Do you want to create a new team for imported issues?', 119 | default: true, 120 | }, 121 | { 122 | type: 'input', 123 | name: 'teamName', 124 | message: 'Name of the team:', 125 | default: importer.defaultTeamName || importer.name, 126 | when: (answers: ImportAnswers) => { 127 | return answers.newTeam; 128 | }, 129 | }, 130 | { 131 | type: 'list', 132 | name: 'targetTeamId', 133 | message: 'Import into team:', 134 | choices: async () => { 135 | return teams.map((team: { id: string; name: string; key: string }) => ({ 136 | name: `[${team.key}] ${team.name}`, 137 | value: team.id, 138 | })); 139 | }, 140 | when: (answers: ImportAnswers) => { 141 | return !answers.newTeam; 142 | }, 143 | }, 144 | { 145 | type: 'confirm', 146 | name: 'includeProject', 147 | message: 'Do you want to import to a specific project?', 148 | when: (answers: ImportAnswers) => { 149 | // if no team is selected then don't show projects screen 150 | if (!answers.targetTeamId) return false; 151 | 152 | const projects = getTeamProjects(answers.targetTeamId, teams); 153 | return projects.length > 0; 154 | }, 155 | }, 156 | { 157 | type: 'list', 158 | name: 'targetProjectId', 159 | message: 'Import into project:', 160 | choices: async (answers: ImportAnswers) => { 161 | const projects = getTeamProjects(answers.targetTeamId as string, teams); 162 | return projects.map((project: { id: string; name: string }) => ({ 163 | name: project.name, 164 | value: project.id, 165 | })); 166 | }, 167 | when: (answers: ImportAnswers) => { 168 | return answers.includeProject; 169 | }, 170 | }, 171 | { 172 | type: 'confirm', 173 | name: 'includeComments', 174 | message: 'Do you want to include comments in the issue description?', 175 | when: () => { 176 | return !!importData.issues.find( 177 | issue => issue.comments && issue.comments.length > 0 178 | ); 179 | }, 180 | }, 181 | { 182 | type: 'confirm', 183 | name: 'selfAssign', 184 | message: 'Do you want to assign these issues to yourself?', 185 | default: true, 186 | }, 187 | { 188 | type: 'list', 189 | name: 'targetAssignee', 190 | message: 'Assign to user:', 191 | choices: () => { 192 | const map = users.map((user: { id: string; name: string }) => ({ 193 | name: user.name, 194 | value: user.id, 195 | })); 196 | map.push({ name: '[Unassigned]', value: '' }); 197 | return map; 198 | }, 199 | when: (answers: ImportAnswers) => { 200 | return !answers.selfAssign; 201 | }, 202 | }, 203 | ]); 204 | 205 | let teamKey: string; 206 | let teamId: string; 207 | if (importAnswers.newTeam) { 208 | // Create a new team 209 | const teamResponse = await linear( 210 | `mutation createIssuesTeam($name: String!) { 211 | teamCreate(input: { name: $name }) { 212 | success 213 | team { 214 | id 215 | name 216 | key 217 | } 218 | } 219 | } 220 | `, 221 | { 222 | name: importAnswers.teamName as string, 223 | } 224 | ); 225 | teamKey = teamResponse.teamCreate.team.key; 226 | teamId = teamResponse.teamCreate.team.id; 227 | } else { 228 | // Use existing team 229 | teamKey = teams.find(team => team.id === importAnswers.targetTeamId)!.key; 230 | teamId = importAnswers.targetTeamId as string; 231 | } 232 | 233 | const teamInfo = (await linear(`query { 234 | team(id: "${teamId}") { 235 | labels { 236 | nodes { 237 | id 238 | name 239 | } 240 | } 241 | states { 242 | nodes { 243 | id 244 | name 245 | } 246 | } 247 | } 248 | }`)) as TeamInfoResponse; 249 | 250 | const issueLabels = teamInfo.team.labels.nodes; 251 | const workflowStates = teamInfo.team.states.nodes; 252 | 253 | const existingLabelMap = {} as { [name: string]: string }; 254 | for (const label of issueLabels) { 255 | const labelName = label.name.toLowerCase(); 256 | if (!existingLabelMap[labelName]) { 257 | existingLabelMap[labelName] = label.id; 258 | } 259 | } 260 | 261 | const projectId = importAnswers.targetProjectId; 262 | 263 | // Create labels and mapping to source data 264 | const labelMapping = {} as { [id: string]: string }; 265 | for (const labelId of Object.keys(importData.labels)) { 266 | const label = importData.labels[labelId]; 267 | const labelName = _.truncate(label.name.trim(), { length: 20 }); 268 | let actualLabelId = existingLabelMap[labelName.toLowerCase()]; 269 | 270 | if (!actualLabelId) { 271 | const labelResponse = (await linear( 272 | ` 273 | mutation createLabel($teamId: String!, $name: String!, $description: String, $color: String) { 274 | issueLabelCreate(input: { name: $name, description: $description, color: $color, teamId: $teamId }) { 275 | issueLabel { 276 | id 277 | } 278 | success 279 | } 280 | } 281 | `, 282 | { 283 | name: labelName, 284 | description: label.description, 285 | color: label.color, 286 | teamId, 287 | } 288 | )) as LabelCreateResponse; 289 | 290 | actualLabelId = labelResponse.issueLabelCreate.issueLabel.id; 291 | existingLabelMap[labelName.toLowerCase()] = actualLabelId; 292 | } 293 | labelMapping[labelId] = actualLabelId; 294 | } 295 | 296 | const existingStateMap = {} as { [name: string]: string }; 297 | for (const state of workflowStates) { 298 | const stateName = state.name.toLowerCase(); 299 | if (!existingStateMap[stateName]) { 300 | existingStateMap[stateName] = state.id; 301 | } 302 | } 303 | 304 | const existingUserMap = {} as { [name: string]: string }; 305 | for (const user of users) { 306 | const userName = user.name.toLowerCase(); 307 | if (!existingUserMap[userName]) { 308 | existingUserMap[userName] = user.id; 309 | } 310 | } 311 | 312 | // Create issues 313 | for (const issue of importData.issues) { 314 | const issueDescription = issue.description 315 | ? await replaceImagesInMarkdown( 316 | linear, 317 | issue.description, 318 | importData.resourceURLSuffix 319 | ) 320 | : undefined; 321 | 322 | const description = 323 | importAnswers.includeComments && issue.comments 324 | ? await buildComments( 325 | linear, 326 | issueDescription || '', 327 | issue.comments, 328 | importData 329 | ) 330 | : issueDescription; 331 | 332 | const labelIds = issue.labels 333 | ? issue.labels.map(labelId => labelMapping[labelId]) 334 | : undefined; 335 | 336 | const stateId = !!issue.status 337 | ? existingStateMap[issue.status.toLowerCase()] 338 | : undefined; 339 | 340 | const existingAssigneeId: string | undefined = !!issue.assigneeId 341 | ? existingUserMap[issue.assigneeId.toLowerCase()] 342 | : undefined; 343 | 344 | const assigneeId: string | undefined = 345 | existingAssigneeId || importAnswers.selfAssign 346 | ? me 347 | : !!importAnswers.targetAssignee && 348 | importAnswers.targetAssignee.length > 0 349 | ? importAnswers.targetAssignee 350 | : undefined; 351 | 352 | await linear( 353 | ` 354 | mutation createIssue( 355 | $teamId: String!, 356 | $projectId: String, 357 | $title: String!, 358 | $description: String, 359 | $priority: Int, 360 | $labelIds: [String!] 361 | $stateId: String 362 | $assigneeId: String 363 | ) { 364 | issueCreate(input: { 365 | teamId: $teamId, 366 | projectId: $projectId, 367 | title: $title, 368 | description: $description, 369 | priority: $priority, 370 | labelIds: $labelIds 371 | stateId: $stateId 372 | assigneeId: $assigneeId 373 | }) { 374 | success 375 | } 376 | } 377 | `, 378 | { 379 | teamId, 380 | projectId, 381 | title: issue.title, 382 | description, 383 | priority: issue.priority, 384 | labelIds, 385 | stateId, 386 | assigneeId, 387 | } 388 | ); 389 | } 390 | 391 | console.error( 392 | chalk.green( 393 | `${importer.name} issues imported to your backlog: https://linear.app/team/${teamKey}/backlog` 394 | ) 395 | ); 396 | }; 397 | 398 | // Build comments into issue description 399 | const buildComments = async ( 400 | client: GraphQLClientRequest, 401 | description: string, 402 | comments: Comment[], 403 | importData: ImportResult 404 | ) => { 405 | const newComments: string[] = []; 406 | for (const comment of comments) { 407 | const user = importData.users[comment.userId]; 408 | const date = comment.createdAt 409 | ? comment.createdAt.toISOString().split('T')[0] 410 | : undefined; 411 | 412 | const body = await replaceImagesInMarkdown( 413 | client, 414 | comment.body || '', 415 | importData.resourceURLSuffix 416 | ); 417 | newComments.push(`**${user.name}**${' ' + date}\n\n${body}\n`); 418 | } 419 | return `${description}\n\n---\n\n${newComments.join('\n\n')}`; 420 | }; 421 | --------------------------------------------------------------------------------