diff --git a/services/jira/jira-base.js b/services/jira/jira-base.js new file mode 100644 index 0000000000000..d653916712740 --- /dev/null +++ b/services/jira/jira-base.js @@ -0,0 +1,28 @@ +'use strict' + +const BaseJsonService = require('../base-json') +const serverSecrets = require('../../lib/server-secrets') + +module.exports = class JiraBase extends BaseJsonService { + static get category() { + return 'issue-tracking' + } + + async fetch({ url, qs, schema, errorMessages }) { + const options = { qs } + + if (serverSecrets && serverSecrets.jira_username) { + options.auth = { + user: serverSecrets.jira_username, + pass: serverSecrets.jira_password, + } + } + + return this._requestJson({ + schema, + url, + options, + errorMessages, + }) + } +} diff --git a/services/jira/jira-issue.service.js b/services/jira/jira-issue.service.js index b1a26924c68b7..214a50c0da293 100644 --- a/services/jira/jira-issue.service.js +++ b/services/jira/jira-issue.service.js @@ -1,17 +1,49 @@ 'use strict' -const LegacyService = require('../legacy-service') -const { makeBadgeData: getBadgeData } = require('../../lib/badge-data') -const serverSecrets = require('../../lib/server-secrets') +const Joi = require('joi') +const JiraBase = require('./jira-base') -module.exports = class JiraIssue extends LegacyService { - static get category() { - return 'issue-tracking' +const schema = Joi.object({ + fields: Joi.object({ + status: Joi.object({ + name: Joi.string().required(), + statusCategory: Joi.object({ + colorName: Joi.string().required(), + }), + }).required(), + }).required(), +}).required() + +module.exports = class JiraIssue extends JiraBase { + static render({ issueKey, statusName, statusColor }) { + let color = 'lightgrey' + if (statusColor) { + // map JIRA status color names to closest shields color schemes + const colorMap = { + 'medium-gray': 'lightgrey', + green: 'green', + yellow: 'yellow', + brown: 'orange', + 'warm-red': 'red', + 'blue-gray': 'blue', + } + color = colorMap[statusColor] + } + return { + label: issueKey, + message: statusName, + color, + } + } + + static get defaultBadgeData() { + return { color: 'lightgrey', label: 'jira' } } static get route() { return { base: 'jira/issue', + pattern: ':protocol(http|https)/:hostAndPath(.+)/:issueKey', } } @@ -19,82 +51,44 @@ module.exports = class JiraIssue extends LegacyService { return [ { title: 'JIRA issue', - pattern: ':protocol/:hostAndPath+/:issueKey', + pattern: ':protocol/:hostAndPath/:issueKey', namedParams: { protocol: 'https', hostAndPath: 'issues.apache.org/jira', issueKey: 'KAFKA-2896', }, - staticPreview: { - label: 'kafka-2896', - message: 'Resolved', - color: 'green', - }, + staticPreview: this.render({ + issueKey: 'KAFKA-2896', + statusName: 'Resolved', + statusColor: 'green', + }), + keywords: ['jira', 'issue'], }, ] } - static registerLegacyRouteHandler({ camp, cache }) { - camp.route( - /^\/jira\/issue\/(http(?:s)?)\/(.+)\/([^/]+)\.(svg|png|gif|jpg|json)$/, - cache((data, match, sendBadge, request) => { - const protocol = match[1] // eg, https - const host = match[2] // eg, issues.apache.org/jira - const issueKey = match[3] // eg, KAFKA-2896 - const format = match[4] - - const options = { - method: 'GET', - json: true, - uri: `${protocol}://${host}/rest/api/2/issue/${encodeURIComponent( - issueKey - )}`, - } - if (serverSecrets && serverSecrets.jira_username) { - options.auth = { - user: serverSecrets.jira_username, - pass: serverSecrets.jira_password, - } - } - - // map JIRA color names to closest shields color schemes - const colorMap = { - 'medium-gray': 'lightgrey', - green: 'green', - yellow: 'yellow', - brown: 'orange', - 'warm-red': 'red', - 'blue-gray': 'blue', - } - - const badgeData = getBadgeData(issueKey, data) - request(options, (err, res, json) => { - if (err !== null) { - badgeData.text[1] = 'inaccessible' - sendBadge(format, badgeData) - return - } - try { - const jiraIssue = json - if (jiraIssue.fields && jiraIssue.fields.status) { - if (jiraIssue.fields.status.name) { - badgeData.text[1] = jiraIssue.fields.status.name // e.g. "In Development" - } - if (jiraIssue.fields.status.statusCategory) { - badgeData.colorscheme = - colorMap[jiraIssue.fields.status.statusCategory.colorName] || - 'lightgrey' - } - } else { - badgeData.text[1] = 'invalid' - } - sendBadge(format, badgeData) - } catch (e) { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - } - }) - }) - ) + async handle({ protocol, hostAndPath, issueKey }) { + // Atlassian Documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-api-2-issue-issueIdOrKey-get + const url = `${protocol}://${hostAndPath}/rest/api/2/issue/${encodeURIComponent( + issueKey + )}` + const json = await this.fetch({ + url, + schema, + errorMessages: { + 404: 'issue not found', + }, + }) + const issueStatus = json.fields.status + const statusName = issueStatus.name + let statusColor + if (issueStatus.statusCategory) { + statusColor = issueStatus.statusCategory.colorName + } + return this.constructor.render({ + issueKey, + statusName, + statusColor, + }) } } diff --git a/services/jira/jira-issue.tester.js b/services/jira/jira-issue.tester.js new file mode 100644 index 0000000000000..c772d233b9bfd --- /dev/null +++ b/services/jira/jira-issue.tester.js @@ -0,0 +1,95 @@ +'use strict' + +const t = (module.exports = require('../create-service-tester')()) +const jiraTestHelpers = require('./jira-test-helpers') + +t.create('live: unknown issue') + .get('/https/issues.apache.org/jira/notArealIssue-000.json') + .expectJSON({ name: 'jira', value: 'issue not found' }) + +t.create('live: known issue') + .get('/https/issues.apache.org/jira/kafka-2896.json') + .expectJSON({ name: 'kafka-2896', value: 'Resolved' }) + +t.create('http endpoint') + .get('/http/issues.apache.org/jira/foo-123.json') + .intercept(nock => + nock('http://issues.apache.org/jira/rest/api/2/issue') + .get(`/${encodeURIComponent('foo-123')}`) + .reply(200, { + fields: { + status: { + name: 'pending', + }, + }, + }) + ) + .expectJSON({ name: 'foo-123', value: 'pending' }) + +t.create('endpoint with port and path') + .get('/https/issues.apache.org:8000/jira/bar-345.json') + .intercept(nock => + nock('https://issues.apache.org:8000/jira/rest/api/2/issue') + .get(`/${encodeURIComponent('bar-345')}`) + .reply(200, { + fields: { + status: { + name: 'done', + }, + }, + }) + ) + .expectJSON({ name: 'bar-345', value: 'done' }) + +t.create('endpoint with port and no path') + .get('/https/issues.apache.org:8080/abc-123.json') + .intercept(nock => + nock('https://issues.apache.org:8080/rest/api/2/issue') + .get(`/${encodeURIComponent('abc-123')}`) + .reply(200, { + fields: { + status: { + name: 'under review', + }, + }, + }) + ) + .expectJSON({ name: 'abc-123', value: 'under review' }) + +t.create('endpoint with no port nor path') + .get('/https/issues.apache.org/test-001.json') + .intercept(nock => + nock('https://issues.apache.org/rest/api/2/issue') + .get(`/${encodeURIComponent('test-001')}`) + .reply(200, { + fields: { + status: { + name: 'in progress', + }, + }, + }) + ) + .expectJSON({ name: 'test-001', value: 'in progress' }) + +t.create('with auth') + .before(jiraTestHelpers.mockJiraCreds) + .get('/https/myprivatejira.com/secure-234.json') + .intercept(nock => + nock('https://myprivatejira.com/rest/api/2/issue') + .get(`/${encodeURIComponent('secure-234')}`) + // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ + user: jiraTestHelpers.user, + pass: jiraTestHelpers.pass, + }) + .reply(200, { + fields: { + status: { + name: 'in progress', + }, + }, + }) + ) + .finally(jiraTestHelpers.restore) + .expectJSON({ name: 'secure-234', value: 'in progress' }) diff --git a/services/jira/jira-sprint.service.js b/services/jira/jira-sprint.service.js index 1f21353231701..9e95017b3d4a0 100644 --- a/services/jira/jira-sprint.service.js +++ b/services/jira/jira-sprint.service.js @@ -1,8 +1,22 @@ 'use strict' -const LegacyService = require('../legacy-service') -const { makeBadgeData: getBadgeData } = require('../../lib/badge-data') -const serverSecrets = require('../../lib/server-secrets') +const Joi = require('joi') +const JiraBase = require('./jira-base') + +const schema = Joi.object({ + total: Joi.number(), + issues: Joi.array() + .items( + Joi.object({ + fields: Joi.object({ + resolution: Joi.object({ + name: Joi.string(), + }).allow(null), + }).required(), + }) + ) + .required(), +}).required() const documentation = `

@@ -12,14 +26,32 @@ const documentation = `

` -module.exports = class JiraSprint extends LegacyService { - static get category() { - return 'issue-tracking' +module.exports = class JiraSprint extends JiraBase { + static render({ numCompletedIssues, numTotalIssues }) { + const percentComplete = numTotalIssues + ? (numCompletedIssues / numTotalIssues) * 100 + : 0 + let color = 'orange' + if (numCompletedIssues === 0) { + color = 'red' + } else if (numCompletedIssues === numTotalIssues) { + color = 'brightgreen' + } + return { + label: 'completion', + message: `${percentComplete.toFixed(0)}%`, + color, + } + } + + static get defaultBadgeData() { + return { label: 'jira' } } static get route() { return { base: 'jira/sprint', + pattern: ':protocol(http|https)/:hostAndPath(.+)/:sprintId', } } @@ -27,80 +59,48 @@ module.exports = class JiraSprint extends LegacyService { return [ { title: 'JIRA sprint completion', - pattern: ':protocol/:host/:sprintId', + pattern: ':protocol/:hostAndPath/:sprintId', namedParams: { protocol: 'https', - host: 'jira.spring.io', + hostAndPath: 'jira.spring.io', sprintId: '94', }, - staticPreview: { - label: 'completion', - message: '96%', - color: 'orange', - }, + staticPreview: this.render({ + numCompletedIssues: 27, + numTotalIssues: 28, + }), documentation, + keywords: ['jira', 'sprint', 'issues'], }, ] } - static registerLegacyRouteHandler({ camp, cache }) { - camp.route( - /^\/jira\/sprint\/(http(?:s)?)\/(.+)\/([^/]+)\.(svg|png|gif|jpg|json)$/, - cache((data, match, sendBadge, request) => { - const protocol = match[1] // eg, https - const host = match[2] // eg, jira.spring.io - const sprintId = match[3] // eg, 94 - const format = match[4] // eg, png - - const options = { - method: 'GET', - json: true, - uri: `${protocol}://${host}/rest/api/2/search?jql=sprint=${sprintId}%20AND%20type%20IN%20(Bug,Improvement,Story,"Technical%20task")&fields=resolution&maxResults=500`, - } - if (serverSecrets && serverSecrets.jira_username) { - options.auth = { - user: serverSecrets.jira_username, - pass: serverSecrets.jira_password, - } - } + async handle({ protocol, hostAndPath, sprintId }) { + // Atlassian Documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Search + // There are other sprint-specific APIs but those require authentication. The search API + // allows us to get the needed data without being forced to authenticate. + const url = `${protocol}://${hostAndPath}/rest/api/2/search` + const qs = { + jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`, + fields: 'resolution', + maxResults: 500, + } + const json = await this.fetch({ + url, + schema, + qs, + errorMessages: { + 400: 'sprint not found', + 404: 'sprint not found', + }, + }) + const numTotalIssues = json.total + const numCompletedIssues = json.issues.filter(issue => { + if (issue.fields.resolution != null) { + return issue.fields.resolution.name !== 'Unresolved' + } + }).length - const badgeData = getBadgeData('completion', data) - request(options, (err, res, json) => { - if (err != null) { - badgeData.text[1] = 'inaccessible' - sendBadge(format, badgeData) - return - } - try { - if (json && json.total >= 0) { - const issuesDone = json.issues.filter(el => { - if (el.fields.resolution != null) { - return el.fields.resolution.name !== 'Unresolved' - } - }).length - badgeData.text[1] = `${Math.round( - (issuesDone * 100) / json.total - )}%` - switch (issuesDone) { - case 0: - badgeData.colorscheme = 'red' - break - case json.total: - badgeData.colorscheme = 'brightgreen' - break - default: - badgeData.colorscheme = 'orange' - } - } else { - badgeData.text[1] = 'invalid' - } - sendBadge(format, badgeData) - } catch (e) { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - } - }) - }) - ) + return this.constructor.render({ numTotalIssues, numCompletedIssues }) } } diff --git a/services/jira/jira-sprint.tester.js b/services/jira/jira-sprint.tester.js new file mode 100644 index 0000000000000..5a2f799e009d3 --- /dev/null +++ b/services/jira/jira-sprint.tester.js @@ -0,0 +1,150 @@ +'use strict' + +const Joi = require('joi') +const t = (module.exports = require('../create-service-tester')()) +const { isIntegerPercentage } = require('../test-validators') +const jiraTestHelpers = require('./jira-test-helpers') + +const sprintId = 8 +const queryString = { + jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`, + fields: 'resolution', + maxResults: 500, +} + +t.create('live: unknown sprint') + .get('/https/jira.spring.io/abc.json') + .expectJSON({ name: 'jira', value: 'sprint not found' }) + +t.create('live: known sprint') + .get('/https/jira.spring.io/94.json') + .expectJSONTypes( + Joi.object().keys({ + name: 'completion', + value: isIntegerPercentage, + }) + ) + +t.create('100% completion') + .get(`/http/issues.apache.org/jira/${sprintId}.json`) + .intercept(nock => + nock('http://issues.apache.org/jira/rest/api/2') + .get('/search') + .query(queryString) + .reply(200, { + total: 2, + issues: [ + { + fields: { + resolution: { + name: 'done', + }, + }, + }, + { + fields: { + resolution: { + name: 'completed', + }, + }, + }, + ], + }) + ) + .expectJSON({ name: 'completion', value: '100%' }) + +t.create('0% completion') + .get(`/http/issues.apache.org/jira/${sprintId}.json`) + .intercept(nock => + nock('http://issues.apache.org/jira/rest/api/2') + .get('/search') + .query(queryString) + .reply(200, { + total: 1, + issues: [ + { + fields: { + resolution: { + name: 'Unresolved', + }, + }, + }, + ], + }) + ) + .expectJSON({ name: 'completion', value: '0%' }) + +t.create('no issues in sprint') + .get(`/http/issues.apache.org/jira/${sprintId}.json`) + .intercept(nock => + nock('http://issues.apache.org/jira/rest/api/2') + .get('/search') + .query(queryString) + .reply(200, { + total: 0, + issues: [], + }) + ) + .expectJSON({ name: 'completion', value: '0%' }) + +t.create('issue with null resolution value') + .get(`/https/jira.spring.io:8080/${sprintId}.json`) + .intercept(nock => + nock('https://jira.spring.io:8080/rest/api/2') + .get('/search') + .query(queryString) + .reply(200, { + total: 2, + issues: [ + { + fields: { + resolution: { + name: 'done', + }, + }, + }, + { + fields: { + resolution: null, + }, + }, + ], + }) + ) + .expectJSON({ name: 'completion', value: '50%' }) + +t.create('with auth') + .before(jiraTestHelpers.mockJiraCreds) + .get(`/https/myprivatejira/jira/${sprintId}.json`) + .intercept(nock => + nock('https://myprivatejira/jira/rest/api/2') + .get('/search') + .query(queryString) + // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ + user: jiraTestHelpers.user, + pass: jiraTestHelpers.pass, + }) + .reply(200, { + total: 2, + issues: [ + { + fields: { + resolution: { + name: 'done', + }, + }, + }, + { + fields: { + resolution: { + name: 'Unresolved', + }, + }, + }, + ], + }) + ) + .finally(jiraTestHelpers.restore) + .expectJSON({ name: 'completion', value: '50%' }) diff --git a/services/jira/jira-test-helpers.js b/services/jira/jira-test-helpers.js new file mode 100644 index 0000000000000..463a6bdb601ba --- /dev/null +++ b/services/jira/jira-test-helpers.js @@ -0,0 +1,25 @@ +'use strict' + +const sinon = require('sinon') +const serverSecrets = require('../../lib/server-secrets') + +const user = 'admin' +const pass = 'password' + +function mockJiraCreds() { + serverSecrets['jira_username'] = undefined + serverSecrets['jira_password'] = undefined + sinon.stub(serverSecrets, 'jira_username').value(user) + sinon.stub(serverSecrets, 'jira_password').value(pass) +} + +function restore() { + sinon.restore() +} + +module.exports = { + user, + pass, + mockJiraCreds, + restore, +}