From b39e7fbefa2da1fc0284c44e1bbbfd407a48648a Mon Sep 17 00:00:00 2001 From: Ethan Doh Date: Wed, 25 Sep 2019 10:41:11 -0400 Subject: [PATCH 1/2] jira import improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • added yarn cli-debug for debugging on port 9500 • will modify csv header to import multiple columns of same name as an array (for attachments and comments) • add attachment as a link on description • fetch comments • option to add jira issue key as a prefix to the title --- package.json | 1 + src/importers/jiraCsv/JiraCsvImporter.ts | 212 +++++++++++++++++++++-- src/importers/jiraCsv/index.ts | 13 +- 3 files changed, 211 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 9f326c2..4b7b91c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ ], "scripts": { "cli": "ts-node --project cli-tsconfig.json src/cli", + "cli-debug": "TS_NODE_PROJECT=cli-tsconfig.json node --inspect=9500 -r ts-node/register src/cli", "start": "tsdx watch", "build": "tsdx build", "test": "tsdx test", diff --git a/src/importers/jiraCsv/JiraCsvImporter.ts b/src/importers/jiraCsv/JiraCsvImporter.ts index e1ca8e6..acf2874 100644 --- a/src/importers/jiraCsv/JiraCsvImporter.ts +++ b/src/importers/jiraCsv/JiraCsvImporter.ts @@ -1,4 +1,4 @@ -import { Importer, ImportResult } from '../../types'; +import { Comment, Importer, ImportResult } from '../../types'; const csv = require('csvtojson'); const j2m = require('jira2md'); @@ -13,9 +13,28 @@ interface JiraIssueType { 'Project key': string; Summary: string; Assignee: string; + Reporter: string; + Creator: string; Created: string; Release: string; 'Custom field (Story Points)'?: string; + 'Custom field (Epic Link)'?: string; + Attachment: any; + Comment: any; +} + +interface HeaderResult { + HeaderChanged: boolean; + NewHeader: string[]; + error: object | null; +} + +interface labelColor { + [id: string]: { + name: string; + color?: string; + description?: string; + }; } /** @@ -24,9 +43,14 @@ interface JiraIssueType { * @param apiKey GitHub api key for authentication */ export class JiraCsvImporter implements Importer { - public constructor(filePath: string, orgSlug: string) { + public constructor( + filePath: string, + orgSlug: string, + includeIssueKeyInTheTitle: boolean + ) { this.filePath = filePath; this.organizationName = orgSlug; + this.includeIssueKeyInTheTitle = includeIssueKeyInTheTitle; } public get name() { @@ -37,8 +61,81 @@ export class JiraCsvImporter implements Importer { return 'Jira'; } + /** + * Check csv file header and if multiple columns with same header name is found, change header into [headerName].[numericIndexFromZero] format + * i.e. attachment, attachment, attachment => attachment.0, attachment.1, attachment.2 + * Assumption: same name headers are grouped together + * @returns {Promise} + */ + public checkHeader = (): Promise => { + const newHeader: HeaderResult = { + HeaderChanged: false, + NewHeader: [], + error: null, + }; + + return new Promise(resolve => { + csv() + .on('header', (header: string[]) => { + const numDistinctColumns = header.filter( + (val, idx, self) => self.indexOf(val) === idx + ).length; + if (numDistinctColumns !== header.length) { + let prevHd: string = ''; + let headerCounter: number = 0; + newHeader.HeaderChanged = true; + + prevHd = header[0]; + newHeader.NewHeader[0] = prevHd; + for (let index = 1; index < header.length; index++) { + const hd = header[index]; + if (hd === prevHd) { + if (headerCounter === 0) { + newHeader.NewHeader[index - 1] = `${hd}.${headerCounter++}`; + newHeader.NewHeader[index] = `${hd}.${headerCounter++}`; + } else { + newHeader.NewHeader[index] = `${hd}.${headerCounter++}`; + } + } else { + prevHd = hd; + headerCounter = 0; + newHeader.NewHeader[index] = hd; + } + } + } + resolve(newHeader); + }) + .on('error', (err: any) => { + newHeader.error = err; + resolve(newHeader); + }) + .fromFile(this.filePath); + }); + }; + public import = async (): Promise => { - const data = (await csv().fromFile(this.filePath)) as JiraIssueType[]; + const labelColors: labelColor = { + Task: { + name: 'Task', + color: '#4dafe4', + }, + 'Sub-task': { + name: 'Sub-task', + color: '#4dafe4', + }, + Story: { + name: 'Story', + color: '#68b74a', + }, + Bug: { + name: 'Bug', + color: '#e24b42', + }, + Epic: { + name: 'Epic', + color: '#8c5adc', + }, + }; const importData: ImportResult = { issues: [], @@ -47,13 +144,37 @@ export class JiraCsvImporter implements Importer { statuses: {}, }; + const newHeader = (await this.checkHeader()) as HeaderResult; + const csvOptions = { + noheader: false, + headers: (newHeader.HeaderChanged && newHeader.NewHeader) || null, + }; + + if (newHeader.error !== null) { + return importData; + } + + const data = (await csv(csvOptions).fromFile( + this.filePath + )) as JiraIssueType[]; + const statuses = Array.from(new Set(data.map(row => row['Status']))); - const assignees = Array.from(new Set(data.map(row => row['Assignee']))); + const assignees = [ + ...Array.from(new Set(data.map(row => row['Assignee']))), + ...Array.from(new Set(data.map(row => row['Reporter']))), + ...Array.from(new Set(data.map(row => row['Creator']))), + ]; + + const baseUrl = this.organizationName + ? `https://${this.organizationName}.atlassian.net/browse` + : undefined; for (const user of assignees) { - importData.users[user] = { - name: user, - }; + if (user) { + importData.users[user] = { + name: user, + }; + } } for (const status of statuses) { importData.statuses![status] = { @@ -62,15 +183,50 @@ export class JiraCsvImporter implements Importer { } for (const row of data) { - const url = this.organizationName - ? `https://${this.organizationName}.atlassian.net/browse/${row['Issue key']}` - : undefined; - const mdDesc = j2m.to_markdown(row['Description']); + let mdDesc: string = j2m.to_markdown(row['Description']); + + // add attachment links to description + let jiraAttachments = row.Attachment || []; + if (jiraAttachments && typeof jiraAttachments === 'string') { + jiraAttachments = [jiraAttachments]; + } + if (jiraAttachments.length) { + mdDesc += `${mdDesc && '\n\n'}Attachment(s):`; + } + jiraAttachments.forEach((att: string) => { + if (att) { + const [, userId, linkName, linkUrl] = att.split(';'); + mdDesc += `\n[${linkName}](${linkUrl})`; + if (!importData.users[userId]) { + importData.users[userId] = { + name: userId, + }; + } + } + }); + + // put epic link + mdDesc += + row['Custom field (Epic Link)'] && baseUrl + ? `${mdDesc && '\n\n'}[View epic link in Jira \[${ + row['Custom field (Epic Link)'] + }\]](${baseUrl}/${row['Custom field (Epic Link)']})` + : ''; + + // put jira issue link + const url = baseUrl ? `${baseUrl}/${row['Issue key']}` : undefined; const description = url - ? `${mdDesc}\n\n[View original issue in Jira](${url})` + ? `${mdDesc}${mdDesc && '\n\n'}[View original issue in Jira \[${ + row['Issue key'] + }\]](${url})` : mdDesc; + + const title = this.includeIssueKeyInTheTitle + ? `\[${row['Issue key']}\] ${row['Summary']}` + : row['Summary']; + const priority = mapPriority(row['Priority']); - const type = `Type: ${row['Issue Type']}`; + const type = row['Issue Type']; const release = row['Release'] && row['Release'].length > 0 ? `Release: ${row['Release']}` @@ -82,24 +238,51 @@ export class JiraCsvImporter implements Importer { const status = row['Status']; const labels = [type]; + + // comments + const comments: Comment[] = []; + let jiraComments = row.Comment || []; + if (jiraComments && typeof jiraComments === 'string') { + jiraComments = [jiraComments]; + } + const validComments = jiraComments.filter((cm: string) => !!cm); + validComments.forEach((cm: string) => { + const commentChunks = cm.split(';'); + const [createdAt, userId, ...body] = commentChunks; + const commentBody = body.join(';'); + comments.push({ + body: commentBody, + userId, + createdAt: new Date(createdAt), + }); + if (!importData.users[userId]) { + importData.users[userId] = { + name: userId, + }; + } + }); + if (release) { labels.push(release); } importData.issues.push({ - title: row['Summary'], + title, description, status, priority, url, assigneeId, labels, + comments, }); for (const lab of labels) { if (!importData.labels[lab]) { + const color = labelColors[lab]; importData.labels[lab] = { name: lab, + color: color.color, }; } } @@ -112,6 +295,7 @@ export class JiraCsvImporter implements Importer { private filePath: string; private organizationName?: string; + private includeIssueKeyInTheTitle?: boolean; } const mapPriority = (input: JiraPriority): number => { diff --git a/src/importers/jiraCsv/index.ts b/src/importers/jiraCsv/index.ts index d7e66b5..0893973 100644 --- a/src/importers/jiraCsv/index.ts +++ b/src/importers/jiraCsv/index.ts @@ -9,13 +9,18 @@ const JIRA_URL_REGEX = /^https?:\/\/(\S+).atlassian.net/; export const jiraCsvImport = async (): Promise => { const answers = await inquirer.prompt(questions); const orgSlug = answers.jiraUrlName.match(JIRA_URL_REGEX)![1]; - const jiraImporter = new JiraCsvImporter(answers.jiraFilePath, orgSlug); + const jiraImporter = new JiraCsvImporter( + answers.jiraFilePath, + orgSlug, + answers.includeIssueKeyInTheTitle + ); return jiraImporter; }; interface JiraImportAnswers { jiraFilePath: string; jiraUrlName: string; + includeIssueKeyInTheTitle: boolean; } const questions = [ @@ -25,6 +30,12 @@ const questions = [ name: 'jiraFilePath', message: 'Select your exported CSV file of Jira issues', }, + { + type: 'confirm', + name: 'includeIssueKeyInTheTitle', + message: 'Include existing Jira issue key in the title (as prefix)?: ', + default: true, + }, { type: 'input', name: 'jiraUrlName', From 892285ebaee48f853a8c6bc700de46e8b844415d Mon Sep 17 00:00:00 2001 From: Ethan Doh <16962469+neoswallow@users.noreply.github.com> Date: Wed, 25 Sep 2019 10:46:54 -0400 Subject: [PATCH 2/2] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 8bdd6f8..d57e249 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,10 @@ Following fields are supported: - `Description` - Converted into markdown and used as issue description - `Priority` - Issue priority - `Issue Type` - Added as a label +- `Attachemnts` - Added as a link on description - (Optional) `Release` - Added as a label +- (Optional) `Issue key` - Added to the title as a prefix +- (Optional) `Comments` ## Todo