Skip to content
This repository was archived by the owner on Nov 14, 2023. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
212 changes: 198 additions & 14 deletions src/importers/jiraCsv/JiraCsvImporter.ts
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');

Expand All @@ -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;
};
}

/**
Expand All @@ -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() {
Expand All @@ -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> => {
Copy link
Contributor

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 reject if there was an error: parseHeader(): Promise<string[] | undefined>. This way you don't need to pass errors isn't used and you can infer HeaderChanged from existence of the data.

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 = {
Copy link
Contributor

Choose a reason for hiding this comment

The 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: [],
Expand All @@ -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 = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming to users would make more sense in this context

...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] = {
Expand All @@ -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):`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of always checking for mdDesc existence, maybe just all || '' to line 186

}
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)']})`
: '';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be written in easier to read form:

if (row['Custom field (Epic Link)'] && baseUrl) {
  mdDesc += `${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']}`
Expand All @@ -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,
};
}
}
Expand All @@ -112,6 +295,7 @@ export class JiraCsvImporter implements Importer {

private filePath: string;
private organizationName?: string;
private includeIssueKeyInTheTitle?: boolean;
}

const mapPriority = (input: JiraPriority): number => {
Expand Down
13 changes: 12 additions & 1 deletion src/importers/jiraCsv/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would change this to false so there's it's more explicit decision

},
{
type: 'input',
name: 'jiraUrlName',
Expand Down