-
Notifications
You must be signed in to change notification settings - Fork 22
jira import improvements #5
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HeaderResult>} | ||
| */ | ||
| public checkHeader = (): Promise<HeaderResult> => { | ||
| 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<ImportResult> => { | ||
| const data = (await csv().fromFile(this.filePath)) as JiraIssueType[]; | ||
| const labelColors: labelColor = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: would move to be a constant along side the importer class, not inside the method. |
||
| 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 = [ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Renaming to |
||
| ...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):`; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of always checking for mdDesc existence, maybe just all |
||
| } | ||
| 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)']})` | ||
| : ''; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could be written in easier to read form: |
||
|
|
||
| // 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 => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,13 +9,18 @@ const JIRA_URL_REGEX = /^https?:\/\/(\S+).atlassian.net/; | |
| export const jiraCsvImport = async (): Promise<Importer> => { | ||
| const answers = await inquirer.prompt<JiraImportAnswers>(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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would change this to |
||
| }, | ||
| { | ||
| type: 'input', | ||
| name: 'jiraUrlName', | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like better return value would be just to return the newly parsed header, or undefined, and
rejectif there was an error:parseHeader(): Promise<string[] | undefined>. This way you don't need to pass errors isn't used and you can inferHeaderChangedfrom existence of the data.