├── test ├── test.txt ├── issuesTestFile.txt ├── mocha.opts ├── .userconfig ├── .jirarc ├── .eslintrc ├── helper.js ├── validation-strategies │ ├── has-one-issue.js │ └── all-issues-exist.js ├── user-email-check-test.js ├── issue-generator.js ├── deployments-config-check.js ├── jira-connection-test.js ├── issue-handler-test.js ├── fs-utils-test.js ├── jira-configuration-test.js ├── promise-utils-test.js ├── precommit-entry-test.js ├── jira-operations-test.js └── dummy-jira.js ├── .eslintignore ├── .ackrc ├── bin ├── jira-precommit └── install ├── .travis.yml ├── .gitignore ├── .babelrc ├── src ├── config.js ├── validation-strategies │ ├── index.js │ ├── has-one-issue.js │ └── all-issues-exist.js ├── issue-handler.js ├── cli.js ├── cli-commands │ ├── index.js │ ├── jokes.js │ └── configcheck.js ├── jira-connection.js ├── outdated-check.js ├── promise-utils.js ├── jira-configuration.js ├── deployments-config-check.js ├── install.js ├── user-email-check.js ├── joke.js ├── fs-utils.js ├── jira-operations.js └── precommit-entry.js ├── .eslintrc ├── hooks └── commit-msg ├── .editorconfig ├── LICENSE ├── package.json └── README.md /test/test.txt: -------------------------------------------------------------------------------- 1 | TW-2345 2 | 3 | #TW-6346 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | -------------------------------------------------------------------------------- /.ackrc: -------------------------------------------------------------------------------- 1 | --ignore-dir=lib 2 | --ignore-dir=node_modules 3 | -------------------------------------------------------------------------------- /test/issuesTestFile.txt: -------------------------------------------------------------------------------- 1 | TW-5032 2 | TW-2380 3 | TW-2018 4 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require babel-core/register 2 | --recursive 3 | -------------------------------------------------------------------------------- /bin/jira-precommit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../lib/cli'); 3 | -------------------------------------------------------------------------------- /test/.userconfig: -------------------------------------------------------------------------------- 1 | { 2 | "username": "UserDudeBro", 3 | "password": "SuperSecret" 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | before_install: if [[ `npm -v` != 3* ]]; then npm i -g npm@3; fi 4 | node_js: 5 | - "4.2" 6 | - "node" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | 4 | #Dummy Test Directory 5 | test/tmp/ 6 | 7 | #Logging Output 8 | *.log 9 | 10 | #Jira Connection Information 11 | .jirarc -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0" 5 | ], 6 | "plugins": [ 7 | "add-module-exports", 8 | "transform-runtime" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import Configstore from 'configstore'; 2 | import pkg from '../package.json'; 3 | 4 | const config = new Configstore(pkg.name, { jokes: true }); 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /src/validation-strategies/index.js: -------------------------------------------------------------------------------- 1 | import allExistStrat from './all-issues-exist.js'; 2 | import oneStrat from './has-one-issue.js'; 3 | 4 | export default [ 5 | oneStrat, 6 | allExistStrat 7 | ]; 8 | -------------------------------------------------------------------------------- /test/.jirarc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "test", 3 | "protocol":"https", 4 | "host":"jira.com", 5 | "port": 8080, 6 | "version": "2.1.0", 7 | "verbose": true, 8 | "strictSSL": true 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "exchange-solutions/base" 4 | ], 5 | "env": { 6 | "node": true 7 | }, 8 | "parser": "babel-eslint", 9 | "rules": { 10 | "no-console": 0 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /hooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var precommit = require('jira-precommit-hook/lib/precommit-entry'); 4 | precommit.precommit(process.argv[2]) 5 | .then(function(exitCode) { 6 | process.exit(exitCode); 7 | }); 8 | -------------------------------------------------------------------------------- /src/validation-strategies/has-one-issue.js: -------------------------------------------------------------------------------- 1 | export default function apply(issues, jiraClientAPI) { 2 | if (issues.length === 0) { 3 | throw new Error('Must commit against at least one issue.'); 4 | } 5 | 6 | return true; 7 | } 8 | -------------------------------------------------------------------------------- /src/issue-handler.js: -------------------------------------------------------------------------------- 1 | import strats from './validation-strategies/index'; 2 | 3 | export async function issueStrategizer(issues, jiraClientAPI) { 4 | await Promise.all(strats.map(strat => strat(issues, jiraClientAPI))); 5 | return true; 6 | } 7 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": "mocha" 4 | }, 5 | "globals": { 6 | "assert": true, 7 | "expect": true, 8 | "sinon": true 9 | }, 10 | "rules": { 11 | "func-names": 0, 12 | "no-unused-expressions": 0 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/validation-strategies/all-issues-exist.js: -------------------------------------------------------------------------------- 1 | export default async function apply(issues, jiraClientAPI) { 2 | const issueMap = issues.map(issue => jiraClientAPI.findIssue(issue)); 3 | 4 | // On error, throws: "Error: Issue {key} does not exist." 5 | await Promise.all(issueMap); 6 | 7 | return true; 8 | } 9 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | import sinon from 'sinon'; 4 | import 'sinon-as-promised'; 5 | import sinonChai from 'sinon-chai'; 6 | 7 | chai.should(); 8 | chai.use(chaiAsPromised); 9 | chai.use(sinonChai); 10 | 11 | global.expect = chai.expect; 12 | global.assert = chai.assert; 13 | global.sinon = sinon; 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | [*.js] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 2 15 | max_line_length = 100 16 | 17 | [{package.json,.travis.yml}] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | import pkg from '../package.json'; 2 | import { ArgumentParser } from 'argparse'; 3 | import { register, execute } from './cli-commands'; 4 | 5 | async function run() { 6 | const parser = new ArgumentParser({ 7 | version: pkg.version, 8 | addHelp: true, 9 | description: 'jira-precommit-hook configuration utility' 10 | }); 11 | 12 | register(parser); 13 | 14 | const args = parser.parseArgs(); 15 | const exitCode = await execute(args); 16 | process.exit(exitCode); 17 | } 18 | 19 | run(); 20 | -------------------------------------------------------------------------------- /test/validation-strategies/has-one-issue.js: -------------------------------------------------------------------------------- 1 | import oneStrat from '../../src/validation-strategies/has-one-issue.js'; 2 | 3 | describe('One issue exist apply tests', () => { 4 | it('Has at least one issue', () => { 5 | const testIssues = ['TW1']; 6 | oneStrat(testIssues).should.equal(true); 7 | }); 8 | 9 | it('Does not have any issues, should be rejected', () => { 10 | const testIssues = []; 11 | expect(() => oneStrat(testIssues)) 12 | .to.throw(Error, /Must commit against at least one issue./); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/cli-commands/index.js: -------------------------------------------------------------------------------- 1 | import jokes from './jokes'; 2 | import configcheck from './configcheck'; 3 | const map = { 4 | [jokes.command]: jokes, 5 | [configcheck.command]: configcheck 6 | }; 7 | 8 | export function register(parser) { 9 | const subparsers = parser.addSubparsers({ 10 | title: 'commands', 11 | dest: 'command' 12 | }); 13 | 14 | Object.keys(map).forEach(command => map[command].register(subparsers)); 15 | } 16 | 17 | export async function execute({ command, ...options }) { 18 | return map[command].execute(options); 19 | } 20 | -------------------------------------------------------------------------------- /bin/install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint no-var: 0 */ 4 | 5 | if (process.env.TEAMCITY_VERSION) { 6 | console.log('TeamCity Detected not installing the commit hook'); 7 | process.exit(0); // eslint-disable-line no-process-exit 8 | } 9 | 10 | var path = require('path'); 11 | var fs = require('fs'); 12 | var exists = fs.existsSync(path.join(__dirname, '../lib')); 13 | 14 | if (exists) { 15 | var install = require('../lib/install.js'); 16 | 17 | install() 18 | .catch(function(err) { 19 | console.error(err); 20 | process.exit(1); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /test/validation-strategies/all-issues-exist.js: -------------------------------------------------------------------------------- 1 | import allStrat from '../../src/validation-strategies/all-issues-exist.js'; 2 | import DummyJira from '../dummy-jira.js'; 3 | 4 | const dummyJira = new DummyJira(); 5 | 6 | describe('All issues exist apply tests', () => { 7 | it('All issues exist', () => { 8 | const testIssues = ['I1', 'MT1', 'SubTask15']; 9 | return allStrat(testIssues, dummyJira).should.eventually.equal(true); 10 | }); 11 | 12 | it('At least one issue does not exist, should error', () => { 13 | const testIssues = ['TW15']; 14 | return allStrat(testIssues, dummyJira).should.eventually.be.rejectedWith(Error); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/user-email-check-test.js: -------------------------------------------------------------------------------- 1 | import { isWorkEmail } from '../src/user-email-check'; 2 | 3 | describe('Work Email', () => { 4 | [ 5 | ['mtscout6@gmail.com', false], 6 | ['mtscout6@yahoo.com', false], 7 | ['mtscout6@hotmail.com', false], 8 | ['mtscout6@customdomain.com', false], 9 | ['matt.smith@extendhealth.com', true], 10 | ['matt.smith@towerswatson.com', true], 11 | ['matt.smith@willistowerswatson.com', true], 12 | ['matt.smith@ExTeNdHeAlTh.com', true], 13 | ['matt.smith@ToWeRsWaTsOn.com', true], 14 | ['matt.smith@WiLlIsToWeRsWaTsOn.com', true] 15 | ].forEach(([email, expected]) => { 16 | it(`${email} is ${expected ? '' : 'not'} a work email`, () => { 17 | isWorkEmail(email).should.equal(expected); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/jira-connection.js: -------------------------------------------------------------------------------- 1 | import { getAPIConfig } from './jira-configuration.js'; 2 | import JiraApi from 'jira-client'; 3 | import _ from 'lodash'; 4 | 5 | class OurJiraApi extends JiraApi { 6 | findIssue(issueNumber) { 7 | if (!this._findIssueCache) { 8 | this._findIssueCache = _.memoize(super.findIssue); 9 | } 10 | 11 | return this._findIssueCache(issueNumber); 12 | } 13 | } 14 | 15 | // Grabs data from files and returns a JIRA connection object wrapped in promise 16 | export async function getJiraAPI(configPath) { 17 | const { 18 | projectName, // eslint-disable-line no-use-before-define 19 | ...config // eslint-disable-line no-use-before-define 20 | } = await getAPIConfig(configPath); 21 | 22 | const jiraClient = new OurJiraApi(config); 23 | 24 | jiraClient.projectName = projectName; 25 | return jiraClient; 26 | } 27 | -------------------------------------------------------------------------------- /src/outdated-check.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { name, version } from '../package.json'; 3 | import request from 'request-promise'; 4 | 5 | export default async function checkOutdated() { 6 | try { 7 | const rawResponse = await request(`https://registry.npmjs.org/${name}/latest`); 8 | const latest = JSON.parse(rawResponse).version; 9 | 10 | if (version !== latest) { 11 | const warning = chalk.yellow(`WARNING: You are using version ${version} of the ` + 12 | `jira-precommit-hook. However, version ${latest} has been ` + 13 | 'released. To update run:'); 14 | const arrow = chalk.grey('\n> '); 15 | const updateCommand = chalk.green(`npm install ${name}@${latest} --save-dev\n`); 16 | console.warn(warning + arrow + updateCommand); 17 | } 18 | } catch (err) { } // eslint-disable-line 19 | } 20 | -------------------------------------------------------------------------------- /src/promise-utils.js: -------------------------------------------------------------------------------- 1 | export async function anyPromise(arrayOfPromises) { 2 | if (arrayOfPromises === undefined) { 3 | throw new Error('No arguments provided'); 4 | } 5 | 6 | if (!(arrayOfPromises instanceof Array) || arrayOfPromises.length === 0) { 7 | throw new Error('Argument is not a non-array'); 8 | } 9 | 10 | if (arrayOfPromises.length === 1) { 11 | return arrayOfPromises[0]; 12 | } 13 | 14 | let resolve; 15 | let reject; 16 | const result = new Promise((x, y) => { 17 | resolve = x; 18 | reject = y; 19 | }); 20 | 21 | let rejects = []; 22 | 23 | arrayOfPromises.forEach(async function firstOrAllErrors(prom) { 24 | try { 25 | const x = await prom; 26 | resolve(x); 27 | } catch (err) { 28 | rejects = [ 29 | ...rejects, 30 | err 31 | ]; 32 | 33 | if (rejects.length === arrayOfPromises.length) { 34 | reject(rejects); 35 | } 36 | } 37 | }); 38 | 39 | return result; 40 | } 41 | -------------------------------------------------------------------------------- /src/cli-commands/jokes.js: -------------------------------------------------------------------------------- 1 | import config from '../config'; 2 | const command = 'jokes'; 3 | 4 | export default { 5 | command, 6 | async execute({ decision }) { 7 | if (decision === 'enable') { 8 | config.set('jokes', true); 9 | console.log('Jokes enabled!'); 10 | } else if (decision === 'disable') { 11 | config.set('jokes', false); 12 | console.log('Jokes disabled!'); 13 | } else if (decision === 'explicit') { 14 | config.set('explicit', true); 15 | console.log('Profane joke filter disabled!'); 16 | } else if (decision === 'clean') { 17 | config.set('explicit', false); 18 | console.log('Profane joke filter enabled!'); 19 | } 20 | 21 | return 0; 22 | }, 23 | register(subparsers) { 24 | const joke = subparsers.addParser(command, { 25 | addHelp: true, 26 | help: `${command} help` 27 | }); 28 | 29 | joke.addArgument('decision', { 30 | choices: ['enable', 'disable', 'explicit', 'clean'] 31 | }); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/jira-configuration.js: -------------------------------------------------------------------------------- 1 | import { readJSON } from './fs-utils.js'; 2 | 3 | export function validateAPIConfig(config) { 4 | // validate that this is a proper .jirarc file 5 | if (!config.host) { 6 | throw new Error('.jirarc missing host url. Please check the README for details'); 7 | } 8 | if (!config.projectName) { 9 | throw new Error('.jirarc missing project name. Please check the README for details'); 10 | } 11 | return config; 12 | } 13 | 14 | export function validateAuthentication(authConfig) { 15 | // validate that there are proper credentials 16 | if (!authConfig.username) { 17 | throw new Error('.userconfig missing username'); 18 | } 19 | if (!authConfig.password) { 20 | throw new Error('.userconfig missing password'); 21 | } 22 | return authConfig; 23 | } 24 | 25 | export async function getAPIConfig(filePath) { 26 | const config = await readJSON(filePath); 27 | return validateAPIConfig(config); 28 | } 29 | 30 | export async function getAuthentication(filePath) { 31 | const config = await readJSON(filePath); 32 | return validateAuthentication(config); 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/deployments-config-check.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-promise'; // eslint-disable-line id-length 2 | import fsUtils from './fs-utils.js'; 3 | 4 | export default async function checkValidJSON({ 5 | fileSystem = fs, 6 | fileSystemUtils = fsUtils, 7 | log = console.log 8 | }) { 9 | try { 10 | const filePath = fileSystemUtils.findParentFolder(process.cwd(), 11 | 'hubot-deployments-config.json'); 12 | const fileResult = await fileSystem.readFile(filePath); 13 | const jsonResult = JSON.parse(fileResult); 14 | 15 | return !!jsonResult; 16 | } catch (err) { 17 | if (err.message.indexOf('Unexpected token') > -1) { 18 | throw new Error('hubot-deployments-config.json is not a valid JSON file. Committing will ' + 19 | `not succeed until the JSON is fixed. ${err.message}`); 20 | } 21 | if (err.message.indexOf('Cannot find') > -1 22 | && err.message.indexOf('hubot-deployments-config.json') > -1) { 23 | log('No hubot-deployments-config.json set up for this repository.'); 24 | return false; 25 | } 26 | 27 | throw err; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/install.js: -------------------------------------------------------------------------------- 1 | import { findParentFolder, copyHookFiles, verifyHooksFolder } from './fs-utils.js'; 2 | import fsp from 'fs-promise'; 3 | import path from 'path'; 4 | 5 | export default async function install() { 6 | const thisProjectsGitFolder = path.resolve(path.join(__dirname, '../.git')); 7 | let gitPath = findParentFolder(__dirname, '.git'); 8 | 9 | if (thisProjectsGitFolder === gitPath) { 10 | return 0; 11 | } 12 | 13 | console.log('Installing JIRA pre-commit hook....'); 14 | 15 | try { 16 | gitPath = findParentFolder(__dirname, '.git'); 17 | } catch (error) { 18 | throw new Error('Your project needs a git repository to install the hook.'); 19 | } 20 | 21 | if ((await fsp.stat(gitPath)).isFile()) { 22 | console.log('Attempting install to git worktree, please install in the ' + 23 | 'primary worktree directory.'); 24 | return 0; 25 | } 26 | 27 | console.log(`Found .git directory at: ${gitPath}`); 28 | 29 | const hooksPath = path.join(gitPath, 'hooks'); 30 | verifyHooksFolder(hooksPath); 31 | 32 | await copyHookFiles(gitPath); 33 | console.log('Copied commit hook.'); 34 | return 0; 35 | } 36 | -------------------------------------------------------------------------------- /src/user-email-check.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { exec } from 'child-process-promise'; 3 | 4 | export function isWorkEmail(email) { 5 | return /@(extendhealth|towerswatson|willistowerswatson)\.com/i.test(email); 6 | } 7 | 8 | export default async function checkUserEmail() { 9 | const { stdout } = await exec('git config --get user.email'); 10 | const email = stdout.trim(); 11 | if (!isWorkEmail(email)) { 12 | const message = 13 | chalk.yellow(`WARNING: The email address you have configured in Git, '${email}', is not ` + 14 | 'your work email address. To configure your work email address for this ' + 15 | 'repo run:\n\n') + 16 | chalk.green('> git config user.email ""') + 17 | chalk.yellow('\n\nTo configure your work email address for all repos run: (You may ' + 18 | 'want to remember to use your personal email address for any open source ' + 19 | 'repos with this option, so choose the option that works best for you.)\n\n') + 20 | chalk.green('> git config user.email "" --global\n'); 21 | 22 | console.log(message); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/joke.js: -------------------------------------------------------------------------------- 1 | import request from 'request-promise'; 2 | import boxen from 'boxen'; 3 | import wordwrap from 'wordwrap'; 4 | import size from 'window-size'; 5 | import chalk from 'chalk'; 6 | 7 | export default function fetchJoke(config) { 8 | if (!config.get('jokes')) { 9 | return async () => {}; 10 | } 11 | 12 | let options; 13 | if (!config.get('explicit')) { 14 | options = { 15 | uri: 'http://api.icndb.com/jokes/random', 16 | qs: { 17 | escape: 'javascript', 18 | limitTo: 'nerdy' 19 | } 20 | }; 21 | } else { 22 | options = { 23 | uri: 'http://api.icndb.com/jokes/random', 24 | qs: { 25 | escape: 'javascript' 26 | } 27 | }; 28 | } 29 | 30 | const jokeRequest = request(options); 31 | jokeRequest.catch(err => {}); // This is to hide any errors from hitting the console. 32 | 33 | return async () => { 34 | try { 35 | const json = await jokeRequest; 36 | const { joke } = JSON.parse(json).value; 37 | const wrap = wordwrap(size.width - 10); 38 | const wrapped = wrap(`Good work now enjoy this joke. You deserve it!\n\n${joke}\n\n` + //eslint-disable-line 39 | chalk.grey('If you want to disable these jokes run: \n' + 40 | '> ./node_modules/.bin/jira-precommit jokes disable')); 41 | console.log(boxen( 42 | wrapped, 43 | { 44 | padding: 1, 45 | margin: 1 46 | } 47 | )); 48 | } catch (err) {} // eslint-disable-line 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /test/issue-generator.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | function createIssueLinks(direction, parentKey, parentType, linkType) { 4 | return { 5 | fields: { 6 | issuelinks: [{ 7 | type: { 8 | name: linkType 9 | }, 10 | [direction]: { 11 | key: parentKey, 12 | fields: { 13 | issuetype: { 14 | name: parentType 15 | } 16 | } 17 | } 18 | }] 19 | } 20 | }; 21 | } 22 | 23 | export default function createTestIssue(key, type, color, parentKey, parentType, linkType) { 24 | const baseIssue = { 25 | key, 26 | fields: { 27 | status: { 28 | statusCategory: { 29 | colorName: color 30 | } 31 | }, 32 | issuetype: { 33 | name: type 34 | } 35 | } 36 | }; 37 | 38 | switch (type) { 39 | case 'Epic': 40 | return _.merge(baseIssue, createIssueLinks('inwardIssue', parentKey, parentType, linkType)); 41 | case 'Story': 42 | if (parentType === 'Epic') { 43 | return _.merge(baseIssue, { 44 | fields: { 45 | customfield_10805: parentKey 46 | } 47 | }); 48 | } 49 | return _.merge(baseIssue, createIssueLinks('outwardIssue', parentKey, parentType, linkType)); 50 | case 'Sub-task': 51 | case 'Feature Defect': 52 | return _.merge(baseIssue, { 53 | fields: { 54 | parent: { 55 | key: parentKey 56 | } 57 | } 58 | }); 59 | default: 60 | return baseIssue; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/fs-utils.js: -------------------------------------------------------------------------------- 1 | import fsys from 'fs'; 2 | import fsp from 'fs-promise'; 3 | import path from 'path'; 4 | 5 | export function findParentFolder(startDir, parentDirName) { 6 | let currentDir = startDir; 7 | 8 | while (fsys.existsSync(currentDir)) { 9 | if (fsys.existsSync(path.join(currentDir, parentDirName))) { 10 | currentDir = path.join(currentDir, parentDirName); 11 | break; 12 | } else { 13 | const tempPath = currentDir; 14 | currentDir = path.normalize(path.join(currentDir, '/..')); 15 | 16 | if (currentDir === tempPath) { 17 | throw new Error(`Cannot find ${parentDirName}`); 18 | } 19 | } 20 | } 21 | 22 | return currentDir; 23 | } 24 | 25 | export function verifyHooksFolder(desiredHooksPath) { 26 | if (!fsys.existsSync(desiredHooksPath)) { 27 | console.log('Creating hooks directory in .git'); 28 | fsys.mkdirSync(desiredHooksPath); 29 | } 30 | } 31 | 32 | export function copyHookFiles(gitDirectory) { 33 | const source = path.join(__dirname, '../hooks/commit-msg'); 34 | const destination = path.join(gitDirectory, '/hooks/commit-msg'); 35 | const stat = fsys.statSync(source); 36 | 37 | return new Promise((fulfill, reject) => { 38 | fsys.createReadStream(source) 39 | .pipe(fsys.createWriteStream(destination, { mode: stat.mode })) 40 | .on('close', (error, result) => { 41 | if (error) { 42 | reject(error); 43 | } else { 44 | fulfill(result); 45 | } 46 | }); 47 | }); 48 | } 49 | 50 | export async function readJSON(filePath) { 51 | const content = await fsp.readFile(filePath); 52 | return JSON.parse(content); 53 | } 54 | -------------------------------------------------------------------------------- /test/deployments-config-check.js: -------------------------------------------------------------------------------- 1 | import checkValidJSON from '../src/deployments-config-check'; 2 | 3 | const dummyFS = { 4 | readFile(arg) { 5 | return arg; 6 | } 7 | }; 8 | 9 | const dummyGoodJSON = JSON.stringify({ 10 | someBuildType: [ 11 | 'some/directory/.*' 12 | ] 13 | }); 14 | 15 | const dummyBadJSON = 'somethingthatobviously [] is not JSON'; 16 | 17 | const dummyGoodFileSystemUtils = { 18 | findParentFolder(cwd, filename) { 19 | return dummyGoodJSON; 20 | } 21 | }; 22 | 23 | const dummyBadFileSystemUtils = { 24 | findParentFolder(cwd, filename) { 25 | return dummyBadJSON; 26 | } 27 | }; 28 | 29 | const dummyNotExistFileUtils = { 30 | findParentFolder(cwd, filename) { 31 | throw new Error('Cannot find hubot-deployments-config.json'); 32 | } 33 | }; 34 | 35 | describe('Checking Valid Hubot Deployments Config', () => { 36 | it('Basic Case, exists and is good', (done) => checkValidJSON({ 37 | fileSystemUtils: dummyGoodFileSystemUtils, 38 | fileSystem: dummyFS 39 | }).should.eventually.equal(true).and.notify(done)); 40 | 41 | it('File exists but is not formatted properly', (done) => checkValidJSON({ 42 | fileSystemUtils: dummyBadFileSystemUtils, 43 | fileSystem: dummyFS 44 | }).should.eventually.be.rejectedWith('hubot-deployments-config.json is not a valid JSON file') 45 | .and.notify(done)); 46 | 47 | it('File does not exist', (done) => { 48 | function fakeLog(string) { 49 | assert(string === 'No hubot-deployments-config.json set up for this repository.'); 50 | } 51 | return checkValidJSON({ 52 | fileSystemUtils: dummyNotExistFileUtils, 53 | fileSystem: dummyFS, 54 | log: fakeLog 55 | }).should.eventually.equal(false) 56 | .and.notify(done); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/jira-connection-test.js: -------------------------------------------------------------------------------- 1 | import { getJiraAPI } from '../src/jira-connection.js'; 2 | import path from 'path'; 3 | import DummyJira from './dummy-jira.js'; 4 | 5 | describe('JIRA Connection Tests', () => { 6 | it('JIRA Object has Correct Project Name', async () => { 7 | const testJira = await getJiraAPI(path.join(process.cwd(), 'test', '.jirarc')); 8 | testJira.projectName.should.eql('test'); 9 | }); 10 | 11 | xdescribe('findIssue Memoization', () => { 12 | let jiraApi; 13 | let spy; 14 | 15 | const dummyJira = new DummyJira(); 16 | 17 | beforeEach(async () => { 18 | const testJira = await getJiraAPI(path.join(process.cwd(), 'test', '.jirarc')); 19 | jiraApi = testJira; 20 | 21 | // doRequest is part of the super class and isn't visible to sinon here... 22 | // these tests compare undefined to undefined... 23 | spy = sinon.stub(jiraApi, 'doRequest', async function(options) { 24 | const issueNumber = options.uri.split('/').pop().toString(); 25 | return dummyJira.issues[issueNumber]; 26 | }); 27 | }); 28 | 29 | it('findIssue with Same Key is Run Only Once', async () => { 30 | const [first, second] = await Promise.all([ 31 | jiraApi.findIssue('Story5'), 32 | jiraApi.findIssue('Story5') 33 | ]); 34 | 35 | assert.equal(spy.calledOnce, true); 36 | assert.equal(first, second); 37 | }); 38 | 39 | it('findIssue with Different Keys is Run Twice', async () => { 40 | const [first, second] = await Promise.all([ 41 | jiraApi.findIssue('Story1'), 42 | jiraApi.findIssue('Story2') 43 | ]); 44 | 45 | assert.equal(first.key, 'Story1'); 46 | assert.equal(second.key, 'Story2'); 47 | assert.equal(spy.calledTwice, true); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/issue-handler-test.js: -------------------------------------------------------------------------------- 1 | import * as issueHandler from '../src/issue-handler.js'; 2 | import DummyJira from './dummy-jira.js'; 3 | 4 | const dummyJira = new DummyJira(); 5 | 6 | describe('Issue Handler Test', () => { 7 | it('Empty issues array', done => { 8 | const testIssueArr = []; 9 | issueHandler.issueStrategizer(testIssueArr, dummyJira) 10 | .should.eventually.be.rejectedWith(Error, /Must commit against at least one issue/) 11 | .notify(done); 12 | }); 13 | 14 | it('1 good issue', async function() { 15 | const testIssueArr = ['SubTask1']; 16 | const result = await issueHandler.issueStrategizer(testIssueArr, dummyJira); 17 | result.should.equal(true); 18 | }); 19 | 20 | it('1 non-existent issue', done => { 21 | const testIssueArr = ['TW500']; 22 | issueHandler.issueStrategizer(testIssueArr, dummyJira) 23 | .should.eventually.be.rejectedWith(Error, /Issue TW500 does not exist/) 24 | .notify(done); 25 | }); 26 | 27 | it('1 non-existent issue and 1 good issue', done => { 28 | const testIssueArr = ['SubTask1', 'TW500']; 29 | issueHandler.issueStrategizer(testIssueArr, dummyJira) 30 | .should.eventually.be.rejectedWith(Error, /Issue TW500 does not exist/) 31 | .notify(done); 32 | }); 33 | 34 | it('1 good issue and 1 non-existent issue', done => { 35 | const testIssueArr = ['TW502', 'SubTask1']; 36 | issueHandler.issueStrategizer(testIssueArr, dummyJira) 37 | .should.eventually.be.rejectedWith(Error, /Issue TW502 does not exist/) 38 | .notify(done); 39 | }); 40 | 41 | it('2 bad issue and 1 good issue', done => { 42 | const testIssueArr = ['Story6', 'SubTask7', 'Story1']; 43 | issueHandler.issueStrategizer(testIssueArr, dummyJira) 44 | .should.eventually.equal(true) 45 | .notify(done); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira-precommit-hook", 3 | "version": "4.0.0", 4 | "description": "Git commit hook to verify commit messages are tagged with a JIRA issue number", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "rm -rf lib && babel src --out-dir lib", 8 | "lint": "eslint ./", 9 | "prepublish": "npm run test && npm run build", 10 | "test": " npm run lint && mocha", 11 | "install": "node bin/install" 12 | }, 13 | "bin": { 14 | "jira-precommit": "./bin/jira-precommit" 15 | }, 16 | "keywords": [ 17 | "jira", 18 | "git", 19 | "precommit-hook" 20 | ], 21 | "author": "Matthew L Smith ", 22 | "contributors": [ 23 | "Curtis Knox", 24 | "Karl Thompson", 25 | "Matthew Radcliffe", 26 | "Steven Tran", 27 | "Devin Wall", 28 | "Trevor Martz", 29 | "Matt Smith" 30 | ], 31 | "files": [ 32 | "bin", 33 | "hooks", 34 | "lib", 35 | "LICENSE", 36 | "README.md" 37 | ], 38 | "license": "MIT", 39 | "devDependencies": { 40 | "babel-cli": "^6.2.0", 41 | "babel-core": "^6.3.13", 42 | "babel-eslint": "^6.0.0-beta.6", 43 | "babel-plugin-add-module-exports": "^0.1.1", 44 | "babel-plugin-transform-runtime": "^6.4.0", 45 | "babel-preset-es2015": "^6.1.18", 46 | "babel-preset-stage-0": "^6.1.18", 47 | "chai": "^3.2.0", 48 | "chai-as-promised": "^5.0.0", 49 | "eslint": "^2.2.0", 50 | "eslint-config-exchange-solutions": "6.0.0", 51 | "mocha": "^2.2.4", 52 | "rimraf": "^2.3.4", 53 | "sinon": "^1.14.1", 54 | "sinon-as-promised": "^4.0.0", 55 | "sinon-chai": "^2.7.0" 56 | }, 57 | "dependencies": { 58 | "argparse": "^1.0.7", 59 | "babel-runtime": "^6.3.13", 60 | "boxen": "^0.5.0", 61 | "chalk": "^1.1.1", 62 | "child-process-promise": "^2.0.1", 63 | "configstore": "^2.0.0", 64 | "es6-promise": "^3.0.2", 65 | "fs-promise": "^0.5.0", 66 | "jira-client": "^3.0.0", 67 | "lodash": "^4.0.0", 68 | "request-promise": "^3.0.0", 69 | "window-size": "^0.2.0", 70 | "wordwrap": "^1.0.0" 71 | }, 72 | "repository": { 73 | "type": "git", 74 | "url": "git+https://github.com/TWExchangeSolutions/jira-precommit-hook.git" 75 | }, 76 | "bugs": { 77 | "url": "https://github.com/TWExchangeSolutions/jira-precommit-hook/issues" 78 | }, 79 | "engines": { 80 | "node": ">=4.2.3", 81 | "npm": ">=2.13.1" 82 | }, 83 | "homepage": "https://github.com/TWExchangeSolutions/jira-precommit-hook#readme" 84 | } 85 | -------------------------------------------------------------------------------- /src/cli-commands/configcheck.js: -------------------------------------------------------------------------------- 1 | import fsp from 'fs-promise'; 2 | import fsys from 'fs'; 3 | import path from 'path'; 4 | const command = 'configcheck'; 5 | 6 | function recursiveWalk(dir) { 7 | var results = []; // eslint-disable-line no-var 8 | var list = fsys.readdirSync(dir); // eslint-disable-line no-var 9 | list.forEach((file) => { 10 | const newfile = path.join(dir, file); 11 | const stat = fsys.lstatSync(newfile); 12 | if (stat && stat.isDirectory()) { 13 | results = results.concat(recursiveWalk(newfile)); 14 | } else { 15 | results.push(newfile); 16 | } 17 | }); 18 | return results; 19 | } 20 | 21 | export function fileFilter(filename, regexPatterns = []) { 22 | if (regexPatterns.length === 0) { 23 | return true; 24 | } 25 | 26 | const actualResult = regexPatterns.some((x) => filename.match(x)); 27 | return actualResult; 28 | } 29 | 30 | export default { 31 | command, 32 | async execute({}) { 33 | console.log('Checking hubot-deployments-config.json...'); 34 | const workingDirectory = process.cwd(); 35 | const deploymentConfigPath = path.join(workingDirectory, 'hubot-deployments-config.json'); 36 | 37 | if (!fsys.existsSync(deploymentConfigPath)) { 38 | console.warn(`${deploymentConfigPath} does not exist.`); 39 | return 1; 40 | } 41 | 42 | const fileResult = await fsp.readFile(deploymentConfigPath); 43 | var jsonResult; // eslint-disable-line no-var 44 | try { 45 | jsonResult = JSON.parse(fileResult); 46 | } catch (err) { 47 | console.error(`hubot-deployments-config.json is not a valid JSON file. ${err.message}`); 48 | return 1; 49 | } 50 | 51 | console.log('hubot-deployments-config.json is valid JSON. Showing debug file list...'); 52 | const fileList = recursiveWalk(workingDirectory); 53 | const collectorMap = {}; 54 | const allRegexPatterns = []; 55 | 56 | Object.keys(jsonResult).forEach(buildType => { 57 | allRegexPatterns.push(jsonResult[buildType]); 58 | collectorMap[buildType] = fileList.filter(x => fileFilter(x, jsonResult[buildType])); 59 | }); 60 | 61 | console.log('Results:'); 62 | Object.keys(collectorMap).forEach(buildType => { 63 | console.log(`Files Associated with ${buildType}:`); 64 | console.log(collectorMap[buildType]); 65 | }); 66 | console.log('Uncovered Files:'); 67 | console.log(fileList.filter(x => !fileFilter(x, allRegexPatterns))); 68 | return 0; 69 | }, 70 | register(subparsers) { 71 | subparsers.addParser(command, { 72 | addHelp: true, 73 | help: `${command} help` 74 | }); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /test/fs-utils-test.js: -------------------------------------------------------------------------------- 1 | import { findParentFolder, copyHookFiles, verifyHooksFolder } from '../src/fs-utils.js'; 2 | import path from 'path'; 3 | import fsys from 'fs'; 4 | import fsp from 'fs-promise'; 5 | import rimraf from 'rimraf'; 6 | 7 | const tmpDir = path.join(__dirname, 'tmp'); 8 | const tmpGitDir = path.join(tmpDir, '.git'); 9 | const hooksDir = path.join(tmpGitDir, 'hooks'); 10 | const commitMsgPath = path.join(hooksDir, 'commit-msg'); 11 | 12 | describe('FS-Utils Tests', () => { 13 | before(() => 14 | fsp.exists(tmpDir) 15 | .then(exists => { 16 | if (!exists) { 17 | return fsp.mkdir(tmpDir) 18 | .then(() => fsp.mkdir('test/tmp/.git')); 19 | } 20 | return ''; 21 | }) 22 | ); 23 | 24 | describe('Finding Directory', () => { 25 | it('Finding Named Directory', () => { 26 | const gitPath = findParentFolder(__dirname, '.git'); 27 | gitPath.should.equal(path.join(__dirname, '../.git')); 28 | }); 29 | 30 | it('Unable to Find Desired Directory', () => { 31 | const funct = () => { findParentFolder(path.join(__dirname, '../../')); }; 32 | expect(funct).to.throw(Error); 33 | }); 34 | }); 35 | 36 | describe('Hooks folder verification', () => { 37 | beforeEach(() => { 38 | const pathString = path.resolve(hooksDir); 39 | const hooksExists = fsys.existsSync(pathString); 40 | if (hooksExists) { 41 | rimraf.sync(pathString); 42 | } 43 | }); 44 | 45 | it('Confirm hooks folder exists', () => { 46 | verifyHooksFolder(hooksDir); 47 | assert(fsys.existsSync(hooksDir)); 48 | }); 49 | }); 50 | 51 | describe('Hook Installation', () => { 52 | before(() => 53 | fsp.exists(hooksDir) 54 | .then(exists => { 55 | if (!exists) { 56 | return fsp.mkdir(hooksDir); 57 | } 58 | return ''; 59 | }) 60 | ); 61 | 62 | beforeEach(() => 63 | fsp.exists(commitMsgPath) 64 | .then(exists => { 65 | if (exists) { 66 | return fsp.unlink(commitMsgPath); 67 | } 68 | return ''; 69 | }) 70 | ); 71 | 72 | it('Hook Creation Test', async function() { 73 | await copyHookFiles(tmpGitDir); 74 | fsys.existsSync(commitMsgPath).should.equal(true); 75 | }); 76 | 77 | it('Validate Hook File is Correct', async function() { 78 | await copyHookFiles(tmpGitDir); 79 | const newFile = fsys.readFileSync(commitMsgPath); 80 | const oldFile = fsys.readFileSync('hooks/commit-msg'); 81 | newFile.should.eql(oldFile); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/jira-operations.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export async function findProjectKey(jiraClient) { 4 | const projects = await jiraClient.listProjects(); 5 | return _.find(projects, project => project.name === jiraClient.projectName).key; 6 | } 7 | 8 | export const getEpicLinkField = _.memoize( 9 | async function getEpicLinkFieldActual(jiraClient) { 10 | const fields = await jiraClient.listFields(); 11 | for (let i = 0; i < fields.length; i++) { 12 | if (fields[i].name === 'Epic Link') { 13 | return fields[i].id; 14 | } 15 | } 16 | throw new Error('Cannot find Epic Link Field.'); 17 | }, 18 | (jiraClient) => jiraClient.host 19 | ); 20 | 21 | export function findIssueLinkParentKey(issue) { 22 | let result = null; 23 | issue.fields.issuelinks.forEach(issueLink => { 24 | if (issueLink.type.name !== 'Relates') { 25 | return; 26 | } 27 | 28 | let linkDirection = null; 29 | 30 | if (issueLink.inwardIssue) { 31 | linkDirection = 'inwardIssue'; 32 | } else if (issueLink.outwardIssue) { 33 | linkDirection = 'outwardIssue'; 34 | } 35 | 36 | if (linkDirection && issueLink[linkDirection].fields.issuetype.name === 'Initiative') { 37 | result = issueLink[linkDirection].key; 38 | } 39 | }); 40 | 41 | return result; 42 | } 43 | 44 | function handleSubtask(jiraClient, issue) { 45 | return jiraClient.findIssue(issue.fields.parent.key); 46 | } 47 | 48 | async function handleStory(jiraClient, issue) { 49 | if (issue.fields.issuelinks) { 50 | const parentKey = findIssueLinkParentKey(issue); 51 | 52 | if (parentKey) { 53 | return jiraClient.findIssue(parentKey); 54 | } 55 | } 56 | 57 | const linkField = await getEpicLinkField(jiraClient); 58 | const epicIssueNumber = issue.fields[linkField]; 59 | 60 | if (!epicIssueNumber) { 61 | throw new Error(`${issue.key} does not have an associated parent Initiative or Epic.`); 62 | } 63 | 64 | return jiraClient.findIssue(issue.fields[linkField]); 65 | } 66 | 67 | function handleEpic(jiraClient, issue) { 68 | const parentKey = findIssueLinkParentKey(issue); 69 | 70 | if (!parentKey) { 71 | throw new Error(`Cannot find initiative from Epic ${issue.key} in issue links. ` + 72 | "Initiative should be linked by 'relates to'."); 73 | } 74 | 75 | return jiraClient.findIssue(parentKey); 76 | } 77 | export const findParent = _.memoize( 78 | async function findParentActual(issue, jiraClient) { 79 | switch (issue.fields.issuetype.name) { 80 | case 'Sub-task': 81 | case 'Feature Defect': 82 | return handleSubtask(jiraClient, issue); 83 | case 'Story': 84 | return await handleStory(jiraClient, issue); 85 | case 'Epic': 86 | return handleEpic(jiraClient, issue); 87 | default: 88 | throw new Error(`${issue.fields.issuetype.name} should not have a parent.`); 89 | } 90 | }, 91 | (issue) => JSON.stringify(issue) 92 | ); 93 | -------------------------------------------------------------------------------- /test/jira-configuration-test.js: -------------------------------------------------------------------------------- 1 | import { getAPIConfig, getAuthentication, validateAPIConfig, validateAuthentication } 2 | from '../src/jira-configuration.js'; 3 | import { findParentFolder } from '../src/fs-utils.js'; 4 | import path from 'path'; 5 | 6 | const jiraPath = findParentFolder(path.join(process.cwd(), 'test'), '.jirarc'); 7 | const authPath = findParentFolder(path.join(process.cwd(), 'test'), '.userconfig'); 8 | 9 | const goodJiraObject = { 10 | projectName: 'test', 11 | protocol: 'https', 12 | host: 'jira.com', 13 | port: 8080, 14 | version: '2.1.0', 15 | verbose: true, 16 | strictSSL: true 17 | }; 18 | const missingHost = { 19 | projectName: 'test', 20 | port: 8080, 21 | version: '2.1.0', 22 | verbose: true, 23 | strictSSL: true 24 | }; 25 | const missingProjectName = { 26 | host: 'jira.com' 27 | }; 28 | 29 | const goodAuthenticationObject = { 30 | username: 'UserDudeBro', 31 | password: 'SuperSecret' 32 | }; 33 | const missingUsername = { 34 | password: 'SuperSecret' 35 | }; 36 | const missingPassword = { 37 | username: 'UserDudeBro' 38 | }; 39 | 40 | describe('JIRA Configuration Tests', () => { 41 | describe('API Config', () => { 42 | it('Get Project URL', () => { 43 | getAPIConfig(jiraPath) 44 | .then(config => config.projectName.should.equal('test')); 45 | }); 46 | 47 | it('Get Host', () => { 48 | getAPIConfig(jiraPath) 49 | .then(config => config.host.should.equal('jira.com')); 50 | }); 51 | 52 | it('Validation', () => { 53 | const object = validateAPIConfig(goodJiraObject); 54 | object.projectName.should.equal('test'); 55 | }); 56 | 57 | it('Missing Host', () => { 58 | assert.throw(() => { validateAPIConfig(missingHost); }, 59 | '.jirarc missing host url. Please check the README for details'); 60 | }); 61 | 62 | it('Missing Project Name', () => { 63 | assert.throw(() => { validateAPIConfig(missingProjectName); }, 64 | '.jirarc missing project name. Please check the README for details'); 65 | }); 66 | }); 67 | 68 | describe('Authentication', () => { 69 | it('Get username', () => { 70 | getAuthentication(authPath) 71 | .then(authConfig => authConfig.username.should.equal('UserDudeBro')); 72 | }); 73 | 74 | it('Get Password', () => { 75 | getAuthentication(authPath) 76 | .then(authConfig => authConfig.password.should.equal('SuperSecret')); 77 | }); 78 | 79 | it('Validation', () => { 80 | const object = validateAuthentication(goodAuthenticationObject); 81 | object.username.should.equal('UserDudeBro'); 82 | }); 83 | 84 | it('Missing Username', () => { 85 | assert.throw(() => { validateAuthentication(missingUsername); }, 86 | '.userconfig missing username'); 87 | }); 88 | 89 | it('Missing Password', () => { 90 | assert.throw(() => { validateAuthentication(missingPassword); }, 91 | '.userconfig missing password'); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | jira-precommit-hook 2 | ------------------- 3 | 4 | Goals: 5 | 6 | - Commit hook script contains the bare bones needed to call into this library. 7 | We may want to investigate use of existing 8 | [pre-commit](https://www.npmjs.com/package/pre-commit) module. 9 | 10 | 11 | # NOTICE 12 | 13 | Currently only configured for read-only access to JIRA. 14 | 15 | # Project Configuration 16 | 17 | ## DESCRIPTION 18 | 19 | In order to communicate with your JIRA server, a .jirarc file needs to be 20 | placed in the root of the repo. 21 | 22 | ```json 23 | { 24 | "projectName": "", 25 | "host":"", 26 | "protocol":"[default:http|https]", 27 | "port": default:80, 28 | "version": default:2, 29 | "verbose": default:false, 30 | "strictSSL": default:true 31 | } 32 | ``` 33 | 34 | ## INSTALLATION 35 | 36 | To install, run the following with [npm](https://www.npmjs.com): 37 | 38 | ```bash 39 | > npm install jira-precommit-hook 40 | ``` 41 | 42 | ## SYMLINK DIRECTORY NOTICE 43 | 44 | If the hooks directory in your .git folder is symlinked, the module will be 45 | unable to find it. To avoid this, do not symlink your hooks folder inside of 46 | your project's git directory. 47 | 48 | # Making JIRA Commits 49 | 50 | _In order to make a successful commit with the precommit hook, **ALL** issues 51 | being committed must meet the following requirements:_ 52 | 53 | - There must be at least one issue in the commit message 54 | - All committed issues must exist in the project designated in the .jirarc 55 | 56 | 57 | _At **least one** issue being committed must meet the following requirements:_ 58 | - The issue must be open for commits 59 | - The parents of the issue must also be open for commits 60 | - The issue must lead up to an initiative 61 | - The issue must not be an initiative, epic, nor a deployment task 62 | 63 | ## Jokes 64 | 65 | By default you will get a joke presented to you upon a successful commit. This 66 | is two-fold, first to get you accustomed to the commit hook so you are aware 67 | when it's not present. The second is for some comic relief at work, cause we 68 | all need it. 69 | 70 | ### To disable the jokes from your shell 71 | 72 | ```bash 73 | > ./node_modules/.bin/jira-precommit jokes disable 74 | ``` 75 | 76 | ### To re-enable the jokes from your shell 77 | 78 | ```bash 79 | > ./node_modules/.bin/jira-precommit jokes enable 80 | ``` 81 | ***To disable*** 82 | To disable the joke feature simply delete the .chuckNorris file from your home directory. 83 | 84 | ## Hubot Deployments Config 85 | 86 | For repositories that have a hubot-deployments-config.json, this precommit hook 87 | will not allow you to commit if that file is an invalid json file. In addition 88 | it also provides a command to show what files are covered and uncovered by the 89 | regex patterns in that file. 90 | 91 | ### Check Config command 92 | ```bash 93 | > ./node_modules/.bin/jira-precommit checkconfig 94 | ``` 95 | 96 | This output is often long for a project, so it is useful to pipe into a file. 97 | -------------------------------------------------------------------------------- /src/precommit-entry.js: -------------------------------------------------------------------------------- 1 | /* eslint no-process-exit:0 */ 2 | import fsp from 'fs-promise'; 3 | import _ from 'lodash'; 4 | import * as issueHandler from './issue-handler'; 5 | import { findProjectKey } from './jira-operations'; 6 | import { getJiraAPI } from './jira-connection'; 7 | import * as fsUtils from './fs-utils'; 8 | import checkOutdated from './outdated-check'; 9 | import chalk from 'chalk'; 10 | import fetchJoke from './joke'; 11 | import config from './config'; 12 | import checkUserEmail from './user-email-check'; 13 | import checkValidJSON from './deployments-config-check'; 14 | 15 | export function getIssueReference(msgToParse, prjKey) { 16 | const pattern = RegExp(`${prjKey}-\\d+`, 'gi'); 17 | const commentPattern = RegExp('^#.*$', 'gm'); 18 | 19 | const msgToParseReplaced = msgToParse.replace(commentPattern, ''); 20 | const references = msgToParseReplaced.match(pattern); 21 | 22 | return _.uniq(references).map(x => x.toUpperCase()); 23 | } 24 | 25 | export async function getCommitMsg(readPromise) { 26 | let jiraAPI; 27 | let jiraConfigPath; 28 | 29 | try { 30 | jiraConfigPath = fsUtils.findParentFolder(process.cwd(), '.jirarc'); 31 | } catch (err) { 32 | throw new Error('.jirarc file is not found. Please refer to the readme for details about ' + 33 | 'the .jirarc file'); 34 | } 35 | 36 | const [projectKey, fileContents] = await Promise.all([ 37 | getJiraAPI(jiraConfigPath) 38 | .then(api => jiraAPI = api) // eslint-disable-line no-return-assign 39 | .then(() => findProjectKey(jiraAPI)), 40 | readPromise 41 | ]); 42 | 43 | const firstWord = fileContents.split(' ')[0]; 44 | 45 | if (firstWord === 'Merge') { 46 | return null; 47 | } 48 | 49 | const issues = getIssueReference(fileContents, projectKey); 50 | return issueHandler.issueStrategizer(issues, jiraAPI); 51 | } 52 | 53 | export async function precommit(path) { 54 | const showJoke = fetchJoke(config); 55 | await checkOutdated(); 56 | 57 | const readPromise = fsp.readFile(path, { encoding: 'utf8' }); 58 | 59 | try { 60 | await Promise.all([ 61 | getCommitMsg(readPromise), 62 | checkUserEmail(), 63 | checkValidJSON({ 64 | fileSystem: fsp, 65 | fileSystemUtils: fsUtils, 66 | log: console.log 67 | }) 68 | ]); 69 | await showJoke(); 70 | console.log(chalk.grey('[jira-precommit-hook] ') + 71 | chalk.cyan('Commit message successfully verified.')); 72 | return 0; 73 | } catch (err) { 74 | try { 75 | const contents = await readPromise; 76 | console.log('Commit Message:'); 77 | console.log(contents); 78 | 79 | if (typeof err === 'string') { 80 | console.error(chalk.red(err)); 81 | } else if (process.env.NODE_ENV === 'development') { 82 | console.error(chalk.red(err.stack)); 83 | } else { 84 | console.error(chalk.red(err.toString())); 85 | } 86 | 87 | return 1; 88 | } catch (err2) { 89 | console.log(chalk.red('Failed to read commit message file.')); 90 | return 1; 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /test/promise-utils-test.js: -------------------------------------------------------------------------------- 1 | import * as promiseUtils from '../src/promise-utils.js'; 2 | 3 | describe('Any Promise Tests', () => { 4 | it('No args', () => { 5 | promiseUtils.anyPromise() 6 | .should.eventually.be.rejectedWith(Error, 'No arguments provided'); 7 | }); 8 | 9 | it('Non-array argument', () => { 10 | const testArgs = 0; 11 | return promiseUtils.anyPromise(testArgs) 12 | .should.eventually.be.rejectedWith(Error, 'Argument is not a non-array'); 13 | }); 14 | 15 | it('Empty array', () => { 16 | const testArgs = []; 17 | return promiseUtils.anyPromise(testArgs) 18 | .should.eventually.be.rejectedWith(Error, 'Argument is not a non-array'); 19 | }); 20 | 21 | it('Successful single promise', () => { 22 | const testPromiseArray = [Promise.resolve(true)]; 23 | return promiseUtils.anyPromise(testPromiseArray) 24 | .should.eventually.equal(true); 25 | }); 26 | 27 | it('Unsuccessful single promise', () => { 28 | const testPromiseArray = [Promise.reject(new Error('Failed'))]; 29 | return promiseUtils.anyPromise(testPromiseArray) 30 | .should.eventually.be.rejectedWith(Error, 'Failed'); 31 | }); 32 | 33 | it('1 successful and 1 incompconste promise', () => { 34 | const testPromiseArray = [Promise.resolve(true), new Promise(() => {})]; 35 | return promiseUtils.anyPromise(testPromiseArray) 36 | .should.eventually.equal(true); 37 | }); 38 | 39 | it('1 incompconste and 1 successful promise', () => { 40 | const testPromiseArray = [new Promise(() => {}), Promise.resolve(true)]; 41 | return promiseUtils.anyPromise(testPromiseArray) 42 | .should.eventually.equal(true); 43 | }); 44 | 45 | it('1 successful and 1 failure', () => { 46 | const testPromiseArray = [Promise.resolve(true), Promise.reject(new Error('Failed'))]; 47 | return promiseUtils.anyPromise(testPromiseArray) 48 | .should.eventually.equal(true); 49 | }); 50 | 51 | it('1 failure and 1 successful', () => { 52 | const testPromiseArray = [Promise.reject(new Error('Failed')), Promise.resolve(true)]; 53 | return promiseUtils.anyPromise(testPromiseArray) 54 | .should.eventually.equal(true); 55 | }); 56 | 57 | it('2 failures', () => { 58 | const err1 = new Error('Error 1'); 59 | const err2 = new Error('Error 2'); 60 | const testPromiseArray = [Promise.reject(err1), Promise.reject(err2)]; 61 | return promiseUtils.anyPromise(testPromiseArray) 62 | .should.eventually.be.rejectedWith([err1, err2]); 63 | }); 64 | 65 | it('3 failures', () => { 66 | const err1 = new Error('Error 1'); 67 | const err2 = new Error('Error 2'); 68 | const err3 = new Error('Error 3'); 69 | const testPromiseArray = [Promise.reject(err1), Promise.reject(err2), Promise.reject(err3)]; 70 | return promiseUtils.anyPromise(testPromiseArray) 71 | .should.eventually.be.rejectedWith([err1, err2, err3]); 72 | }); 73 | 74 | it('2 failures and 1 successful', () => { 75 | const err1 = new Error('Error 1'); 76 | const err2 = new Error('Error 2'); 77 | const testPromiseArray = [Promise.reject(err1), Promise.reject(err2), Promise.resolve(true)]; 78 | return promiseUtils.anyPromise(testPromiseArray) 79 | .should.eventually.be.equal(true); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/precommit-entry-test.js: -------------------------------------------------------------------------------- 1 | import * as pce from '../src/precommit-entry'; 2 | import * as issueHandler from '../src/issue-handler'; 3 | import opsys from 'os'; 4 | import * as connection from '../src/jira-connection'; 5 | import * as operations from '../src/jira-operations'; 6 | import JiraApi from 'jira-client'; 7 | import * as fileUtils from '../src/fs-utils'; 8 | 9 | const eol = opsys.EOL; 10 | 11 | describe('precommit-entry tests', () => { 12 | describe('Hook Message', () => { 13 | beforeEach(() => { 14 | const stubJson = { 15 | issueType: { 16 | name: 'Story' 17 | } 18 | }; 19 | 20 | sinon.stub(issueHandler, 'issueStrategizer', issues => { 21 | const jsonObjects = [stubJson, stubJson, stubJson]; 22 | return jsonObjects; 23 | }); 24 | 25 | sinon.stub(connection, 'getJiraAPI', () => { 26 | const api = new JiraApi( 27 | 'http', 28 | 'www.jira.com', 29 | 80, 30 | 'UserDudeBro', 31 | 'SuperSecret', 32 | '2.0.0' 33 | ); 34 | return Promise.resolve(api); 35 | }); 36 | 37 | operations.findProjectKey = (api) => 'TW'; 38 | 39 | sinon.stub(fileUtils, 'findParentFolder', (startDir, fileName) => './.jirarc'); 40 | }); 41 | 42 | afterEach(() => { 43 | issueHandler.issueStrategizer.restore(); 44 | connection.getJiraAPI.restore(); 45 | fileUtils.findParentFolder.restore(); 46 | }); 47 | 48 | it('read from issue list and return JSON array', () => { 49 | pce.getCommitMsg(Promise.resolve('')) 50 | .then(results => { 51 | results.length.should.equal(3); 52 | }); 53 | }); 54 | 55 | it('Check for merge commit', () => { 56 | pce.getCommitMsg(Promise.resolve('Merge')) 57 | .then(results => { 58 | assert.equal(results, null); 59 | }); 60 | }); 61 | 62 | it('Check for revert commit', () => { 63 | pce.getCommitMsg(Promise.resolve('Revert')) 64 | .then(results => { 65 | results.length.should.equal(3); 66 | }); 67 | }); 68 | }); 69 | 70 | describe('Issue number test', () => { 71 | it('Parse issue number, no issue numbers found', () => { 72 | pce.getIssueReference('no issue numbers here', 'TW').should.eql([]); 73 | }); 74 | 75 | it('Parse issue number', () => { 76 | pce.getIssueReference('TW-5734', 'TW').should.eql(['TW-5734']); 77 | }); 78 | 79 | it('Parse issue number lowercase', () => { 80 | pce.getIssueReference('tw-5734', 'TW').should.eql(['TW-5734']); 81 | }); 82 | 83 | it('Parse issue number mixed case', () => { 84 | pce.getIssueReference('tW-5734', 'TW').should.eql(['TW-5734']); 85 | }); 86 | 87 | it('Parse multiple issue numbers', () => { 88 | pce.getIssueReference('TW-763 blah TW-856', 'TW').should.eql(['TW-763', 'TW-856']); 89 | }); 90 | 91 | it('Parse multiple issue numbers, ignore duplicates', () => { 92 | pce.getIssueReference('TW-123 blah blah TW-123', 'TW').should.eql(['TW-123']); 93 | }); 94 | 95 | it('Parse issue number, ignore issue numbers in comments', () => { 96 | const content = `TW-2345${eol}#TW-6346`; 97 | pce.getIssueReference(content, 'TW').should.eql(['TW-2345']); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /test/jira-operations-test.js: -------------------------------------------------------------------------------- 1 | import { findProjectKey, getEpicLinkField, findParent, findIssueLinkParentKey } 2 | from '../src/jira-operations.js'; 3 | import DummyJira from './dummy-jira.js'; 4 | 5 | const dummyJira = new DummyJira(); 6 | 7 | describe('JIRA Operations Tests', () => { 8 | describe('Find Issue Parent', () => { 9 | it('Find Project Keys', () => { 10 | findProjectKey(dummyJira) 11 | .then(key => { 12 | key.should.eql('XYZ'); 13 | }); 14 | }); 15 | 16 | it('Find Epic Link', () => { 17 | getEpicLinkField(dummyJira) 18 | .then(field => { 19 | field.should.eql('customfield_10805'); 20 | }); 21 | }); 22 | 23 | it('Missing Epic Link', done => { 24 | dummyJira.listFields = async function() { 25 | return dummyJira.fields.noEpicLink; 26 | }; 27 | dummyJira.host = 'jira.host2.com'; 28 | 29 | getEpicLinkField(dummyJira) 30 | .should.eventually.be.rejectedWith(/Cannot find Epic Link Field/) 31 | .notify(done); 32 | }); 33 | 34 | it('Find Parent from Sub-task', async function() { 35 | const parent = await findParent(dummyJira.issues.SubTask1, dummyJira); 36 | parent.fields.issuetype.name.should.eql('Story'); 37 | }); 38 | 39 | it('Find Parent from Feature Defect', async function() { 40 | const parent = await findParent(dummyJira.issues.FeatureDefect1, dummyJira); 41 | parent.fields.issuetype.name.should.eql('Story'); 42 | }); 43 | 44 | it('Find Parent from Story by EpicLink', async function() { 45 | dummyJira.listFields = async function() { 46 | return dummyJira.fields.epicLink; 47 | }; 48 | dummyJira.host = 'jira.host3.com'; 49 | 50 | const parent = await findParent(dummyJira.issues.Story3, dummyJira); 51 | parent.fields.issuetype.name.should.eql('Epic'); 52 | }); 53 | 54 | it('Find Parent from Story by IssueLink', async function() { 55 | const parent = await findParent(dummyJira.issues.Story4, dummyJira); 56 | parent.fields.issuetype.name.should.eql('Initiative'); 57 | }); 58 | 59 | it('Find Parent from Story with no Epic or Initiative', (done) => { 60 | findParent(dummyJira.issues.Story9, dummyJira) 61 | .should.eventually.be.rejectedWith( 62 | /Story9 does not have an associated parent Initiative or Epic./) 63 | .notify(done); 64 | }); 65 | 66 | it('Find Parent from Epic', async function() { 67 | const parent = await findParent(dummyJira.issues.Epic3, dummyJira); 68 | parent.fields.issuetype.name.should.eql('Initiative'); 69 | }); 70 | 71 | it('No Parent Found from Epic', done => { 72 | findParent(dummyJira.issues.Epic1, dummyJira) 73 | .should.eventually.be.rejectedWith( 74 | /initiative from Epic Epic1 in issue links. Initiative should be linked by 'relates to'/) 75 | .notify(done); 76 | }); 77 | 78 | it('No Parent Found from Initiative', done => { 79 | findParent(dummyJira.issues.I2, dummyJira) 80 | .should.eventually.be.rejectedWith(/Initiative should not have a parent/) 81 | .notify(done); 82 | }); 83 | }); 84 | 85 | describe('Relates Check', () => { 86 | it('Good Link', () => { 87 | const result = findIssueLinkParentKey(dummyJira.issues.Story2); 88 | result.should.equal('I2'); 89 | }); 90 | 91 | it('Bad Link', () => { 92 | const result = findIssueLinkParentKey(dummyJira.issues.Story5); 93 | expect(result).to.be.null; 94 | }); 95 | }); 96 | 97 | describe('Memoization Tests', () => { 98 | let spy; 99 | 100 | it('findParent with Same Key is Called Only Once', async function() { 101 | spy = sinon.spy(dummyJira, 'findIssue'); 102 | 103 | const [first, second] = await Promise.all([ 104 | findParent(dummyJira.issues.SubTask2, dummyJira), 105 | findParent(dummyJira.issues.SubTask2, dummyJira) 106 | ]); 107 | 108 | spy.calledOnce.should.be.true; 109 | first.should.equal(second); 110 | }); 111 | 112 | it('getEpicLinkField with Same JIRA Host is Called Only Once', async function() { 113 | spy = sinon.spy(dummyJira, 'listFields'); 114 | dummyJira.host = 'jira.host4.com'; 115 | 116 | const [first, second] = await Promise.all([ 117 | getEpicLinkField(dummyJira), 118 | getEpicLinkField(dummyJira) 119 | ]); 120 | 121 | spy.calledOnce.should.be.true; 122 | first.should.equal(second); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /test/dummy-jira.js: -------------------------------------------------------------------------------- 1 | import issueGenerator from './issue-generator.js'; 2 | 3 | class DummyJira { 4 | constructor() { 5 | this.host = 'jira.host.com'; 6 | this.projectName = 'Last Three'; 7 | 8 | this.issues = { 9 | Bug1: issueGenerator('Bug1', 'Bug', 'yellow'), 10 | Bug2: issueGenerator('Bug2', 'Bug', 'green'), 11 | 12 | MT1: issueGenerator('MT1', 'Maintenance Task', 'yellow'), 13 | MT2: issueGenerator('MT2', 'Maintenance Task', 'green'), 14 | MT3: issueGenerator('MT3', 'Maintenance Task', 'yellow', 'Epic1'), 15 | MT4: issueGenerator('MT4', 'Maintenance Task', 'yellow', 'Epic4'), 16 | 17 | I1: issueGenerator('I1', 'Initiative', 'yellow'), 18 | I2: issueGenerator('I2', 'Initiative', 'yellow'), 19 | I3: issueGenerator('I3', 'Initiative', 'red'), 20 | 21 | DeploymentTask1: issueGenerator('DeploymentTask1', 'Deployment Task', 'yellow'), 22 | 23 | Unknown1: issueGenerator('Unknown1', 'Unknown', 'yellow'), 24 | 25 | Epic1: issueGenerator('Epic1', 'Epic', 'yellow'), 26 | // Epic2: issueGenerator('Epic2', 'Epic', 'green'), // Missing tests? 27 | Epic3: issueGenerator('Epic3', 'Epic', 'yellow', 'I2', 'Initiative', 'Relates'), 28 | Epic4: issueGenerator('Epic4', 'Epic', 'red', 'I2', 'Initiative', 'Relates'), 29 | // Epic5: issueGenerator('Epic5', 'Epic', 'yellow', 'I3', 'Initiative', 'Relates'), 30 | // Missing tests? ^ 31 | 32 | // Valid Parents 33 | Story1: issueGenerator('Story1', 'Story', 'yellow', 'Epic3', 'Epic'), 34 | Story2: issueGenerator('Story2', 'Story', 'yellow', 'I2', 'Initiative', 'Relates'), 35 | 36 | // Invalid Parents 37 | Story3: issueGenerator('Story3', 'Story', 'yellow', 'Epic4', 'Epic'), 38 | Story4: issueGenerator('Story4', 'Story', 'yellow', 'I3', 'Initiative', 'Relates'), 39 | Story5: issueGenerator('Story5', 'Story', 'yellow', 'I2', 'Initiative', 'Blocks'), 40 | 41 | // Invalid Story 42 | Story6: issueGenerator('Story6', 'Story', 'red', 'Epic4', 'Epic'), 43 | // Story7: issueGenerator('Story7', 'Story', 'green'), // Missing tests? 44 | // Story8: issueGenerator('Story8', 'Story', 'yellow'), // Missing tests? 45 | Story9: issueGenerator('Story9', 'Story', 'yellow', null, 'Epic'), 46 | 47 | // Valid Parents 48 | SubTask1: issueGenerator('SubTask1', 'Sub-task', 'yellow', 'Story1'), 49 | SubTask2: issueGenerator('SubTask2', 'Sub-task', 'yellow', 'Story2'), 50 | SubTask3: issueGenerator('SubTask3', 'Sub-task', 'yellow', 'MT1'), 51 | SubTask4: issueGenerator('SubTask4', 'Sub-task', 'yellow', 'MT3'), 52 | SubTask5: issueGenerator('SubTask5', 'Sub-task', 'yellow', 'MT4'), 53 | SubTask6: issueGenerator('SubTask6', 'Sub-task', 'yellow', 'Bug1'), 54 | 55 | // Invalid Parents 56 | SubTask7: issueGenerator('SubTask7', 'Sub-task', 'yellow', 'Story3'), 57 | SubTask8: issueGenerator('SubTask8', 'Sub-task', 'yellow', 'Story4'), 58 | SubTask9: issueGenerator('SubTask9', 'Sub-task', 'yellow', 'Story6'), 59 | SubTask10: issueGenerator('SubTask10', 'Sub-task', 'yellow', 'MT2'), 60 | SubTask11: issueGenerator('SubTask11', 'Sub-task', 'yellow', 'Bug2'), 61 | 62 | // Invalid SubTask 63 | SubTask12: issueGenerator('SubTask12', 'Sub-task', 'red', 'Story6'), 64 | SubTask13: issueGenerator('SubTask13', 'Sub-task', 'green', 'MT1'), 65 | SubTask14: issueGenerator('SubTask14', 'Sub-task', 'green', 'Bug1'), 66 | SubTask15: issueGenerator('SubTask15', 'Sub-task', 'green'), 67 | // SubTask1: issueGenerator('SubTask1', 'Sub-task', 'yellow'), // Missing tests? 68 | 69 | Task1: issueGenerator('Task1', 'Task', 'yellow'), 70 | 71 | FeatureDefect1: issueGenerator( 72 | 'FeatureDefect1', 'Feature Defect', 'yellow', 'Story2', 'Story') 73 | // FeatureDefect2: issueGenerator( 74 | // 'FeatureDefect2', 'Feature Defect', 'green', 'Story2', 'Story') 75 | // Missing tests? ^ 76 | }; 77 | 78 | this.fields = { 79 | epicLink: [{ 80 | id: 'customfield_10805', 81 | name: 'Epic Link' 82 | }], 83 | noEpicLink: [] 84 | }; 85 | 86 | this.projects = [ 87 | { 88 | key: 'ABC', 89 | name: 'First Three' 90 | }, 91 | { 92 | key: 'XYZ', 93 | name: 'Last Three' 94 | } 95 | ]; 96 | } 97 | 98 | findIssue(key) { 99 | if (this.issues[key] === undefined) { 100 | return Promise.reject(new Error(`Issue ${key} does not exist.`)); 101 | } 102 | 103 | return Promise.resolve(this.issues[key]); 104 | } 105 | 106 | listFields() { 107 | return Promise.resolve(this.fields.epicLink); 108 | } 109 | 110 | listProjects() { 111 | return Promise.resolve(this.projects); 112 | } 113 | } 114 | 115 | export default DummyJira; 116 | --------------------------------------------------------------------------------