1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-24 13:43:12 +02:00

Import jira (#1561)

* Load and parse xml

* Priority and status

* Replace foreach

* type property

* explicitArray false

* Parse description html

* Use turndown to convert html

* Allow optional priority

* Import assignee and reporter as Select

* Store original URL

* Created date

* Created date

* Update readme

* .gitignore

* Update readme

* Update import readme

* Fix readme

* Update import/jira/README.md

Fix typo.

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>

* Remove commented out line

* Add basic Jest test

* Test that import was complete

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
This commit is contained in:
Chen-I Lim 2021-10-20 09:13:53 -07:00 committed by GitHub
parent 95b230acea
commit 0e10f52317
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 9914 additions and 1 deletions

View File

@ -1,4 +1,10 @@
# Import scripts
This subfolder contains scripts to import data from other systems. It is at an early stage. At present, there are examples of basic importing from Trello, Asana, and Notion. [Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.
This subfolder contains scripts to import data from other systems. It is at an early stage. At present, there are examples of basic importing from the following:
* Trello
* Asana
* Notion
* Jira
* Todoist
[Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.

1
import/jira/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
test

23
import/jira/README.md Normal file
View File

@ -0,0 +1,23 @@
# Jira importer
This node app converts a Jira xml export into a Focalboard archive. To use:
1. Open Jira advanced search, and search for all the items to export
2. Select `Export`, then `Export XML`
3. Save it locally, e.g. to `jira_export.xml`
4. Run `npm install` from within `focalboard/webapp`
5. Run `npm install` from within `focalboard/import/jira`
6. Run `npx ts-node importJira.ts -i <path-to-jira.xml> -o archive.focalboard` (also from within `focalboard/import/jira`)
7. In Focalboard, click `Settings`, then `Import archive` and select `archive.focalboard`
## Import scope and known limitations
Currently, the script imports each item as a card into a single board. Note that Jira XML export is limited to 1000 issues at a time.
Users are imported as Select properties, with the name of the user.
The following aren't currently imported:
* Custom properties
* Comments
* Embedded files
[Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.

16
import/jira/importJira.ts Normal file
View File

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import minimist from 'minimist'
import {run} from './jiraImporter'
async function main() {
const args: minimist.ParsedArgs = minimist(process.argv.slice(2))
const inputFile = args['i']
const outputFile = args['o'] || 'archive.focalboard'
return run(inputFile, outputFile)
}
main()

View File

@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {run} from './jiraImporter'
import * as fs from 'fs'
import {ArchiveUtils} from '../../webapp/src/blocks/archive'
const inputFile = './test/jira-export.xml'
const outputFile = './test/jira.focalboard'
describe('import from Jira', () => {
test('import', async () => {
const blockCount = await run(inputFile, outputFile)
expect(blockCount === 4)
})
test('import was complete', async () => {
const archiveData = fs.readFileSync(outputFile, 'utf-8')
const blocks = ArchiveUtils.parseBlockArchive(archiveData)
console.debug(blocks)
blocks.forEach(block => {
console.log(block.title)
})
expect(blocks).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Jira import',
type: 'board'
}),
expect.objectContaining({
title: 'Board View',
type: 'view'
}),
expect.objectContaining({
title: 'Investigate feature area',
type: 'card'
}),
expect.objectContaining({
title: 'Investigate feature',
type: 'card'
}),
])
)
})
afterAll(() => {
fs.rmSync(outputFile)
});
})

243
import/jira/jiraImporter.ts Normal file
View File

@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as fs from 'fs'
import {exit} from 'process'
import {ArchiveUtils} from '../../webapp/src/blocks/archive'
import {Block} from '../../webapp/src/blocks/block'
import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board'
import {createBoardView} from '../../webapp/src/blocks/boardView'
import {Card, createCard} from '../../webapp/src/blocks/card'
import {createTextBlock} from '../../webapp/src/blocks/textBlock'
import {Utils} from './utils'
import xml2js, {ParserOptions} from 'xml2js'
import TurndownService from 'turndown'
// HACKHACK: To allow Utils.CreateGuid to work
(global.window as any) = {}
const optionColors = [
'propColorGray',
'propColorBrown',
'propColorOrange',
'propColorYellow',
'propColorGreen',
'propColorBlue',
'propColorPurple',
'propColorPink',
'propColorRed',
]
let optionColorIndex = 0
var turndownService = new TurndownService()
async function run(inputFile: string, outputFile: string): Promise<number> {
console.log(`input: ${inputFile}`)
console.log(`output: ${outputFile}`)
if (!inputFile) {
showHelp()
}
if (!fs.existsSync(inputFile)) {
console.error(`File not found: ${inputFile}`)
exit(2)
}
// Read input
console.log(`Reading ${inputFile}`)
const inputData = fs.readFileSync(inputFile, 'utf-8')
if (!inputData) {
console.error(`Unable to read data from file: ${inputFile}`)
exit(2)
}
console.log(`Read ${Math.round(inputData.length / 1024)} KB`)
const parserOptions: ParserOptions = {
explicitArray: false
}
const parser = new xml2js.Parser(parserOptions);
const input = await parser.parseStringPromise(inputData)
if (!input?.rss?.channel) {
console.error(`No channels in xml: ${inputFile}`)
exit(2)
}
const channel = input.rss.channel
const items = channel.item
// console.dir(items);
// Convert
const blocks = convert(items)
// Save output
// TODO: Stream output
const outputData = ArchiveUtils.buildBlockArchive(blocks)
fs.writeFileSync(outputFile, outputData)
console.log(`Exported ${blocks.length} block(s) to ${outputFile}`)
return blocks.length
}
function convert(items: any[]) {
const blocks: Block[] = []
// Board
const board = createBoard()
board.rootId = board.id
board.title = 'Jira import'
// Compile standard properties
board.fields.cardProperties = []
const priorityProperty = buildCardPropertyFromValues('Priority', items.map(o => o.priority?._))
board.fields.cardProperties.push(priorityProperty)
const statusProperty = buildCardPropertyFromValues('Status', items.map(o => o.status?._))
board.fields.cardProperties.push(statusProperty)
const resolutionProperty = buildCardPropertyFromValues('Resolution', items.map(o => o.resolution?._))
board.fields.cardProperties.push(resolutionProperty)
const typeProperty = buildCardPropertyFromValues('Type', items.map(o => o.type?._))
board.fields.cardProperties.push(typeProperty)
const assigneeProperty = buildCardPropertyFromValues('Assignee', items.map(o => o.assignee?._))
board.fields.cardProperties.push(assigneeProperty)
const reporterProperty = buildCardPropertyFromValues('Reporter', items.map(o => o.reporter?._))
board.fields.cardProperties.push(reporterProperty)
const originalUrlProperty: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'Original URL',
type: 'url',
options: []
}
board.fields.cardProperties.push(originalUrlProperty)
const createdDateProperty: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'Created Date',
type: 'date',
options: []
}
board.fields.cardProperties.push(createdDateProperty)
blocks.push(board)
// Board view
const view = createBoardView()
view.title = 'Board View'
view.fields.viewType = 'board'
view.rootId = board.id
view.parentId = board.id
blocks.push(view)
for (const item of items) {
console.log(
`Item: ${item.summary}, ` +
`priority: ${item.priority?._}, ` +
`status: ${item.status?._}, ` +
`type: ${item.type?._}`)
const card = createCard()
card.title = item.summary
card.rootId = board.id
card.parentId = board.id
// Map standard properties
if (item.priority?._) { setSelectProperty(card, priorityProperty, item.priority._) }
if (item.status?._) { setSelectProperty(card, statusProperty, item.status._) }
if (item.resolution?._) { setSelectProperty(card, resolutionProperty, item.resolution._) }
if (item.type?._) { setSelectProperty(card, typeProperty, item.type._) }
if (item.assignee?._) { setSelectProperty(card, assigneeProperty, item.assignee._) }
if (item.reporter?._) { setSelectProperty(card, reporterProperty, item.reporter._) }
if (item.link) { setProperty(card, originalUrlProperty.id, item.link)}
if (item.created) {
const dateInMs = Date.parse(item.created)
setProperty(card, createdDateProperty.id, dateInMs.toString())
}
// TODO: Map custom properties
if (item.description) {
const description = turndownService.turndown(item.description)
console.log(`\t${description}`)
const text = createTextBlock()
text.title = description
text.rootId = board.id
text.parentId = card.id
blocks.push(text)
card.fields.contentOrder = [text.id]
}
blocks.push(card)
}
return blocks
}
function buildCardPropertyFromValues(propertyName: string, allValues: string[]) {
const options: IPropertyOption[] = []
// Remove empty and duplicate values
const values = allValues.
filter(o => !!o).
filter((x, y) => allValues.indexOf(x) == y);
for (const value of values) {
const optionId = Utils.createGuid()
const color = optionColors[optionColorIndex % optionColors.length]
optionColorIndex += 1
const option: IPropertyOption = {
id: optionId,
value,
color,
}
options.push(option)
}
const cardProperty: IPropertyTemplate = {
id: Utils.createGuid(),
name: propertyName,
type: 'select',
options
}
console.log(`Property: ${propertyName}, values: ${values}`)
return cardProperty
}
function setSelectProperty(card: Card, cardProperty: IPropertyTemplate, propertyValue: string) {
const option = optionForPropertyValue(cardProperty, propertyValue)
if (option) {
card.fields.properties[cardProperty.id] = option.id
}
}
function setProperty(card: Card, cardPropertyId: string, propertyValue: string) {
card.fields.properties[cardPropertyId] = propertyValue
}
function optionForPropertyValue(cardProperty: IPropertyTemplate, propertyValue: string): IPropertyOption | null {
const option = cardProperty.options.find(o => o.value === propertyValue)
if (!option) {
console.error(`Property value not found: ${propertyValue}`)
return null
}
return option
}
function showHelp() {
console.log('import -i <input.xml> -o [output.focalboard]')
exit(1)
}
export { run }

9337
import/jira/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
import/jira/package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "focalboard-jira-importer",
"version": "1.0.0",
"private": true,
"description": "",
"main": "importJira.js",
"scripts": {
"lint": "eslint --ext .tsx,.ts . --quiet --cache",
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache",
"test": "jest",
"testRun": "ts-node importJira.ts -i test/jira_export.xml -o test/jira-import.focalboard",
"debug:test": "node --inspect=5858 -r ts-node/register importJira.ts -i test/jira_export.xml -o test/jira-import.focalboard"
},
"keywords": [],
"author": "",
"jest": {
"globals": {
"ts-jest": {
"tsconfig": "./tsconfig.json"
}
},
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"collectCoverage": true,
"collectCoverageFrom": [
"*.{ts,tsx,js,jsx}",
"!test/**"
]
},
"devDependencies": {
"@types/jest": "^27.0.2",
"@types/minimist": "^1.2.1",
"@types/node": "^14.14.28",
"@types/turndown": "^5.0.1",
"@types/xml2js": "^0.4.9",
"@typescript-eslint/eslint-plugin": "^4.15.0",
"@typescript-eslint/parser": "^4.15.0",
"eslint": "^7.20.0",
"jest": "^27.3.1",
"ts-jest": "^27.0.7",
"ts-node": "^9.1.1",
"typescript": "^4.1.5"
},
"dependencies": {
"minimist": "^1.2.5",
"turndown": "^7.1.1",
"xml2js": "^0.4.23"
}
}

View File

@ -0,0 +1,138 @@
<!--
RSS generated by JIRA (1001.0.0-SNAPSHOT#100183-sha1:347f6eac38020c8d2c450799ab145942a8caa5a4) at Tue Oct 19 19:29:39 UTC 2021
It is possible to restrict the fields that are returned in this document by specifying the 'field' parameter in your request.
For example, to request only the issue key and summary add field=key&field=summary to the URL of your request.
-->
<!-- If you wish to do custom client-side styling of RSS, uncomment this:
<?xml-stylesheet href="<base-url>/styles/jiraxml2html.xsl" type="text/xsl"?>
-->
<rss version="0.92">
<channel>
<title>Jira</title>
<link>https://areca.atlassian.net/issues/?jql=text+%7E+%22Investigate%22</link>
<description>An XML representation of a search request</description>
<language>en-us</language>
<issue start="0" end="2" total="2"/>
<build-info>
<version>1001.0.0-SNAPSHOT</version>
<build-number>100183</build-number>
<build-date>18-10-2021</build-date>
</build-info>
<item>
<title>[AR-9] Investigate feature area</title>
<link>https://areca.atlassian.net/browse/AR-9</link>
<project id="10000" key="AR">Areca</project>
<description></description>
<environment></environment>
<key id="10008">AR-9</key>
<summary>Investigate feature area</summary>
<type id="10001" iconUrl="https://areca.atlassian.net/secure/viewavatar?size=medium&amp;avatarId=10318&amp;avatarType=issuetype">Task</type>
<priority id="3" iconUrl="https://areca.atlassian.net/images/icons/priorities/medium.svg">Medium</priority>
<status id="10001" iconUrl="https://areca.atlassian.net/" description="">In Progress</status>
<statusCategory id="4" key="indeterminate" colorName="yellow"/>
<resolution id="-1">Unresolved</resolution>
<assignee accountid="-1">Unassigned</assignee>
<reporter accountid="557058:10df6720-08d0-4747-86f6-1aa1a7e45332">Chen Lim</reporter>
<labels>
</labels>
<created>Fri, 24 Sep 2021 14:22:15 -0700</created>
<updated>Fri, 24 Sep 2021 14:22:15 -0700</updated>
<due></due>
<votes>0</votes>
<watches>1</watches>
<attachments>
</attachments>
<subtasks>
</subtasks>
<customfields>
<customfield id="customfield_10000" key="com.atlassian.jira.plugins.jira-development-integration-plugin:devsummarycf">
<customfieldname>Development</customfieldname>
<customfieldvalues>
</customfieldvalues>
</customfield>
<customfield id="customfield_10019" key="com.pyxis.greenhopper.jira:gh-lexo-rank">
<customfieldname>Rank</customfieldname>
<customfieldvalues>
<customfieldvalue>0|i0001r:</customfieldvalue>
</customfieldvalues>
</customfield>
<customfield id="customfield_10020" key="com.pyxis.greenhopper.jira:gh-sprint">
<customfieldname>Sprint</customfieldname>
<customfieldvalues>
</customfieldvalues>
</customfield>
</customfields>
</item>
<item>
<title>[AR-1] Investigate feature</title>
<link>https://areca.atlassian.net/browse/AR-1</link>
<project id="10000" key="AR">Areca</project>
<description></description>
<environment></environment>
<key id="10000">AR-1</key>
<summary>Investigate feature</summary>
<type id="10002" iconUrl="https://areca.atlassian.net/secure/viewavatar?size=medium&amp;avatarId=10307&amp;avatarType=issuetype">Epic</type>
<priority id="3" iconUrl="https://areca.atlassian.net/images/icons/priorities/medium.svg">Medium</priority>
<status id="10000" iconUrl="https://areca.atlassian.net/" description="">To Do</status>
<statusCategory id="2" key="new" colorName="blue-gray"/>
<resolution id="-1">Unresolved</resolution>
<assignee accountid="557058:10df6720-08d0-4747-86f6-1aa1a7e45332">Chen Lim</assignee>
<reporter accountid="557058:10df6720-08d0-4747-86f6-1aa1a7e45332">Chen Lim</reporter>
<labels>
<label>PM</label>
</labels>
<created>Fri, 24 Sep 2021 14:19:44 -0700</created>
<updated>Fri, 24 Sep 2021 14:21:32 -0700</updated>
<due>Fri, 12 Nov 2021 00:00:00 +0000</due>
<votes>0</votes>
<watches>1</watches>
<comments>
<comment id="10000" author="557058:10df6720-08d0-4747-86f6-1aa1a7e45332" created="Fri, 24 Sep 2021 14:21:15 -0700" >&lt;p&gt;Kicking off Project Areca&amp;#33; ��&lt;/p&gt;</comment>
</comments>
<attachments>
</attachments>
<subtasks>
</subtasks>
<customfields>
<customfield id="customfield_10000" key="com.atlassian.jira.plugins.jira-development-integration-plugin:devsummarycf">
<customfieldname>Development</customfieldname>
<customfieldvalues>
</customfieldvalues>
</customfield>
<customfield id="customfield_10017" key="com.pyxis.greenhopper.jira:jsw-issue-color">
<customfieldname>Issue color</customfieldname>
<customfieldvalues>
<customfieldvalue>blue</customfieldvalue>
</customfieldvalues>
</customfield>
<customfield id="customfield_10019" key="com.pyxis.greenhopper.jira:gh-lexo-rank">
<customfieldname>Rank</customfieldname>
<customfieldvalues>
<customfieldvalue>0|hzzzzz:</customfieldvalue>
</customfieldvalues>
</customfield>
<customfield id="customfield_10020" key="com.pyxis.greenhopper.jira:gh-sprint">
<customfieldname>Sprint</customfieldname>
<customfieldvalues>
</customfieldvalues>
</customfield>
<customfield id="customfield_10015" key="com.atlassian.jira.plugin.system.customfieldtypes:datepicker">
<customfieldname>Start date</customfieldname>
<customfieldvalues>
<customfieldvalue>Tue, 28 Sep 2021 00:00:00 +0000</customfieldvalue>
</customfieldvalues>
</customfield>
</customfields>
</item>
</channel>
</rss>

27
import/jira/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"jsx": "react",
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": true,
"strict": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"allowJs": true,
"resolveJsonModule": true,
"incremental": false,
"outDir": "./dist",
"moduleResolution": "node"
},
"include": [
"."
],
"exclude": [
".git",
"**/node_modules/*",
"dist",
"pack"
]
}

19
import/jira/utils.ts Normal file
View File

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as crypto from 'crypto'
class Utils {
static createGuid(): string {
function randomDigit() {
if (crypto && crypto.randomBytes) {
const rands = crypto.randomBytes(1)
return (rands[0] % 16).toString(16)
}
return (Math.floor((Math.random() * 16))).toString(16)
}
return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/x/g, randomDigit)
}
}
export { Utils }