├── .gitattributes ├── CHANGELOG.md ├── .eslintrc.js ├── .travis.yml ├── src ├── teams.actions.js ├── edit-contents.js ├── point-reducers.js ├── data │ └── paginator.js ├── team.actions.js ├── formatters │ ├── issues.js │ ├── table.js │ └── index.js ├── config.js ├── sprints.actions.js ├── epics.actions.js ├── config.actions.js ├── team-data.js ├── sprint.actions.js ├── epic.actions.js ├── jira-client.js └── issue.actions.js ├── test ├── team-data.test.js ├── issue.actions.test.js ├── sprint.actions.test.js ├── formatters │ └── table.test.js ├── sprints.actions.test.js ├── epic.actions.test.js ├── config.test.js ├── config.actions.test.js ├── issue.response.js └── jira-client.test.js ├── .gitignore ├── LICENSE ├── package.json ├── README.md └── bin └── cli.js /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js eol=lf 2 | package-lock.json binary 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ##1.1.0 4 | [#27] Kick off the rust -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true 4 | }, 5 | "root": true, 6 | "extends": "godaddy" 7 | } 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | matrix: 4 | fast_finish: true 5 | include: 6 | - node_js: "10" 7 | - node_js: "8" 8 | # https://github.com/greenkeeperio/greenkeeper-lockfile#npm 9 | before_install: 10 | # package-lock.json was introduced in npm@5 11 | - '[[ $(node -v) =~ ^v9.*$ ]] || npm install -g npm@latest' # skipped when using node 9 12 | - npm install -g greenkeeper-lockfile 13 | before_script: greenkeeper-lockfile-update 14 | after_script: greenkeeper-lockfile-upload 15 | install: npm install 16 | -------------------------------------------------------------------------------- /src/teams.actions.js: -------------------------------------------------------------------------------- 1 | const { makeGetRequest } = require('./jira-client'); 2 | 3 | async function getTeams({ id }) { 4 | const teams = await makeGetRequest('board', 'agile/1.0', { query: { projectKeyOrId: id } }); 5 | return teams.values; 6 | } 7 | 8 | async function get({ id }) { 9 | const teams = await getTeams({ id }); 10 | return teams.map(team => ({ 11 | id: team.id, 12 | name: team.name, 13 | type: team.type 14 | })); 15 | } 16 | 17 | module.exports = { 18 | create: () => {}, 19 | describe: get, 20 | get, 21 | getTeams 22 | }; 23 | -------------------------------------------------------------------------------- /src/edit-contents.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-sync */ 2 | const editor = require('editor2'); 3 | const fs = require('fs'); 4 | const tmp = require('tmp'); 5 | 6 | async function editContents({ content, prefix = 'jiractl-', postfix = '.txt' }) { 7 | const tmpobj = tmp.fileSync({ prefix, postfix }); 8 | try { 9 | fs.writeFileSync(tmpobj.name, content); 10 | await editor(tmpobj.name); 11 | const res = fs.readFileSync(tmpobj.name).toString(); 12 | tmpobj.removeCallback(); 13 | return res; 14 | } catch (err) { 15 | tmpobj.removeCallback(); 16 | throw err; 17 | } 18 | } 19 | 20 | module.exports = editContents; 21 | -------------------------------------------------------------------------------- /src/point-reducers.js: -------------------------------------------------------------------------------- 1 | const { getCurrentContext } = require('./config'); 2 | 3 | function reduceStoryPoints(total, story) { 4 | const points = getCurrentContext().points; 5 | return total + (story.fields[points] || 0); 6 | } 7 | 8 | function getCompletedPoints(stories) { 9 | return stories 10 | .filter(story => story.fields.status.name === 'Closed' || story.fields.status.name === 'Completed') 11 | .reduce(reduceStoryPoints, 0); 12 | } 13 | 14 | function getTotalPoints(stories) { 15 | return stories.reduce(reduceStoryPoints, 0); 16 | } 17 | 18 | module.exports = { 19 | getCompletedPoints, 20 | getTotalPoints 21 | }; 22 | -------------------------------------------------------------------------------- /test/team-data.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const teamData = require('../src/team-data'); 3 | 4 | describe('src.team-data', () => { 5 | describe('getTeamId', () => { 6 | const { getTeamId } = teamData; 7 | 8 | it('should map teamId against teamMap', () => { 9 | const teamMap = { foo: { board: 2456 } }; 10 | 11 | const teamId = getTeamId('foo', teamMap); 12 | expect(teamId).to.equal(2456); 13 | }); 14 | 15 | it('should return teamId if there is no entry in team-map', () => { 16 | const teamMap = {}; 17 | 18 | const teamId = getTeamId('foo', teamMap); 19 | expect(teamId).to.equal('foo'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/data/paginator.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = class Paginator { 3 | constructor({ fetchPage, processResults }) { 4 | // Fetch & handle results of any entity (e.g. Epic, Story, etc.) 5 | this.fetchPage = fetchPage; 6 | this.processResults = processResults; 7 | } 8 | 9 | async nextPage(startAt, acc) { 10 | const { fetchPage, processResults } = this; 11 | const result = await fetchPage({ startAt }); 12 | 13 | acc.push(...processResults(result)); 14 | 15 | return result; 16 | } 17 | 18 | async fetchAll() { 19 | let isLast = false; 20 | let startAt = 0; 21 | const values = []; 22 | 23 | while (!isLast) { 24 | const result = await this.nextPage(startAt, values); 25 | 26 | startAt += result.values.length; 27 | isLast = result.isLast; 28 | } 29 | 30 | return values; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | project.xcworkspace 24 | 25 | *.log 26 | 27 | #Test 28 | test/coverage 29 | test/junit 30 | 31 | # node.js 32 | # 33 | node_modules/ 34 | npm-debug.log 35 | test/junit 36 | test/coverage 37 | 38 | # BUCK 39 | buck-out/ 40 | \.buckd/ 41 | android/app/libs 42 | android/keystores/debug.keystore 43 | 44 | # VSC 45 | 46 | .vscode 47 | .history 48 | tsconfig.json 49 | packager.log 50 | 51 | # VIM 52 | .tern-port 53 | 54 | # CodePush 55 | /CodePush/main.jsbundle.meta 56 | /CodePush/main.jsbundle 57 | jsconfig.json 58 | 59 | # mocha test results from native ui tests 60 | test-results.xml 61 | 62 | # Caching 63 | ceph/assetMap.json 64 | 65 | # wdio result 66 | results/ 67 | 68 | # Config 69 | .jiractl-team-map.json 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 GoDaddy Operating Company, LLC. 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/issue.actions.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const proxyquire = require('proxyquire'); 3 | const clientStub = {}; 4 | const configStub = {}; 5 | const issueActions = proxyquire('../src/issue.actions', { 6 | './jira-client': clientStub, 7 | './config': configStub 8 | }); 9 | 10 | const issueResponse = require('./issue.response'); 11 | 12 | describe('src.issue.actions', () => { 13 | beforeEach(() => { 14 | clientStub.makeGetRequest = () => issueResponse; 15 | configStub.getCurrentContext = () => { return { points: 'customfield_10004' }; }; 16 | }); 17 | 18 | it('gets an issue by key', async () => { 19 | const issue = await issueActions.getIssue('GX-123'); 20 | expect(issue).to.have.all.keys(['expand', 'id', 'self', 'key', 'fields']); 21 | }); 22 | 23 | it('extracts `get` fields from an issue response', async () => { 24 | const issue = await issueActions.get.action({ id: 'GX-123' }); 25 | expect(issue).to.eql({ 26 | summary: issueResponse.fields.summary, 27 | status: issueResponse.fields.status.name, 28 | epic: issueResponse.fields.epic.key, 29 | sprint: `${ issueResponse.fields.sprint.name }, ${ issueResponse.fields.closedSprints[0].name }`, 30 | assignee: issueResponse.fields.assignee.name, 31 | key: issueResponse.key, 32 | points: issueResponse.fields.customfield_10004 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/team.actions.js: -------------------------------------------------------------------------------- 1 | const { getSprints } = require('./sprints.actions'); 2 | const { getTeamId } = require('./team-data'); 3 | const { makeGetRequest } = require('./jira-client'); 4 | const { getCurrentContext } = require('./config'); 5 | 6 | async function getTeam(teamId) { 7 | const team = await makeGetRequest('board/' + teamId); 8 | delete team.self; 9 | return team; 10 | } 11 | 12 | async function describe({ id }) { 13 | const teamId = getTeamId(id); 14 | const teamObj = await getTeam(teamId); 15 | const sprints = await getSprints({ team: id }); 16 | const backlog = await makeGetRequest(`board/${ teamId }/backlog?maxResults=20`); 17 | const stats = sprints.map(sprint => ({ 18 | id: sprint.id, 19 | name: sprint.name, 20 | estimated: sprint.estimated, 21 | completed: sprint.velocity 22 | })); 23 | const points = getCurrentContext().points; 24 | const issues = backlog.issues.map(issue => ({ 25 | key: issue.key, 26 | summary: issue.fields.summary, 27 | points: issue.fields[points] || '-' 28 | })); 29 | const results = Object.assign({}, teamObj, { velocity: stats }, { backlog: issues }); 30 | const activeSprint = sprints.filter(sprint => sprint.state === 'active'); 31 | if (activeSprint) { 32 | results.activeSprint = activeSprint[0]; 33 | } 34 | return results; 35 | } 36 | 37 | async function get({ id }) { 38 | const teamId = getTeamId(id); 39 | return await getTeam(teamId); 40 | } 41 | 42 | module.exports = { 43 | get, 44 | describe, 45 | create: () => {} 46 | }; 47 | -------------------------------------------------------------------------------- /src/formatters/issues.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { formatTable } = require('./table'); 3 | 4 | function describe(issue) { 5 | console.log('Issue'); 6 | console.log(issue.key); 7 | console.log(); 8 | console.log('Summary'); 9 | console.log(issue.summary); 10 | console.log(); 11 | console.log('Description'); 12 | console.log(issue.description); 13 | console.log(); 14 | describeDetails(issue); 15 | } 16 | 17 | function describeDetails(issue) { 18 | console.log('Creator: ', issue.creator); 19 | console.log('Assignee: ', issue.assignee); 20 | console.log('Status: ', issue.status); 21 | console.log('Priority: ', issue.priority); 22 | console.log('Epic: ', issue.epic); 23 | console.log('Sprint: ', issue.sprint); 24 | console.log('Points: ', issue.points); 25 | } 26 | 27 | function get(issue) { 28 | console.log('Summary'); 29 | console.log(issue.summary); 30 | console.log(); 31 | console.log(formatTable([{ 32 | key: issue.key, 33 | points: issue.points, 34 | assignee: issue.assignee, 35 | sprint: issue.sprint, 36 | epic: issue.epic, 37 | status: issue.status 38 | }])); 39 | } 40 | 41 | function update(issue) { 42 | console.log(formatTable([issue])); 43 | } 44 | 45 | function jsonDefault({ issues }) { 46 | const formattedIssues = issues.map(i => ({ 47 | key: i.key, 48 | summary: i.fields.summary, 49 | status: i.fields.status.name 50 | })); 51 | 52 | return formattedIssues; 53 | } 54 | 55 | module.exports = { 56 | console: { 57 | describe, 58 | get, 59 | update 60 | }, 61 | json: { 62 | default: jsonDefault 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /test/sprint.actions.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const proxyquire = require('proxyquire'); 3 | const clientStub = {}; 4 | const issueResponse = require('./issue.response'); 5 | const sprintActions = proxyquire('../src/sprint.actions', { './jira-client': clientStub }); 6 | 7 | const sprintId = 123; 8 | const sprintData = { 9 | id: sprintId, 10 | state: 'active', 11 | name: 'Cats 4/23-5/4', 12 | startDate: '2018-04-23', 13 | endDate: '2018-05-04' 14 | }; 15 | const sprintResponse = Object.assign({}, sprintData, { 16 | self: `https://jira.com/rest/agile/1.0/sprint/${ sprintId }`, 17 | originBoardId: 1, 18 | goal: '' 19 | }); 20 | const sprintIssuesResponse = { 21 | expand: 'schema,names', 22 | startAt: 0, 23 | maxResults: 50, 24 | total: 2, 25 | issues: [issueResponse, issueResponse] 26 | }; 27 | 28 | describe('src.sprint.actions', () => { 29 | 30 | beforeEach(() => { 31 | clientStub.makeGetRequest = (endpoint) => { 32 | return endpoint === `sprint/${ sprintId }` ? sprintResponse : sprintIssuesResponse; 33 | }; 34 | }); 35 | 36 | it('gets a sprint by id', async () => { 37 | const sprint = await sprintActions.get({ team: 'cats', id: sprintId }); 38 | expect(sprint).to.eql(Object.assign({}, sprintData, { members: ['Foo Bar'], issues: [issueResponse, issueResponse] })); 39 | }); 40 | 41 | it('describes a sprint by id', async () => { 42 | const sprint = await sprintActions.describe({ team: 'cats', id: sprintId }); 43 | expect(sprint).to.eql(Object.assign({}, sprintData, { members: ['Foo Bar'], issues: [issueResponse, issueResponse] })); 44 | }); 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-sync */ 2 | const mkdirp = require('mkdirp'); 3 | const os = require('os'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | const configDir = path.join(os.homedir(), '.jiractl'); 8 | const configFilePath = path.join(configDir, 'config.json'); 9 | const initialConfig = { 10 | contexts: {} 11 | }; 12 | 13 | function ensureConfig() { 14 | mkdirp.sync(configDir); 15 | if (!fs.existsSync(configFilePath)) { 16 | saveConfig(initialConfig); 17 | } 18 | } 19 | 20 | function loadConfig() { 21 | ensureConfig(); 22 | return require(configFilePath); 23 | } 24 | 25 | function saveConfig(config) { 26 | fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2)); 27 | } 28 | 29 | function addContext({ context, username, password, authmode }) { 30 | const config = loadConfig(); 31 | config.contexts[context] = { 32 | uri: context, 33 | username, 34 | password, 35 | authmode 36 | }; 37 | saveConfig(config); 38 | } 39 | 40 | function setCurrentContext(context) { 41 | const config = loadConfig(); 42 | if (!config.contexts[context]) { 43 | return new Error(`Invalid context "${context}" specified.`); 44 | } 45 | 46 | config.currentContext = context; 47 | saveConfig(config); 48 | } 49 | 50 | function getCurrentContext() { 51 | const config = loadConfig(); 52 | return config.contexts[config.currentContext]; 53 | } 54 | 55 | function addPoints({ context, points }) { 56 | const config = loadConfig(); 57 | config.contexts[context].points = points; 58 | saveConfig(config); 59 | } 60 | 61 | module.exports = { 62 | addContext, 63 | addPoints, 64 | ensureConfig, 65 | getCurrentContext, 66 | loadConfig, 67 | saveConfig, 68 | setCurrentContext 69 | }; 70 | -------------------------------------------------------------------------------- /src/sprints.actions.js: -------------------------------------------------------------------------------- 1 | const { getTeamId } = require('./team-data'); 2 | const Paginator = require('./data/paginator'); 3 | const jiraClient = require('./jira-client'); 4 | const { makeGetRequest } = jiraClient; 5 | 6 | async function getSprints({ team }, query = {}) { 7 | const teamId = getTeamId(team); 8 | const summary = await getVelocities(teamId); 9 | const velocities = summary.velocityStatEntries; 10 | 11 | const paginator = new Paginator({ 12 | async fetchPage(query) { 13 | // TODO: support other query string params (startAt, maxResults) 14 | // See: https://docs.atlassian.com/jira-software/REST/7.0.4/#agile/1.0/board/{boardId}/sprint 15 | 16 | // TODO: make state configurable 17 | const state = 'active,future,closed'; 18 | 19 | return await makeGetRequest(`board/${ teamId }/sprint`, 'agile/1.0', { 20 | query: { 21 | ...query, 22 | state 23 | } 24 | }); 25 | }, 26 | 27 | processResults(result) { 28 | // TODO: make this configurable 29 | return result.values.filter(sprint => sprint.originBoardId === teamId); 30 | } 31 | }); 32 | 33 | const sprints = await paginator.fetchAll(); 34 | return sprints.sort((a, b) => b.id - a.id) 35 | .map(sprint => Object.assign({}, sprint, { 36 | velocity: velocities[sprint.id] ? velocities[sprint.id].completed.value : 0, 37 | estimated: velocities[sprint.id] ? velocities[sprint.id].estimated.value : 0 38 | })); 39 | 40 | } 41 | 42 | async function getVelocities(teamId, query = {}) { 43 | const teamVelocities = await makeGetRequest('rapid/charts/velocity', 'greenhopper/1.0', { 44 | query: { 45 | rapidViewId: teamId, 46 | ...query 47 | } 48 | }); 49 | return teamVelocities; 50 | } 51 | 52 | async function describe({ team }) { 53 | return await getSprints({ team }); 54 | } 55 | 56 | module.exports = { 57 | create: () => {}, 58 | describe, 59 | get: describe, 60 | getSprints, 61 | getVelocities 62 | }; 63 | -------------------------------------------------------------------------------- /src/epics.actions.js: -------------------------------------------------------------------------------- 1 | const { getTeamId } = require('./team-data'); 2 | const { describe: describeEpic, status: statusEpic } = require('./epic.actions'); 3 | const Paginator = require('./data/paginator'); 4 | const jiraClient = require('./jira-client'); 5 | const { makeGetRequest } = jiraClient; 6 | 7 | async function getEpics({ team }) { 8 | const id = getTeamId(team); 9 | 10 | const paginator = new Paginator({ 11 | async fetchPage(query) { 12 | return await makeGetRequest(`board/${ id }/epic`, 'agile/1.0', { query }); 13 | }, 14 | 15 | processResults(result) { 16 | return result.values.filter(epic => !epic.done); 17 | } 18 | }); 19 | 20 | const epics = await paginator.fetchAll(); 21 | return { epics }; 22 | } 23 | 24 | 25 | /** 26 | * All epic details and corresponding stories for all epics associated to a team 27 | * w/ total and completed points 28 | * @param {string} team - The team alias or id, ie: "foo" or "1234" 29 | * @returns {obj} epicsAndStories - The team epics and their associated stories 30 | */ 31 | async function describeEpics({ team }) { 32 | const { epics } = await getEpics({ team }); 33 | const epicsAndStories = await Promise.all( 34 | epics.map(async epic => { 35 | const epicData = await describeEpic({ id: epic.key }); 36 | return epicData; 37 | }) 38 | ); 39 | 40 | return epicsAndStories; 41 | } 42 | 43 | /** 44 | * Simplified epic with total and completed points for all epics associated to a team 45 | * @param {string} team - The team alias or id, ie: "foo" or "1234" 46 | * @returns {obj} mappedEpics - The team epics and their associated additional details 47 | */ 48 | async function statusEpics({ team }) { 49 | const { epics } = await getEpics({ team }); 50 | const mappedEpics = await Promise.all( 51 | epics.map(async epic => statusEpic({ id: epic.key })) 52 | ); 53 | 54 | return mappedEpics; 55 | } 56 | 57 | module.exports = { 58 | get: getEpics, 59 | describe: describeEpics, 60 | status: statusEpics, 61 | create: () => {} 62 | }; 63 | -------------------------------------------------------------------------------- /test/formatters/table.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | 3 | const { columnDelimiter, formatHeader, formatBody, formatTable, parseBody } = require('../../src/formatters/table'); 4 | 5 | const testRows = [ 6 | { name: 'Bagels', color: 'orange' }, 7 | { name: 'Little', color: 'black' } 8 | ]; 9 | 10 | describe('src.formatters', () => { 11 | describe('.table', () => { 12 | describe('.formatHeader', () => { 13 | it('returns a single row', () => { 14 | const headers = formatHeader(testRows); 15 | expect(headers.split('\n')).has.length(1); 16 | }); 17 | }); 18 | describe('.formatBody', () => { 19 | it('returns expected number of rows', () => { 20 | const body = formatBody(testRows); 21 | expect(body.split('\n')).has.length(testRows.length); 22 | }); 23 | }); 24 | describe('.formatTable', () => { 25 | it('returns expected number of rows', () => { 26 | const table = formatTable(testRows); 27 | expect(table.split('\n')).has.length(testRows.length + 1); 28 | }); 29 | }); 30 | describe('.parseBody', () => { 31 | it('returns expected rows', () => { 32 | const body = `0,0${ columnDelimiter }0,1\n1,0${ columnDelimiter }1,1`; 33 | const rows = parseBody(body); 34 | expect(rows).to.deep.equal([ 35 | ['0,0', '0,1'], 36 | ['1,0', '1,1'] 37 | ]); 38 | }); 39 | it('ignores comments', () => { 40 | const body = `0,0${ columnDelimiter }0,1\n# comment\n1,0${ columnDelimiter }1,1\n# comment`; 41 | const rows = parseBody(body); 42 | expect(rows).to.deep.equal([ 43 | ['0,0', '0,1'], 44 | ['1,0', '1,1'] 45 | ]); 46 | }); 47 | it('ignores blank comments', () => { 48 | const body = `0,0${ columnDelimiter }0,1\n ${ columnDelimiter } \n1,0${ columnDelimiter }1,1`; 49 | const rows = parseBody(body); 50 | expect(rows).to.deep.equal([ 51 | ['0,0', '0,1'], 52 | ['1,0', '1,1'] 53 | ]); 54 | }); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@godaddy/jiractl", 3 | "version": "1.1.0", 4 | "description": "Jira command-line tool", 5 | "main": "index.js", 6 | "bin": { 7 | "jiractl": "./bin/cli.js" 8 | }, 9 | "scripts": { 10 | "lint": "eslint --config .eslintrc.js --fix src/ test/ bin/", 11 | "posttest": "npm run lint", 12 | "test": "npm run lint && mocha --recursive", 13 | "uninstall": "./bin/cli.js uninstall-completion" 14 | }, 15 | "completions": { 16 | "jiractl": [ 17 | "setup", 18 | "alias", 19 | "get", 20 | "describe", 21 | "update", 22 | "open" 23 | ], 24 | "setup": [], 25 | "alias": [], 26 | "get": [ 27 | "team", 28 | "teams", 29 | "sprint", 30 | "sprints", 31 | "epic", 32 | "epics", 33 | "issue", 34 | "issues" 35 | ], 36 | "describe": [ 37 | "team", 38 | "teams", 39 | "sprint", 40 | "sprints", 41 | "epic", 42 | "epics", 43 | "issue", 44 | "issues" 45 | ], 46 | "update": [ 47 | "issue" 48 | ], 49 | "open": [], 50 | "team": [], 51 | "teams": [], 52 | "sprint": [], 53 | "sprints": [], 54 | "epic": [], 55 | "epics": [], 56 | "issue": [], 57 | "issues": [] 58 | }, 59 | "repository": "godaddy/jiractl", 60 | "keywords": [ 61 | "cli", 62 | "command", 63 | "console", 64 | "jira", 65 | "manage", 66 | "shell" 67 | ], 68 | "author": "GoDaddy Operating Company, LLC", 69 | "license": "MIT", 70 | "dependencies": { 71 | "cli-table3": "^0.5.0", 72 | "diagnostics": "^2.0.2", 73 | "editor2": "^1.0.3", 74 | "make-promises-safe": "^5.1.0", 75 | "opn": "^6.0.0", 76 | "prompts": "^2.3.0", 77 | "request": "^2.88.0", 78 | "request-promise": "^4.2.2", 79 | "tabtab": "^3.0.2", 80 | "tmp": "0.1.0", 81 | "yargs": "^13.2.1" 82 | }, 83 | "devDependencies": { 84 | "chai": "^4.1.2", 85 | "eslint": "^5.14.1", 86 | "eslint-config-godaddy": "^3.0.0", 87 | "eslint-plugin-json": "^1.2.0", 88 | "eslint-plugin-mocha": "^5.0.0", 89 | "mocha": "^6.0.1", 90 | "nock": "^10.0.6", 91 | "proxyquire": "^2.0.1", 92 | "rewire": "^4.0.1", 93 | "sinon": "^7.2.4" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/config.actions.js: -------------------------------------------------------------------------------- 1 | const prompts = require('prompts'); 2 | const diagnostics = require('diagnostics'); 3 | const jiraClient = require('./jira-client'); 4 | const { getSessionCookie, makeGetRequest } = jiraClient; 5 | const config = require('./config'); 6 | const { addContext, addPoints, setCurrentContext, getCurrentContext } = config; 7 | const debug = diagnostics('jiractl:config'); 8 | 9 | async function getInput({ username, password, authmode }) { 10 | const inputs = await prompts([ 11 | !username && { 12 | type: 'text', 13 | name: 'username', 14 | message: 'Jira username?' 15 | }, 16 | !password && { 17 | type: 'password', 18 | name: 'password', 19 | message: 'Jira password?' 20 | 21 | }, 22 | !authmode && { 23 | type: 'toggle', 24 | name: 'authmode', 25 | message: 'Use HTTP Basic Auth?', 26 | initial: true, 27 | active: 'yes', 28 | inactive: 'no', 29 | format: basicauth => basicauth && 'basic' || 'cookie' 30 | } 31 | ].filter(Boolean)); 32 | 33 | return { 34 | username: username || inputs.username, 35 | password: password || inputs.password, 36 | authmode: authmode || inputs.authmode 37 | }; 38 | } 39 | 40 | async function setContext({ id, username, password, authmode }) { 41 | const context = id; 42 | let defaultContext; 43 | 44 | ({ username, password, authmode } = await getInput({ username, password, authmode })); 45 | 46 | debug('Set context: %j', { username, authmode }); 47 | if (authmode === 'cookie') { 48 | await getSessionCookie({ baseUri: context, username, password }); 49 | } 50 | 51 | addContext({ context, username, password, authmode }); 52 | 53 | if (!getCurrentContext()) { 54 | defaultContext = context; 55 | setCurrentContext(context); 56 | } 57 | 58 | const points = await getEstimator(); 59 | addPoints({ context, points }); 60 | 61 | return { context, username, password, authmode, points, defaultContext }; 62 | } 63 | 64 | async function getEstimator() { 65 | let points; 66 | const fields = await makeGetRequest('field', 'api/2'); 67 | const pointsField = fields.filter(field => field.name === 'Story Points'); 68 | if (pointsField.length) { 69 | points = pointsField[0].id; 70 | } else { 71 | throw new Error('No points field configured'); 72 | } 73 | return points; 74 | } 75 | 76 | module.exports = { 77 | 'set-context': setContext, 78 | getEstimator 79 | }; 80 | -------------------------------------------------------------------------------- /test/sprints.actions.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const proxyquire = require('proxyquire'); 3 | 4 | const team = 1; 5 | const sprintsData = { 6 | maxResults: 50, 7 | startAt: 0, 8 | isLast: true, 9 | values: [{ 10 | id: 1, 11 | self: 'https://jira.com/rest/agile/1.0/sprint/1', 12 | state: 'active', 13 | name: 'Cats 4/23-5/4', 14 | startDate: '2018-04-23', 15 | endDate: '2018-05-05', 16 | originBoardId: team, 17 | goal: '' 18 | }, { 19 | id: 2, 20 | self: 'https://jira.com/rest/agile/1.0/sprint/2', 21 | state: 'closed', 22 | name: 'Cats 4/9-4/20', 23 | startDate: '2018-04-09', 24 | endDate: '2018-04-20', 25 | originBoardId: team, 26 | goal: '' 27 | }] 28 | }; 29 | const velocityData = { 30 | sprints: [{ 31 | id: 1, 32 | sequence: 1, 33 | name: 'Cats 4/23-5/4', 34 | state: 'ACTIVE', 35 | goal: '', 36 | linkedPagesCount: 0 37 | }, { 38 | id: 2, 39 | sequence: 2, 40 | name: 'Cats 4/9-4/20', 41 | state: 'CLOSED', 42 | goal: '', 43 | linkedPagesCount: 0 44 | }], 45 | velocityStatEntries: { 46 | 2: { estimated: { value: 51, text: '51.0' }, 47 | completed: { value: 30, text: '30.0' } } 48 | } 49 | }; 50 | 51 | const clientStub = {}; 52 | const sprintsActions = proxyquire('../src/sprints.actions', { './jira-client': clientStub }); 53 | 54 | describe('src.sprints.actions', () => { 55 | 56 | beforeEach(() => { 57 | clientStub.makeGetRequest = (endpoint) => { 58 | return endpoint === `board/${ team }/sprint` ? sprintsData : velocityData; 59 | }; 60 | }); 61 | 62 | it('gets velocities for a team', async () => { 63 | const velocities = await sprintsActions.getVelocities(team); 64 | expect(velocities).to.eql(velocityData); 65 | }); 66 | 67 | it('gets sprints for a team', async () => { 68 | const sprints = await sprintsActions.get({ team }); 69 | const expected = [ 70 | Object.assign({ velocity: 30, estimated: 51 }, sprintsData.values[1]), 71 | Object.assign({ velocity: 0, estimated: 0 }, sprintsData.values[0]) 72 | ]; 73 | expect(sprints).to.eql(expected); 74 | }); 75 | 76 | it('describes sprints for a team', async () => { 77 | const sprints = await sprintsActions.describe({ team }); 78 | const expected = [ 79 | Object.assign({ velocity: 30, estimated: 51 }, sprintsData.values[1]), 80 | Object.assign({ velocity: 0, estimated: 0 }, sprintsData.values[0]) 81 | ]; 82 | expect(sprints).to.eql(expected); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/team-data.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-sync */ 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const { getTeams } = require('./teams.actions'); 5 | const { makeGetRequest } = require('./jira-client'); 6 | 7 | const orderFilters = /ORDER BY createdDate DESC|ORDER BY Rank ASC|ORDER BY Rank/gi; 8 | const mapPath = path.join(__dirname, '../.jiractl-team-map.json'); 9 | 10 | async function getBoardDetails(boardId) { 11 | return await makeGetRequest('rapidviewconfig/editmodel.json', 'greenhopper/1.0', { query: { rapidViewId: boardId } }); 12 | } 13 | 14 | async function writeTeamsData(id) { 15 | const teams = await getTeams({ id }); 16 | const detailsPromises = teams.map(async team => { 17 | const boardData = await getBoardDetails(team.id); 18 | // Remove any `order by rank` filters, since a different order filter will be applied when fetching epics. 19 | const epicFilter = (boardData ? boardData.filterConfig.query : '').replace(orderFilters, ''); 20 | return { 21 | board: team.id, 22 | name: team.name, 23 | epicFilter 24 | }; 25 | }); 26 | 27 | const teamDetails = await Promise.all(detailsPromises); 28 | let config = {}; 29 | try { 30 | config = JSON.parse(fs.readFileSync(mapPath).toString()); 31 | } catch (err) { 32 | if (err.code !== 'ENOENT') { 33 | throw err; 34 | } 35 | } 36 | 37 | teamDetails.forEach(team => { 38 | config[team.name] = { ...team }; 39 | }); 40 | fs.writeFileSync(mapPath, JSON.stringify(config, null, 2)); 41 | console.log(`Updated team-map with teams: ${ // eslint-disable-line no-console 42 | teamDetails.map(team => team.name).join(', ') }`); 43 | } 44 | 45 | function writeTeamAlias(teamName, alias) { 46 | const config = JSON.parse(fs.readFileSync(mapPath).toString()); 47 | const teamData = Object.assign({}, config[teamName]); 48 | config[alias] = teamData; 49 | fs.writeFileSync(mapPath, JSON.stringify(config, null, 2)); 50 | console.log(`Aliased ${ teamName } with ${ alias }:\n`, teamData); // eslint-disable-line no-console 51 | } 52 | 53 | function loadTeamMap() { 54 | try { 55 | return JSON.parse(fs.readFileSync(mapPath).toString()); 56 | } catch (err) { 57 | return {}; 58 | } 59 | } 60 | 61 | function getTeamId(teamName, teamMap = loadTeamMap()) { 62 | const team = teamMap[teamName]; 63 | return (team && team.board) ? team.board : teamName; 64 | } 65 | 66 | function getTeamComponent(teamName) { 67 | return loadTeamMap()[teamName].epicFilter; 68 | } 69 | 70 | module.exports = { 71 | getTeamComponent, 72 | getTeamId, 73 | writeTeamsData, 74 | writeTeamAlias 75 | }; 76 | -------------------------------------------------------------------------------- /test/epic.actions.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const proxyquire = require('proxyquire'); 3 | const sinon = require('sinon'); 4 | 5 | const { columnDelimiter } = require('../src/formatters/table'); 6 | 7 | function editEpic(args, dependencies) { 8 | const epicActions = proxyquire('../src/epic.actions', { 9 | './edit-contents': dependencies.editContents, 10 | './jira-client': { 11 | makeQuery: null, 12 | makeGetRequest: dependencies.makeGetRequest, 13 | makePutRequest: dependencies.makePutRequest 14 | } 15 | }); 16 | return epicActions.edit.action(args); 17 | } 18 | 19 | describe('src.epicActions', () => { 20 | describe('.edit', () => { 21 | it('does not try to update order if order has not changed', async () => { 22 | const id = 'FOO-123'; 23 | const editContents = () => { 24 | return `foo-key${ columnDelimiter }foo-status${ columnDelimiter }foo-summary`; 25 | }; 26 | const makeGetRequest = async () => ({ 27 | issues: [{ 28 | key: 'foo-key', 29 | fields: { 30 | status: { 31 | name: 'foo-status' 32 | }, 33 | summary: 'foo-summary' 34 | } 35 | }] 36 | }); 37 | const res = await editEpic({ id }, { editContents, makeGetRequest }); 38 | expect(res.message).to.equal(`No updates to Epic ${ id }`); 39 | }); 40 | 41 | it('updates issue order', async () => { 42 | const id = 'FOO-123'; 43 | const editContents = () => { 44 | return [`goo-key${ columnDelimiter }goo-status${ columnDelimiter }goo-summary`, 45 | `foo-key${ columnDelimiter }foo-status${ columnDelimiter }foo-summary`].join('\n'); 46 | }; 47 | const issueResponse = { 48 | issues: [{ 49 | key: 'foo-key', 50 | fields: { 51 | status: { 52 | name: 'foo-status' 53 | }, 54 | summary: 'foo-summary' 55 | } 56 | }, { 57 | key: 'goo-key', 58 | fields: { 59 | status: { 60 | name: 'goo-status' 61 | }, 62 | summary: 'goo-summary' 63 | } 64 | }] 65 | }; 66 | const makeGetRequest = async () => { return issueResponse; }; 67 | const makePutRequest = sinon.spy(async (api, version, rankDatum) => { 68 | expect(rankDatum.issues).has.length(1); 69 | if (rankDatum.issues[0] === 'goo-key') { 70 | expect(rankDatum.rankBeforeIssue).is.equal('foo-key'); 71 | } else if (rankDatum.issues[0] === 'foo-key') { 72 | expect(rankDatum.rankAfterIssue).is.equal('goo-key'); 73 | } else { 74 | expect(`Should not reach: ${ rankDatum }`).to.be.false; 75 | } 76 | }); 77 | const res = await editEpic({ id }, { editContents, makeGetRequest, makePutRequest }); 78 | expect(makePutRequest.calledTwice).to.be.true; 79 | expect(res.message).to.equal(`Updated Epic ${ id }`); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/formatters/table.js: -------------------------------------------------------------------------------- 1 | const Table = require('cli-table3'); 2 | 3 | const columnDelimiter = '\t'; 4 | 5 | const tableOptions = { 6 | chars: { 7 | 'top': '', 8 | 'top-mid': '', 9 | 'top-left': '', 10 | 'top-right': '', 11 | 'bottom': '', 12 | 'bottom-mid': '', 13 | 'bottom-left': '', 14 | 'bottom-right': '', 15 | 'left': '', 16 | 'left-mid': '', 17 | 'mid': '', 18 | 'mid-mid': '', 19 | 'right': '', 20 | 'right-mid': '', 21 | 'middle': columnDelimiter 22 | }, 23 | style: { 24 | 'padding-left': 0, 25 | 'padding-right': 0, 26 | 'head': [], 27 | 'border': [] 28 | } 29 | }; 30 | 31 | function getLengthPerColumn(rows = []) { 32 | const maxLengths = rows.reduce((lengths, item) => { 33 | const entries = Object.entries(item); 34 | entries.forEach(e => { 35 | if (!!e[1] && (!lengths[e[0]] || lengths[e[0]] < e[1].toString().length)) { 36 | lengths[e[0]] = e[1].toString().length; 37 | } 38 | }); 39 | 40 | return lengths; 41 | }, {}); 42 | Object.keys(maxLengths).forEach(header => { 43 | if (header.toString().length > maxLengths[header]) { 44 | maxLengths[header] = header.toString().length; 45 | } 46 | }); 47 | return maxLengths; 48 | } 49 | 50 | function formatHeader(rows) { 51 | const columns = getLengthPerColumn(rows); 52 | const table = new Table(Object.assign({ 53 | head: Object.keys(columns).map(col => col.toUpperCase()), 54 | colWidths: Object.values(columns) 55 | }, tableOptions)); 56 | return table.toString(); 57 | } 58 | 59 | function formatBody(rows) { 60 | const columns = getLengthPerColumn(rows); 61 | const table = new Table(Object.assign({ 62 | colWidths: Object.values(columns) 63 | }, tableOptions)); 64 | rows.forEach(item => table.push(Object.values(item))); 65 | return table.toString(); 66 | } 67 | 68 | /** 69 | * Format an array of rows into a CLI table. 70 | * 71 | * @example 72 | * formatTable([{name: 'Bagels', color: 'orange'}, {name: 'Little', color: 'black'}]); 73 | * 74 | * @param {Array} rows - Objects mapping column name to value. 75 | * @returns {String} Representation of the CLI table. 76 | */ 77 | function formatTable(rows) { 78 | return `${ formatHeader(rows) }\n${ formatBody(rows)}`; 79 | } 80 | 81 | /** 82 | * The inverse of `formatBody`: parse a string into rows and columns. 83 | * 84 | * @param {String} body - Table body. 85 | * @returns {Array} Array of rows, each row is represented by an array of column values. 86 | */ 87 | function parseBody(body) { 88 | return body 89 | .split('\n') 90 | .filter(line => !line.startsWith('#')) 91 | .map(line => line.split(columnDelimiter)) 92 | .map(values => { 93 | return values 94 | .map(value => value.trim()) 95 | .filter(value => value); 96 | }) 97 | .filter(values => values.length); 98 | } 99 | 100 | module.exports = { 101 | columnDelimiter, 102 | formatHeader, 103 | formatBody, 104 | formatTable, 105 | parseBody 106 | }; 107 | -------------------------------------------------------------------------------- /test/config.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const { promisify } = require('util'); 3 | const rewire = require('rewire'); 4 | const sinon = require('sinon'); 5 | const tmp = require('tmp'); 6 | 7 | const config = rewire('../src/config'); 8 | const context = 'https://jira.com'; 9 | const username = 'narf'; 10 | const password = 'larf'; 11 | const points = 'customfield_1'; 12 | const testConfig = { 13 | contexts: { 14 | [context]: { 15 | uri: context, 16 | username, 17 | password 18 | } 19 | } 20 | }; 21 | 22 | describe('src.config', () => { 23 | beforeEach(async () => { 24 | const file = promisify(tmp.file); 25 | const configFile = await file({ postfix: '.json' }); 26 | config.__set__({ 27 | configDir: '/tmp', 28 | configFilePath: configFile 29 | }); 30 | }); 31 | 32 | describe('.addContext', () => { 33 | it('adds a context', () => { 34 | config.saveConfig({ contexts: {} }); 35 | config.addContext({ context, username, password }); 36 | expect(config.loadConfig()).to.eql(testConfig); 37 | }); 38 | }); 39 | 40 | describe('.addPoints', () => { 41 | it('adds a points field to the specified context', () => { 42 | config.saveConfig(testConfig); 43 | const expected = { 44 | contexts: { 45 | [context]: { 46 | uri: context, 47 | username, 48 | password, 49 | points 50 | } 51 | } 52 | }; 53 | expect(config.loadConfig()).to.eql(testConfig); 54 | config.addPoints({ context, points }); 55 | expect(config.loadConfig()).to.eql(expected); 56 | }); 57 | }); 58 | 59 | describe('.getCurrentContext', () => { 60 | it('returns the current context', () => { 61 | config.saveConfig(testConfig); 62 | config.setCurrentContext(context); 63 | expect(config.getCurrentContext()).to.eql({ uri: context, username, password }); 64 | }); 65 | }); 66 | 67 | describe('.ensureConfig', () => { 68 | it('saves an initial config if one doesn\'t exist', () => { 69 | const saveConfig = sinon.spy(); 70 | config.__set__({ 71 | configDir: '/tmp', 72 | configFilePath: 'fake_file', 73 | saveConfig 74 | }); 75 | config.ensureConfig(); 76 | expect(saveConfig.calledOnce).to.be.true; 77 | expect(saveConfig.calledWithExactly({ contexts: {} })).to.be.true; 78 | }); 79 | 80 | it('doesn\'t save an initial config if a config already exists', () => { 81 | const saveConfig = sinon.spy(); 82 | config.__set__({ 83 | saveConfig 84 | }); 85 | config.ensureConfig(); 86 | expect(saveConfig.called).to.be.false; 87 | }); 88 | }); 89 | 90 | describe('.loadConfig', () => { 91 | it('calls ensureConfig and loads che config file', () => { 92 | const initialConfig = { contexts: {} }; 93 | config.saveConfig(initialConfig); 94 | const ensureConfig = sinon.spy(); 95 | config.__set__({ 96 | ensureConfig 97 | }); 98 | const result = config.loadConfig(); 99 | expect(ensureConfig.calledOnce).to.be.true; 100 | expect(result).to.eql(initialConfig); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/sprint.actions.js: -------------------------------------------------------------------------------- 1 | const { status: statusEpic } = require('./epic.actions'); 2 | const { getTeamId } = require('./team-data'); 3 | const client = require('./jira-client'); 4 | const { getCurrentContext } = require('./config'); 5 | 6 | 7 | async function describe({ team, id }) { 8 | const teamId = getTeamId(team); 9 | const sprint = await client.makeGetRequest(`sprint/${ id }`); 10 | const issues = await client.makeGetRequest(`board/${ teamId }/sprint/${ id }/issue`); 11 | const epics = await getIssueEpics(issues); 12 | 13 | const members = [...new Set(issues.issues.filter(issue => issue.fields.assignee).map( 14 | issue => issue.fields.assignee.displayName))]; 15 | 16 | return { 17 | id: sprint.id, 18 | name: sprint.name, 19 | startDate: sprint.startDate, 20 | endDate: sprint.endDate, 21 | state: sprint.state, 22 | issues: issues.issues, 23 | epics, 24 | members 25 | }; 26 | } 27 | 28 | /** 29 | * Returns the simplfied details for a single sprint. This will not output the 30 | * members or the issues (issues included in output for total & completed point values). 31 | * @param {string} team - The team alias or id 32 | * @param {string} id - The sprint id 33 | * @returns {array} sprintStatus - Summarized list of sprint details. 34 | */ 35 | async function status({ team, id }) { 36 | const teamId = getTeamId(team); 37 | const sprint = await client.makeGetRequest(`sprint/${ id }`); 38 | const issues = await client.makeGetRequest(`board/${ teamId }/sprint/${ id }/issue`); 39 | const epics = await getIssueEpics(issues); 40 | 41 | return { 42 | id: sprint.id, 43 | name: sprint.name, 44 | startDate: sprint.startDate, 45 | endDate: sprint.endDate, 46 | state: sprint.state, 47 | issues: issues.issues, 48 | epics 49 | }; 50 | } 51 | 52 | /** 53 | * Takes a list of issues for a sprint, groups by epic, and outputs the summary of 54 | * distinct epics with their corresponding sprint, total and currently completed points. 55 | * @param {obj} epicIssues - The list of issues for a sprint. 56 | * @returns {array} issueEpics - Summarized epics with sprint, total and completed points. 57 | */ 58 | async function getIssueEpics(epicIssues) { 59 | const { points } = getCurrentContext(); 60 | // Summarize epic issues - distinct epics to total epic issue points in sprint & summary 61 | const epicSummary = epicIssues.issues.reduce(function (map, issue) { 62 | // TO-DO: handle error if issue does not have an epic 63 | const key = issue.fields.epic.key; 64 | const sprintPoints = +issue.fields[points]; 65 | 66 | map[key] = map[key] || {}; 67 | map[key].sprintPoints = (map[key].sprintPoints + sprintPoints) || sprintPoints; 68 | map[key].summary = issue.fields.epic.name || issue.fields.epic.fields.name; 69 | 70 | return map; 71 | }, {}); 72 | 73 | // final output object w/ all needed fields 74 | const issueEpics = await Promise.all( 75 | Object.entries(epicSummary).map(async function ([name, value]) { 76 | const epicData = await statusEpic({ id: name }); 77 | return { 78 | key: name, 79 | displayName: value.summary, 80 | points: value.sprintPoints, 81 | total: epicData.epics[0].totalPoints, 82 | completed: epicData.epics[0].completedPoints 83 | }; 84 | }) 85 | ); 86 | 87 | return issueEpics; 88 | } 89 | 90 | 91 | module.exports = { 92 | get: describe, 93 | describe, 94 | status, 95 | create: () => {} 96 | }; 97 | -------------------------------------------------------------------------------- /src/epic.actions.js: -------------------------------------------------------------------------------- 1 | const editContents = require('./edit-contents'); 2 | const { formatBody, parseBody } = require('./formatters/table'); 3 | const jiraClient = require('./jira-client'); 4 | const { makeQuery, makeGetRequest, makePutRequest } = jiraClient; 5 | const { getCompletedPoints, getTotalPoints } = require('./point-reducers'); 6 | 7 | async function getEpic({ id }) { 8 | const epics = await makeQuery({ 9 | jql: `key=${id}`, 10 | selector: results => results.issues 11 | }); 12 | return { 13 | epics 14 | }; 15 | } 16 | 17 | /** 18 | * All epic details and corresponding stories 19 | * w/ total and completed points 20 | * @param {string} id - The epic key, ie: "FOO-1234" 21 | * @returns {obj} epics, stories - The epic and associated stories 22 | */ 23 | async function describeEpic({ id }) { 24 | const [{ epics }, epicIssues] = await Promise.all([ 25 | getEpic({ id }), 26 | makeGetRequest(`epic/${id}/issue`)]); 27 | if (!epicIssues) { 28 | throw new Error(`No issues returned for: ${id}`); 29 | } 30 | 31 | const stories = epicIssues.issues; 32 | 33 | epics[0].totalPoints = getTotalPoints(stories); 34 | epics[0].completedPoints = getCompletedPoints(stories); 35 | 36 | return { 37 | epics, 38 | stories 39 | }; 40 | } 41 | 42 | /** 43 | * Simplified epic with total and completed points 44 | * @param {string} id - The epic key, ie: "FOO-1234" 45 | * @returns {obj} epic - The epic and additional details 46 | */ 47 | async function statusEpic({ id }) { 48 | const { epics = {} } = await describeEpic({ id }); 49 | return { epics }; 50 | } 51 | 52 | async function edit({ id }) { 53 | const issues = (await makeGetRequest(`epic/${ id }/issue`)).issues; 54 | const rows = issues.map(i => ({ 55 | key: i.key, 56 | status: i.fields.status.name, 57 | summary: i.fields.summary 58 | })); 59 | 60 | const editedIssues = parseBody(await editContents({ 61 | content: `${ formatBody(rows) }\n\n# Re-order issues to update priority`, 62 | prefix: 'jiractl-edit-', 63 | postfix: '.txt' 64 | })).map(values => ({ 65 | key: values[0] 66 | })); 67 | 68 | const updated = editedIssues.find((issue, index) => { 69 | if (issue.key !== rows[index].key) return true; 70 | }); 71 | if (!updated) return { message: `No updates to Epic ${ id }` }; 72 | 73 | const rankData = [{ 74 | issues: [editedIssues[0].key], 75 | rankBeforeIssue: editedIssues[1].key 76 | }]; 77 | for (let index = 1; index < editedIssues.length; index++) { 78 | rankData.push({ 79 | issues: [editedIssues[index].key], 80 | rankAfterIssue: editedIssues[index - 1].key 81 | }); 82 | } 83 | 84 | const errors = []; 85 | await Promise.all(rankData.map(async rankDatum => { 86 | const res = await makePutRequest('issue/rank', 'agile/1.0', rankDatum); 87 | if (res && res.entries[0].errors) { 88 | Array.prototype.push.apply(errors, res.entries[0].errors); 89 | } 90 | })); 91 | 92 | if (errors.length) { 93 | throw new Error(`Failed to update issue ordering: ${ errors }`); 94 | } 95 | return { message: `Updated Epic ${ id }` }; 96 | } 97 | 98 | module.exports = { 99 | get: getEpic, 100 | describe: describeEpic, 101 | status: statusEpic, 102 | edit: { 103 | action: edit, 104 | formatters: { 105 | console: result => console.log(result.message) // eslint-disable-line no-console 106 | } 107 | }, 108 | create: () => {} 109 | }; 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jiractl 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/godaddy/jiractl.svg)](https://greenkeeper.io/) 4 | 5 | A command-line tool for managing Jira. 6 | 7 | ## Install 8 | 9 | ``` 10 | npm i @godaddy/jiractl --global 11 | ``` 12 | 13 | Optionally enable autocomplete: 14 | 15 | ``` 16 | jiractl install-completion 17 | ``` 18 | 19 | ## Setup 20 | 21 | Add a context for the Jira instance that you use. E.g: 22 | 23 | ```console 24 | $ jiractl config set-context https://jira.yourteam.com 25 | Username: name 26 | Password: 27 | Context "https://jira.yourteam.com" created. 28 | Set default context to "https://jira.yourteam.com". 29 | ``` 30 | 31 | Add the teams in your project. E.g., for a project named FOO: 32 | ``` 33 | jiractl setup FOO 34 | ``` 35 | This will output the team names added. 36 | 37 | Some team names are difficult to type or remember on the command line; to alias a team that you use frequently, run: 38 | ``` 39 | jiractl alias [name] [alias] 40 | ``` 41 | 42 | e.g. 43 | ```console 44 | $ jiractl alias "Orange Cats" cats 45 | 46 | ``` 47 | Outputs: 48 | ``` 49 | Aliased Orange Cats with cats: 50 | { board: 1234, 51 | name: 'Orange Cats', 52 | epicFilter: [ epicFilter ] } 53 | ``` 54 | You can then run jiractl commands using `cats` as the team name. 55 | 56 | ## Example usage 57 | 58 | ``` 59 | jiractl --team=cats [action] [context] 60 | ``` 61 | 62 | ### Teams 63 | 64 | Get teams for a project: 65 | ```console 66 | $ jiractl get teams FOO 67 | ID TYPE NAME 68 | 1234 scrum Orange Cats 69 | 1111 scrum Sharks 70 | 5678 kanban Bats 71 | ``` 72 | 73 | Describe a team with velocity: 74 | ```console 75 | $ jiractl describe team 1111 76 | NAME ID TYPE 77 | Sharks 2593 scrum 78 | 79 | Velocity: 80 | ID NAME ESTIMATED COMPLETED DELTA 81 | 18465 Sharks 4/23 - 5/4 0 0 0 82 | 17071 Sharks 4/9 - 4/20 46 41 5 83 | 17047 Sharks 3/26 - 4/6 47 53 -6 84 | 17046 Sharks 3/12 - 3/23 41 34 7 85 | 86 | Current Sprint: Sharks 4/23 - 5/4 ID: 18465 87 | 88 | Backlog: 89 | 90 | KEY SUMMARY POINTS 91 | FOO-2911 As a shark ISBAT eat fish - 92 | FOO-2910 As a shark ISBAT eat crustaceans - 93 | FOO-2909 As a shark ISBAT participate in shark week 5 94 | ``` 95 | 96 | ### Sprints 97 | 98 | Get a team's sprints: 99 | 100 | ```console 101 | $ jiractl --team=1111 get sprints 102 | ID STATE NAME VELOCITY 103 | 18465 open Sharks 4/23-5/4 0 104 | 17071 closed Sharks 4/9-4/20 41 105 | 17047 closed Sharks 3/26-4/6 53 106 | 17046 closed Sharks 3/12-3/23 34 107 | ``` 108 | 109 | Describe a specific sprint: 110 | 111 | ```console 112 | $ jiractl --team=1111 describe sprint 18465 113 | ``` 114 | 115 | ### Epics 116 | 117 | Get a team's epics: 118 | 119 | ```console 120 | $ jiractl --team=1111 get epics 121 | ``` 122 | 123 | Describe an epic: 124 | 125 | ```console 126 | $ jiractl describe epic EPIC-KEY 127 | ``` 128 | 129 | ### Issues 130 | 131 | Get an issue: 132 | 133 | ```console 134 | $ jiractl get issue ISSUE-KEY 135 | ``` 136 | 137 | Describe an issue: 138 | ```console 139 | $ jiractl describe issue ISSUE-KEY 140 | ``` 141 | 142 | Update an issue: 143 | ```console 144 | $ jiractl update issue ISSUE-KEY --points=8 145 | ``` 146 | 147 | Open an issue in the Jira UI: 148 | ```console 149 | $ jiractl open ISSUE-KEY 150 | ``` 151 | -------------------------------------------------------------------------------- /test/config.actions.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const proxyquire = require('proxyquire'); 3 | const sinon = require('sinon'); 4 | 5 | const context = 'jira.yourteam.com'; 6 | const username = 'narf'; 7 | const password = 'larf'; 8 | const points = 'customfield_1'; 9 | 10 | function stubbedActions(stubs) { 11 | const makeGetRequest = () => { return [{ id: points, name: 'Story Points' }]; }; 12 | const configActions = proxyquire('../src/config.actions', { 13 | './jira-client': { 14 | makeGetRequest: stubs.makeGetRequest || makeGetRequest, 15 | getSessionCookie: () => {} 16 | }, 17 | './config': { 18 | addContext: stubs.addContext || (() => {}), 19 | addPoints: () => { return { points }; }, 20 | getCurrentContext: stubs.getCurrentContext || (() => { return false; }), 21 | setCurrentContext: stubs.setCurrentContext || (() => {}) 22 | } 23 | }); 24 | return configActions; 25 | } 26 | 27 | describe('src.config.actions', () => { 28 | 29 | describe('.set-context', () => { 30 | it('adds and sets the current context', async () => { 31 | const addContext = sinon.spy(); 32 | const setCurrentContext = sinon.spy(); 33 | const result = await stubbedActions({ 34 | addContext, 35 | setCurrentContext })['set-context']({ id: context, password, username }); 36 | expect(addContext.calledOnce).to.be.true; 37 | expect(setCurrentContext.calledOnce).to.be.true; 38 | expect(result).to.eql({ context, defaultContext: context, password, points, username }); 39 | }); 40 | 41 | it('doesn\'t set the current context if it\'s already set', async () => { 42 | const addContext = sinon.spy(); 43 | const getCurrentContext = () => { return true; }; 44 | const setCurrentContext = sinon.spy(); 45 | const result = await stubbedActions({ 46 | addContext, 47 | getCurrentContext, 48 | setCurrentContext })['set-context']({ id: context, password, username }); 49 | let defaultContext; 50 | expect(addContext.calledOnce).to.be.true; 51 | expect(setCurrentContext.called).to.be.false; 52 | expect(result).to.eql({ context, defaultContext, password, points, username }); 53 | }); 54 | 55 | it('throws an error when the points field is missing', async () => { 56 | const makeGetRequest = () => { return []; }; 57 | let error; 58 | try { 59 | await stubbedActions({ makeGetRequest })['set-context']({ id: context, password, username }); 60 | } catch (err) { 61 | error = err; 62 | } 63 | expect(error.message).to.equal('No points field configured'); 64 | }); 65 | }); 66 | 67 | describe('.getEstimator', () => { 68 | it('returns the points field when one is configured', async () => { 69 | const result = await stubbedActions({}).getEstimator(); 70 | expect(result).to.eql(points); 71 | }); 72 | 73 | it('throws an error when makeGetRequest returns no results', async () => { 74 | const makeGetRequest = () => { return; }; 75 | let error; 76 | try { 77 | await stubbedActions({ makeGetRequest }).getEstimator(); 78 | } catch (err) { 79 | error = err; 80 | } 81 | expect(error.message).to.equal('Cannot read property \'filter\' of undefined'); 82 | }); 83 | 84 | it('throws an error when no points field is configured', async () => { 85 | const makeGetRequest = () => { return []; }; 86 | let error; 87 | try { 88 | await stubbedActions({ makeGetRequest }).getEstimator(); 89 | } catch (err) { 90 | error = err; 91 | } 92 | expect(error.message).to.equal('No points field configured'); 93 | }); 94 | 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/issue.response.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | expand: 'renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations', 3 | id: '824493', 4 | self: 'https://jira.com/rest/agile/1.0/issue/824493', 5 | key: 'GX-10209', 6 | fields: 7 | { assignee: 8 | { self: 'https://jira.com/rest/api/2/user?username=foo', 9 | name: 'foo', 10 | key: 'foo', 11 | emailAddress: 'foo@godaddy.com', 12 | avatarUrls: {}, 13 | displayName: 'Foo Bar', 14 | active: true, 15 | timeZone: 'Etc/GMT+7' }, 16 | components: [{}, {}], 17 | subtasks: [], 18 | reporter: 19 | { self: 'https://jira.com/rest/api/2/user?username=bar', 20 | name: 'bar', 21 | key: 'bar', 22 | emailAddress: 'bar@godaddy.com', 23 | avatarUrls: {}, 24 | displayName: 'Bar Foo', 25 | active: true, 26 | timeZone: 'Etc/GMT+7' }, 27 | closedSprints: [{ id: 18470, state: 'closed', name: 'Cats 4/9-4/20', originBoardId: 2802 }], 28 | progress: { progress: 0, total: 0 }, 29 | votes: 30 | { self: 'https://jira.com/rest/api/2/issue/GX-10209/votes', 31 | votes: 0, 32 | hasVoted: false }, 33 | worklog: { startAt: 0, maxResults: 20, total: 0, worklogs: [] }, 34 | issuetype: 35 | { self: 'https://jira.com/rest/api/2/issuetype/8', 36 | id: '8', 37 | description: 'Created by JIRA Software - do not edit or delete. Issue type for a user story.', 38 | iconUrl: 'https://jira.com/secure/viewavatar?size=xsmall&avatarId=15015&avatarType=issuetype', 39 | name: 'Story', 40 | subtask: false, 41 | avatarId: 15015 }, 42 | sprint: 43 | { id: 18471, 44 | self: 'https://jira.com/rest/agile/1.0/sprint/18471', 45 | state: 'active', 46 | name: 'Cats 4/23-5/4', 47 | startDate: '2018-04-23T13:05:18.018-07:00', 48 | endDate: '2018-05-04T21:12:00.000-07:00', 49 | originBoardId: 2802, 50 | goal: '' }, 51 | project: 52 | { self: 'https://jira.com/rest/api/2/project/15802', 53 | id: '15802', 54 | key: 'GX', 55 | name: 'GoDaddy Experience', 56 | avatarUrls: {}, 57 | projectCategory: {} }, 58 | resolutiondate: null, 59 | watches: 60 | { self: 'https://jira.com/rest/api/2/issue/GX-123/watchers', 61 | watchCount: 0, 62 | isWatching: false }, 63 | updated: '2018-04-23T12:59:47.000-0700', 64 | description: 'AC:\r\n * A description of the issue', 65 | summary: 'Issue title', 66 | customfield_10004: 5, 67 | comment: { comments: [], maxResults: 0, total: 0, startAt: 0 }, 68 | epic: 69 | { id: 824489, 70 | key: 'GX-123', 71 | self: 'https://jira.com/rest/agile/1.0/epic/824489', 72 | name: 'Test Epic', 73 | summary: 'Test Epic', 74 | color: {}, 75 | done: false }, 76 | priority: 77 | { self: 'https://jira.com/rest/api/2/priority/5', 78 | iconUrl: 'https://jira.com/images/icons/priorities/trivial.svg', 79 | name: 'Trivial', 80 | id: '5' }, 81 | timeestimate: null, 82 | status: 83 | { self: 'https://jira.com/rest/api/2/status/3', 84 | description: 'This issue is being actively worked on at the moment by the assignee.', 85 | iconUrl: 'https://jira.com/images/icons/statuses/generic.png', 86 | name: 'In Progress', 87 | id: '3', 88 | statusCategory: {} }, 89 | aggregatetimeestimate: null, 90 | creator: 91 | { self: 'https://jira.com/rest/api/2/user?username=bar', 92 | name: 'bar', 93 | key: 'bar', 94 | emailAddress: 'bar@godaddy.com', 95 | avatarUrls: {}, 96 | displayName: 'Bar Foo', 97 | active: true, 98 | timeZone: 'Etc/GMT+7' }, 99 | created: '2018-02-13T18:53:02.000-0700' 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Make. Promises. Safe. 4 | require('make-promises-safe'); 5 | 6 | const configActions = require('../src/config.actions'); 7 | const epicActions = require('../src/epic.actions'); 8 | const epicsActions = require('../src/epics.actions'); 9 | const formatters = require('../src/formatters'); 10 | const { getCurrentContext } = require('../src/config'); 11 | const { writeTeamAlias, writeTeamsData } = require('../src/team-data'); 12 | const issueActions = require('../src/issue.actions'); 13 | const sprintActions = require('../src/sprint.actions'); 14 | const sprintsActions = require('../src/sprints.actions'); 15 | const teamActions = require('../src/team.actions'); 16 | const teamsActions = require('../src/teams.actions'); 17 | 18 | const debug = require('diagnostics')('jiractl:cli'); 19 | 20 | const opn = require('opn'); 21 | const tabtab = require('tabtab'); 22 | const argv = require('yargs') 23 | .usage('Usage: jiractl --team=orange-cats [action] [context]') 24 | .default('output', 'console') 25 | .alias('t', 'team') 26 | .alias('o', 'output') 27 | .argv; 28 | 29 | let action = argv._[0]; // get, update, describe 30 | let context = argv._[1]; // epic(s), sprint(s), team(s) 31 | const id = argv._[2]; // e.g. sprint id, project id 32 | argv.id = id; 33 | 34 | const handlers = { 35 | config: configActions, 36 | epic: epicActions, 37 | epics: epicsActions, 38 | issue: issueActions, 39 | sprint: sprintActions, 40 | sprints: sprintsActions, 41 | team: teamActions, 42 | teams: teamsActions 43 | }; 44 | 45 | function completion(env) { 46 | const completions = require('../package.json').completions; 47 | if (env.prev in completions) { 48 | tabtab.log(completions[env.prev]); 49 | } 50 | return; 51 | } 52 | 53 | async function main() { 54 | if (action === 'install-completion') { 55 | await tabtab.install({ name: 'jiractl', completer: 'jiractl' }); 56 | return; 57 | } else if (action === 'uninstall-completion') { 58 | await tabtab.uninstall({ name: 'jiractl' }); 59 | return; 60 | } else if (action === 'completion') { 61 | const env = tabtab.parseEnv(process.env); 62 | return completion(env); 63 | } 64 | 65 | if (action === 'open') { 66 | const itemKey = context; 67 | await opn(`${ getCurrentContext().uri }/browse/${ itemKey }`); 68 | return; 69 | } 70 | 71 | if (action === 'setup') { 72 | const project = context; 73 | await writeTeamsData(project); 74 | return; 75 | } 76 | 77 | if (action === 'alias') { 78 | const teamName = context; 79 | const alias = id; 80 | await writeTeamAlias(teamName, alias); 81 | return; 82 | } 83 | 84 | if (action === 'config') { 85 | context = argv._[0]; 86 | action = argv._[1]; 87 | } 88 | 89 | let handler = handlers[context][action]; 90 | if (typeof handler === 'function') { 91 | handler = { 92 | action: handler, 93 | formatters: { 94 | json: formatters.json[context], 95 | console: formatters.console[context], 96 | raw: formatters.raw 97 | } 98 | }; 99 | } 100 | 101 | debug('Starting CLI:', { context, action, handler, argv }); 102 | 103 | try { 104 | const output = await handler.action(argv); 105 | handler.formatters[argv.output](output, argv); 106 | } catch (error) { 107 | const format = formatters[argv.output].error || formatters[argv.output]; 108 | format({ error, context, action, id }); 109 | } 110 | } 111 | 112 | main() 113 | .catch(err => { 114 | if (err.statusCode) { 115 | console.error(`${err.name}: ${err.statusCode}`); 116 | // TODO: make this a debug log 117 | // console.error(err.message); 118 | } else { 119 | console.error(err); 120 | } 121 | 122 | process.exit(1); 123 | }); 124 | -------------------------------------------------------------------------------- /src/jira-client.js: -------------------------------------------------------------------------------- 1 | const { format, URL, URLSearchParams } = require('url'); 2 | const rp = require('request-promise'); 3 | const diagnostics = require('diagnostics'); 4 | const { getCurrentContext } = require('./config'); 5 | 6 | const debug = { 7 | http: diagnostics('jiractl:http'), 8 | verbose: diagnostics('jiractl:verbose') 9 | }; 10 | 11 | let sessionCookie; 12 | 13 | async function getRequestOptions() { 14 | const context = getCurrentContext(); 15 | const { authmode = 'cookie' } = context; 16 | const opts = { 17 | json: true, 18 | followAllRedirects: true, 19 | headers: {} 20 | }; 21 | 22 | if (authmode === 'cookie' && !sessionCookie) { 23 | sessionCookie = await getSessionCookie(); 24 | opts.headers.Cookie = sessionCookie; 25 | } else if (authmode === 'basic') { 26 | const { username, password } = context; 27 | const encoded = Buffer.from(`${username}:${password}`).toString('base64'); 28 | opts.headers.Authorization = `Basic ${encoded}`; 29 | } 30 | 31 | debug.verbose('HTTP Options', opts); 32 | return opts; 33 | } 34 | 35 | async function getSessionCookie({ 36 | baseUri = getCurrentContext().uri, 37 | username = getCurrentContext().username, 38 | password = getCurrentContext().password } = {} 39 | ) { 40 | const { session } = await rp({ 41 | method: 'POST', 42 | uri: makeJiraUri({ baseUri, uri: 'auth/1/session' }), 43 | body: { 44 | username, 45 | password 46 | }, 47 | json: true, 48 | followAllRedirects: true 49 | }); 50 | 51 | debug.verbose(`New JIRA Session: ${session.name}=${session.value}`); 52 | return `${session.name}=${session.value}`; 53 | } 54 | 55 | function makeJiraUri({ baseUri = getCurrentContext().uri, uri, query } = {}) { 56 | const fullUri = new URL(`/rest/${ uri }`, `${baseUri}`); 57 | if (query) { 58 | Object.entries(query).forEach(([key, value]) => { 59 | fullUri.searchParams.set(key, value); 60 | }); 61 | } 62 | 63 | const uriStr = format(fullUri); 64 | debug.verbose('Make URI', uriStr); 65 | return uriStr; 66 | } 67 | 68 | async function makeQuery({ jql, selector = (results) => results.total } = {}) { 69 | const { points } = getCurrentContext(); 70 | const opts = await getRequestOptions(); 71 | const uri = makeJiraUri({ uri: 'api/2/search' }); 72 | debug.http(`POST ${uri}`); 73 | 74 | try { 75 | // TODO: move to fetch from rp 76 | const response = await rp(Object.assign({}, opts, { 77 | method: 'POST', 78 | uri, 79 | body: { 80 | jql, 81 | startAt: 0, 82 | maxResults: 10000, 83 | fields: ['summary', 'status', 'assignee', 'description', points], 84 | expand: ['schema', 'names'] 85 | } 86 | })); 87 | return selector(response); 88 | } catch (err) { 89 | throw err; 90 | } 91 | } 92 | 93 | async function makeGetRequest(url, api = 'agile/1.0', options = {}) { 94 | const opts = await getRequestOptions(); 95 | const uri = makeJiraUri({ uri: `${api}/${url}`, query: options.query }); 96 | const fullOpts = Object.assign({}, opts, options, { 97 | method: 'GET', 98 | uri 99 | }); 100 | 101 | debug.http(`GET ${uri}`, fullOpts); 102 | 103 | try { 104 | return await rp(fullOpts); 105 | } catch (err) { 106 | // 107 | // TODO [#28]: Check x-seraph-loginreason and x-authentication-denied-reason headers 108 | // to see if a user is hellban from JIRA. 109 | // console.dir(err.response.headers); 110 | // 111 | throw err; 112 | } 113 | } 114 | 115 | async function makePutRequest(url, api = 'agile/1.0', data = {}) { 116 | const opts = await getRequestOptions(); 117 | const uri = makeJiraUri({ uri: `${api}/${url}` }); 118 | debug.http(`PUT ${uri}`); 119 | 120 | try { 121 | return await rp(Object.assign({}, opts, { 122 | method: 'PUT', 123 | uri, 124 | body: data 125 | })); 126 | } catch (err) { 127 | throw err; 128 | } 129 | } 130 | 131 | module.exports = { 132 | getRequestOptions, 133 | getSessionCookie, 134 | makeGetRequest, 135 | makePutRequest, 136 | makeQuery 137 | }; 138 | -------------------------------------------------------------------------------- /src/issue.actions.js: -------------------------------------------------------------------------------- 1 | const formatters = require('./formatters/issues'); 2 | const client = require('./jira-client'); 3 | const config = require('./config'); 4 | const missingField = '-'; 5 | 6 | async function getIssue(issueId) { 7 | return await client.makeGetRequest(`issue/${ issueId }`); 8 | } 9 | 10 | function formatSprintDetails(sprint, outputSprintId) { 11 | return outputSprintId ? `${sprint.id} - ${sprint.name}` : sprint.name; 12 | } 13 | 14 | function getSprintDetails(fields, outputSprintId) { 15 | const sprints = [fields.sprint, ...(fields.closedSprints || [])].filter(sprint => !!sprint); 16 | return sprints.length ? sprints.map(sprint => formatSprintDetails(sprint, outputSprintId)).join(', ') : missingField; 17 | } 18 | 19 | function getIssueAssignee(fields) { 20 | return fields.assignee ? fields.assignee.key : missingField; 21 | } 22 | 23 | function getEpic(fields, outputSummary) { 24 | if (fields.epic) { 25 | return outputSummary ? `${fields.epic.key} - ${fields.epic.summary}` : fields.epic.key; 26 | } 27 | return missingField; 28 | } 29 | 30 | async function describe({ id }) { 31 | const issue = await getIssue(id); 32 | const fields = issue.fields; 33 | const points = config.getCurrentContext().points; 34 | const issueDescription = { 35 | summary: fields.summary, 36 | description: fields.description, 37 | status: fields.status.name, 38 | creator: fields.creator.key, 39 | priority: fields.priority.name, 40 | epic: getEpic(fields, true), 41 | sprint: getSprintDetails(fields, true), 42 | type: fields.issuetype.name, 43 | assignee: getIssueAssignee(fields), 44 | id: issue.id, 45 | key: issue.key, 46 | points: fields[points] 47 | }; 48 | return issueDescription; 49 | } 50 | 51 | async function get({ id }) { 52 | const issue = await getIssue(id); 53 | const fields = issue.fields; 54 | const points = config.getCurrentContext().points; 55 | 56 | return { 57 | summary: fields.summary, 58 | status: fields.status.name, 59 | epic: getEpic(fields), 60 | sprint: getSprintDetails(fields), 61 | assignee: getIssueAssignee(fields), 62 | key: issue.key, 63 | points: fields[points] 64 | }; 65 | } 66 | 67 | const bulkUpdateFields = ['summary', 'points']; 68 | const supportedUpdateFields = bulkUpdateFields.concat(['assignee']); 69 | 70 | async function update(args) { 71 | const id = args.id; 72 | 73 | // To see keys that can be updated: 74 | // await makeGetRequest(`issue/${ id }/editmeta`, api='api/2'); 75 | if (!Object.keys(args).some(key => supportedUpdateFields.includes(key))) { 76 | throw new Error(`Editable fields are ${ supportedUpdateFields.join(', ') }`); 77 | } 78 | 79 | let outputData = {}; 80 | if (args.assignee) { 81 | await updateAssignee({ id, assignee: args.assignee }); 82 | outputData.assignee = args.assignee; 83 | } 84 | 85 | const updateData = {}; 86 | Object.keys(args).forEach(arg => { 87 | if (bulkUpdateFields.includes(arg)) { 88 | updateData[arg] = args[arg]; 89 | } 90 | }); 91 | 92 | outputData = Object.assign(outputData, updateData); 93 | if (typeof updateData.points !== 'undefined') { 94 | updateData[config.getCurrentContext().points] = updateData.points; 95 | delete updateData.points; 96 | } 97 | 98 | await client.makePutRequest(`issue/${ id }`, 'api/2', { fields: updateData }); 99 | 100 | return outputData; 101 | } 102 | 103 | async function updateAssignee({ id, assignee }) { 104 | return await client.makePutRequest(`issue/${ id }/assignee`, 'api/2', { name: assignee }); 105 | } 106 | 107 | module.exports = { 108 | create: () => {}, 109 | describe: { 110 | action: describe, 111 | formatters: { 112 | console: formatters.console.describe, 113 | json: formatters.json.default 114 | } 115 | }, 116 | get: { 117 | action: get, 118 | formatters: { 119 | console: formatters.console.get, 120 | json: formatters.json.default 121 | } 122 | }, 123 | update: { 124 | action: update, 125 | formatters: { 126 | console: formatters.console.update, 127 | json: formatters.json.default 128 | } 129 | }, 130 | getIssue 131 | }; 132 | -------------------------------------------------------------------------------- /test/jira-client.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const nock = require('nock'); 3 | const proxyquire = require('proxyquire'); 4 | 5 | const issueResponse = require('./issue.response'); 6 | const uri = 'https://jira.com'; 7 | const context = { 8 | uri, 9 | username: 'cookie', 10 | password: 'butter', 11 | points: 'customfield_1' 12 | }; 13 | const client = proxyquire('../src/jira-client', { 14 | './config': { getCurrentContext: () => (context) } 15 | }); 16 | 17 | describe('src.jira-client', () => { 18 | describe('.getSessionClient', () => { 19 | it('gets the session cookie', async () => { 20 | nock(uri) 21 | .post('/rest/auth/1/session') 22 | .reply(200, { session: { name: 'cookie', value: 'chocolate-chip' } }); 23 | const cookie = await client.getSessionCookie(context); 24 | expect(cookie).to.equal('cookie=chocolate-chip'); 25 | }); 26 | 27 | it('rethrows login failure errors from the Jira API', async () => { 28 | nock(uri) 29 | .post('/rest/auth/1/session') 30 | .reply(401, { errorMessages: ['Login failed'] }); 31 | let error; 32 | try { 33 | await client.getSessionCookie(context); 34 | } catch (err) { 35 | error = err; 36 | } 37 | expect(error.message).to.equal('Login failed'); 38 | }); 39 | 40 | it('throws request errors', async () => { 41 | nock(uri) 42 | .post('/rest/auth/1/session') 43 | .reply(404, { message: 'ENOTFOUND jira.foo.com' }); 44 | let error; 45 | try { 46 | await client.getSessionCookie(context); 47 | } catch (err) { 48 | error = err; 49 | } 50 | expect(error.message).to.equal('404 - {"message":"ENOTFOUND jira.foo.com"}'); 51 | }); 52 | }); 53 | 54 | describe('.getRequestOptions', () => { 55 | it('gets request options', async () => { 56 | nock(uri) 57 | .post('/rest/auth/1/session') 58 | .reply(200, { session: { name: 'cookie', value: 'chocolate-chip' } }); 59 | const options = await client.getRequestOptions(); 60 | expect(options).to.eql({ 61 | json: true, 62 | followAllRedirects: true, 63 | headers: { Cookie: 'cookie=chocolate-chip' } 64 | }); 65 | }); 66 | }); 67 | 68 | describe('.makeGetRequest', () => { 69 | beforeEach(() => { 70 | nock(uri) 71 | .post('/rest/auth/1/session') 72 | .reply(200, { session: { name: 'cookie', value: 'chocolate-chip' } }); 73 | }); 74 | 75 | it('makes a get request', async () => { 76 | nock(uri) 77 | .get('/rest/agile/1.0/issue/GX-123') 78 | .reply(200, issueResponse); 79 | const response = await client.makeGetRequest('issue/GX-123'); 80 | expect(response).to.eql(issueResponse); 81 | }); 82 | 83 | it('rethrows a get request error from the jira api', async () => { 84 | nock(uri) 85 | .get('/rest/agile/1.0/issue/GX-123') 86 | .reply(400, { errorMessages: ['something', 'went', 'wrong'] }); 87 | let error; 88 | try { 89 | await client.makeGetRequest('issue/GX-123'); 90 | } catch (err) { 91 | error = err; 92 | } 93 | expect(error.message).to.equal('something, went, wrong'); 94 | }); 95 | }); 96 | 97 | describe('.makePutRequest', () => { 98 | beforeEach(() => { 99 | nock(uri) 100 | .post('/rest/auth/1/session') 101 | .reply(200, { session: { name: 'cookie', value: 'chocolate-chip' } }); 102 | }); 103 | 104 | it('makes a put request', async () => { 105 | nock(uri) 106 | .put('/rest/agile/1.0/issue/GX-123') 107 | .reply(200, {}); 108 | const response = await client.makePutRequest('issue/GX-123'); 109 | expect(response).to.eql({}); 110 | }); 111 | 112 | it('rethrows a put request error from the jira api', async () => { 113 | nock(uri) 114 | .put('/rest/agile/1.0/issue/GX-123') 115 | .reply(400, { errorMessages: ['something', 'went', 'wrong'] }); 116 | let error; 117 | try { 118 | await client.makePutRequest('issue/GX-123'); 119 | } catch (err) { 120 | error = err; 121 | } 122 | expect(error.message).to.equal('something, went, wrong'); 123 | }); 124 | }); 125 | 126 | describe('.makeQuery', () => { 127 | beforeEach(() => { 128 | nock(uri) 129 | .post('/rest/auth/1/session') 130 | .reply(200, { session: { name: 'cookie', value: 'chocolate-chip' } }); 131 | }); 132 | 133 | it('makes a jql query and applies the selector', async () => { 134 | nock(uri) 135 | .post('/rest/api/2/search') 136 | .reply(200, { id: 111, issues: [1, 2] }); 137 | const response = await client.makeQuery({ jql: 'key=111', selector: results => results.issues }); 138 | expect(response).to.eql([1, 2]); 139 | }); 140 | 141 | it('rethrows a put request error from the jira api', async () => { 142 | nock(uri) 143 | .post('/rest/api/2/search') 144 | .reply(400, { errorMessages: ['something', 'went', 'wrong'] }); 145 | let error; 146 | try { 147 | await client.makeQuery({ jql: 'key=111', selector: results => results.issues }); 148 | } catch (err) { 149 | error = err; 150 | } 151 | expect(error.message).to.equal('something, went, wrong'); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/formatters/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { getCompletedPoints, getTotalPoints } = require('../point-reducers'); 3 | const { formatTable } = require('./table'); 4 | const { getCurrentContext } = require('../config'); 5 | 6 | function logTable(rows) { 7 | console.log(formatTable(rows)); 8 | } 9 | 10 | function jsonEpicsFormatter({ epics, stories }) { 11 | console.error('not fully implemented'); 12 | const finalJson = { 13 | epics: jsonIssueFormatter({ issues: epics }), 14 | stories: stories ? jsonIssueFormatter({ issues: stories }) : [] 15 | }; 16 | 17 | console.log(JSON.stringify(finalJson, null, 2)); 18 | } 19 | 20 | function jsonIssueFormatter({ issues }) { 21 | const formattedIssues = issues.map(i => ({ 22 | key: i.key, 23 | summary: i.fields.summary, 24 | status: i.fields.status.name 25 | })); 26 | 27 | return formattedIssues; 28 | } 29 | 30 | function jsonFormatter(object) { 31 | console.log(JSON.stringify(object, null, 2)); 32 | } 33 | 34 | function consoleEpicsFormatter(epicsAndStories) { 35 | if (Array.isArray(epicsAndStories)) { 36 | epicsAndStories.forEach(format); 37 | } else { 38 | format(epicsAndStories); 39 | } 40 | 41 | function format({ epics, stories }) { 42 | console.log('Epic:'); 43 | logTable(epics.map(epic => { 44 | const summary = { 45 | key: epic.key, 46 | summary: epic.summary || epic.fields.summary 47 | }; 48 | 49 | summary.Completed = epic.completedPoints || '-'; 50 | summary['Total points'] = epic.totalPoints || '-'; 51 | 52 | return summary; 53 | })); 54 | 55 | if (stories && stories.length) { 56 | console.log('\nStories:'); 57 | const points = getCurrentContext().points; 58 | 59 | logTable( 60 | stories.map(story => ({ 61 | key: story.key, 62 | status: story.fields.status.name, 63 | summary: story.fields.summary, 64 | points: story.fields[points] || '-', 65 | sprint: (story.fields.sprint ? story.fields.sprint.name : 'N/A') 66 | })) 67 | ); 68 | } 69 | } 70 | } 71 | 72 | function consoleSprintFormatter(sprint) { 73 | const points = getCurrentContext().points; 74 | logTable([{ 75 | name: sprint.name, 76 | startDate: sprint.startDate || 'Future Sprint', 77 | endDate: sprint.endDate || 'Future Sprint', 78 | ['Completed/Total points']: `${getCompletedPoints(sprint.issues)}/${getTotalPoints(sprint.issues)}` 79 | }]); 80 | console.log(); 81 | 82 | if (sprint.members) { 83 | console.log(`Members: ${sprint.members.join(', ')}`); 84 | console.log(); 85 | console.log('Issues:'); 86 | logTable(sprint.issues.map(i => ({ 87 | key: i.key, 88 | status: i.fields.status.name, 89 | summary: i.fields.summary, 90 | points: i.fields[points] || '-', 91 | epic: i.fields.epic.key 92 | }))); 93 | console.log(); 94 | } 95 | 96 | console.log(`Epic Summary:`); 97 | logTable(sprint.epics.map(i => ({ 98 | summary: i.displayName, 99 | epic: i.key, 100 | points: i.points, 101 | percent: ((i.completed / i.total) * 100).toFixed(2), 102 | completed: i.completed, 103 | total: i.total 104 | }))); 105 | } 106 | 107 | function consoleSprintsFormatter(sprints) { 108 | return logTable(sprints.map(s => ({ id: s.id, state: s.state, name: s.name, velocity: s.velocity }))); 109 | } 110 | 111 | function consoleTeamFormatter(team) { 112 | logTable([{ name: team.name, id: team.id, type: team.type }]); 113 | if (team.velocity) { 114 | console.log(); 115 | console.log('Velocity:'); 116 | logTable(team.velocity.map(s => ({ 117 | id: s.id, 118 | name: s.name, 119 | estimated: s.estimated.toString(), 120 | completed: s.completed.toString(), 121 | delta: (s.estimated - s.completed).toString() 122 | }))); 123 | } 124 | if (team.activeSprint) { 125 | console.log(); 126 | console.log('Current Sprint: ' + team.activeSprint.name + ' ID: ' + team.activeSprint.id); 127 | } 128 | if (team.backlog) { 129 | console.log(); 130 | console.log('Backlog: '); 131 | console.log(); 132 | logTable(team.backlog); 133 | } 134 | } 135 | 136 | function consoleTeamsFormatter(teams) { 137 | return logTable(teams.map(t => ({ id: t.id, type: t.type, name: t.name }))); 138 | } 139 | 140 | function consoleConfigFormatter(config) { 141 | if (config.error) { 142 | console.error(`Error while creating context "${config.context}".`); 143 | console.error(`Reason: ${config.error}`); 144 | return; 145 | } 146 | 147 | console.log(`Context "${config.context}" created.`); 148 | 149 | if (config.defaultContext) { 150 | console.log(`Set default context to "${config.context}".`); 151 | } 152 | } 153 | 154 | function jsonConfigFormatter(config) { 155 | console.log(JSON.stringify(config, null, 2)); 156 | } 157 | 158 | function consoleErrorFormatter({ error, context, action, id }) { 159 | console.log(`Received error "${ error.message }" when performing action ${ action } on context ${ context } with id ${ id }`); 160 | } 161 | 162 | function jsonErrorFormatter({ error, context, action, id }) { 163 | console.log(JSON.stringify({ error: error.message, id, context, action }, null, 2)); 164 | } 165 | 166 | 167 | const consoleFormatters = { 168 | config: consoleConfigFormatter, 169 | epics: consoleEpicsFormatter, 170 | epic: consoleEpicsFormatter, 171 | error: consoleErrorFormatter, 172 | sprint: consoleSprintFormatter, 173 | sprints: consoleSprintsFormatter, 174 | team: consoleTeamFormatter, 175 | teams: consoleTeamsFormatter 176 | }; 177 | 178 | const jsonFormatters = { 179 | config: jsonConfigFormatter, 180 | epics: jsonEpicsFormatter, 181 | epic: jsonEpicsFormatter, 182 | error: jsonErrorFormatter, 183 | sprint: jsonFormatter, 184 | sprints: jsonFormatter, 185 | team: jsonFormatter, 186 | teams: jsonFormatter 187 | }; 188 | 189 | const rawFormatter = function (output) { 190 | console.log(JSON.stringify(output, null, 2)); 191 | }; 192 | 193 | const formatters = { 194 | console: consoleFormatters, 195 | json: jsonFormatters, 196 | raw: rawFormatter 197 | }; 198 | 199 | module.exports = formatters; 200 | --------------------------------------------------------------------------------