From bb66a99a66af3838c8d5bf3ea2f6af09d2450004 Mon Sep 17 00:00:00 2001 From: Paul Melnikow Date: Mon, 2 Oct 2017 13:26:42 -0400 Subject: [PATCH] [GitHub] Issue and pull request detail and check state (#1114) This adds badges for Github issues and pull requests. You can display the state, title, username, number of comments, age, time since last update, and state of checks. Provides an endpoint the Shields CI can use to fetch PR titles for #979 and resolves #1011. --- lib/badge-data.js | 7 ++- lib/github-helpers.js | 19 ++++++ package.json | 3 +- server.js | 133 ++++++++++++++++++++++++++++++++++++++-- service-tests/github.js | 58 ++++++++++++++++-- try.html | 36 +++++++++++ 6 files changed, 246 insertions(+), 10 deletions(-) create mode 100644 lib/github-helpers.js diff --git a/lib/badge-data.js b/lib/badge-data.js index 9558aa01aa..864f9f707a 100644 --- a/lib/badge-data.js +++ b/lib/badge-data.js @@ -47,6 +47,10 @@ function makeColor(color) { } } +function makeColorB(defaultColor, overrides) { + return makeColor(overrides.colorB || defaultColor); +} + function makeLabel(defaultLabel, overrides) { return overrides.label || defaultLabel; } @@ -98,5 +102,6 @@ module.exports = { makeLabel, makeLogo, makeBadgeData, - makeColor + makeColor, + makeColorB }; diff --git a/lib/github-helpers.js b/lib/github-helpers.js new file mode 100644 index 0000000000..39aee7e4ac --- /dev/null +++ b/lib/github-helpers.js @@ -0,0 +1,19 @@ +'use strict'; + +const { colorScale } = require('./color-formatters'); + +function stateColor(s) { + return { open: '2cbe4e', closed: 'cb2431', merged: '6f42c1' }[s]; +} + +function checkStateColor(s) { + return { pending: 'dbab09', success: '2cbe4e', failure: 'cb2431', error: 'cb2431' }[s]; +} + +const commentsColor = colorScale([1, 3, 10, 25], undefined, true); + +module.exports = { + stateColor, + checkStateColor, + commentsColor +}; diff --git a/package.json b/package.json index 176e3b4e53..2a17e1b554 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,12 @@ "camp": "~16.2.3", "chrome-web-store-item-property": "~1.1.2", "dot": "~1.0.3", - "pretty-bytes": "^3.0.1", "gm": "^1.23.0", "json-autosave": "~1.1.2", + "lodash.countby": "^4.6.0", "moment": "^2.18.1", "pdfkit": "~0.8.0", + "pretty-bytes": "^3.0.1", "redis": "~2.6.2", "request": "~2.81.0", "semver": "~5.3.0", diff --git a/server.js b/server.js index 8b58e3304a..c0a4cdc24f 100644 --- a/server.js +++ b/server.js @@ -65,13 +65,14 @@ const { getAnalytics } = require('./lib/analytics'); const { - makeColor, + makeColorB, isValidStyle, isSixHex: sixHex, makeLabel: getLabel, makeLogo: getLogo, makeBadgeData: getBadgeData, } = require('./lib/badge-data'); +const countBy = require('lodash.countby'); const { handleRequest: cache, clearRequestCache @@ -107,6 +108,11 @@ const { getVscodeApiReqOptions, getVscodeStatistic } = require('./lib/vscode-badge-helpers'); +const { + stateColor: githubStateColor, + checkStateColor: githubCheckStateColor, + commentsColor: githubCommentsColor +} = require('./lib/github-helpers'); var semver = require('semver'); var serverStartTime = new Date((new Date()).toGMTString()); @@ -3461,6 +3467,125 @@ cache(function(data, match, sendBadge, request) { }); })); +// GitHub issue detail integration. +camp.route(/^\/github\/(?:issues|pulls)\/detail\/(s|title|u|label|comments|age|last-update)\/([^\/]+)\/([^\/]+)\/(\d+)\.(svg|png|gif|jpg|json)$/, +cache((queryParams, match, sendBadge, request) => { + const [, which, owner, repo, number, format] = match; + const uri = `${githubApiUrl}/repos/${owner}/${repo}/issues/${number}`; + const badgeData = getBadgeData('', queryParams); + if (badgeData.template === 'social') { + badgeData.logo = getLogo('github', queryParams); + } + githubAuth.request(request, uri, {}, (err, res, buffer) => { + if (err != null) { + badgeData.text[1] = 'inaccessible'; + sendBadge(format, badgeData); + return; + } + try { + const parsedData = JSON.parse(buffer); + const isPR = 'pull_request' in parsedData; + const noun = isPR ? 'pull request' : 'issue'; + badgeData.text[0] = getLabel(`${noun} ${parsedData.number}`, queryParams); + switch (which) { + case 's': { + const state = badgeData.text[1] = parsedData.state; + badgeData.colorscheme = null; + badgeData.colorB = makeColorB(githubStateColor(state), queryParams); + break; + } + case 'title': + badgeData.text[1] = parsedData.title; + break; + case 'u': + badgeData.text[0] = getLabel('author', queryParams); + badgeData.text[1] = parsedData.user.login; + break; + case 'label': + badgeData.text[0] = getLabel('label', queryParams); + badgeData.text[1] = parsedData.labels.map(i => i.name).join(' | '); + if (parsedData.labels.length === 1) { + badgeData.colorscheme = null; + badgeData.colorB = makeColorB(parsedData.labels[0].color, queryParams); + } + break; + case 'comments': { + badgeData.text[0] = getLabel('comments', queryParams); + const comments = badgeData.text[1] = parsedData.comments; + badgeData.colorscheme = null; + badgeData.colorB = makeColorB(githubCommentsColor(comments), queryParams); + break; + } + case 'age': + case 'last-update': { + const label = which === 'age' ? 'created' : 'updated'; + const date = which === 'age' ? parsedData.created_at : parsedData.updated_at; + badgeData.text[0] = getLabel(label, queryParams); + badgeData.text[1] = formatDate(date); + badgeData.colorscheme = ageColor(Date.parse(date)); + break; + } + default: + throw Error('Unreachable due to regex'); + } + sendBadge(format, badgeData); + } catch(e) { + badgeData.text[1] = 'invalid'; + sendBadge(format, badgeData); + } + }); +})); + +// GitHub pull request build status integration. +camp.route(/^\/github\/status\/(s|contexts)\/pulls\/([^\/]+)\/([^\/]+)\/(\d+)\.(svg|png|gif|jpg|json)$/, +cache((queryParams, match, sendBadge, request) => { + const [, which, owner, repo, number, format] = match; + const issueUri = `${githubApiUrl}/repos/${owner}/${repo}/pulls/${number}`; + const badgeData = getBadgeData('checks', queryParams); + if (badgeData.template === 'social') { + badgeData.logo = getLogo('github', queryParams); + } + githubAuth.request(request, issueUri, {}, (err, res, buffer) => { + if (err != null) { + badgeData.text[1] = 'inaccessible'; + sendBadge(format, badgeData); + return; + } + try { + const parsedData = JSON.parse(buffer); + const ref = parsedData.head.sha; + const statusUri = `${githubApiUrl}/repos/${owner}/${repo}/commits/${ref}/status`; + githubAuth.request(request, statusUri, {}, (err, res, buffer) => { + try { + const parsedData = JSON.parse(buffer); + const state = badgeData.text[1] = parsedData.state; + badgeData.colorscheme = null; + badgeData.colorB = makeColorB(githubCheckStateColor(state), queryParams); + switch(which) { + case 's': + badgeData.text[1] = state; + break; + case 'contexts': { + const counts = countBy(parsedData.statuses, 'state'); + badgeData.text[1] = Object.keys(counts).map(k => `${counts[k]} ${k}`).join(', '); + break; + } + default: + throw Error('Unreachable due to regex'); + } + sendBadge(format, badgeData); + } catch(e) { + badgeData.text[1] = 'invalid'; + sendBadge(format, badgeData); + } + }); + } catch(e) { + badgeData.text[1] = 'invalid'; + sendBadge(format, badgeData); + } + }); +})); + // GitHub forks integration. camp.route(/^\/github\/forks\/([^\/]+)\/([^\/]+)\.(svg|png|gif|jpg|json)$/, cache(function(data, match, sendBadge, request) { @@ -5837,7 +5962,7 @@ cache(function(data, match, sendBadge, request) { return; } var count = 0; - var color; + var color = '78bdf2'; for (var i = 0; i < cards.length; i++) { var cardMetadata = cards[i].githubMetadata; if (cardMetadata.labels && cardMetadata.labels.length > 0) { @@ -5850,10 +5975,10 @@ cache(function(data, match, sendBadge, request) { } } } - badgeData.text[0] = data.label || ghLabel; + badgeData.text[0] = getLabel(ghLabel, data); badgeData.text[1] = '' + count; badgeData.colorscheme = null; - badgeData.colorB = makeColor(data.colorB || color || '78bdf2'); + badgeData.colorB = makeColorB(color, data); sendBadge(format, badgeData); } catch(e) { badgeData.text[1] = 'invalid'; diff --git a/service-tests/github.js b/service-tests/github.js index 7d5a08eb43..4a44fd0356 100644 --- a/service-tests/github.js +++ b/service-tests/github.js @@ -6,6 +6,11 @@ const ServiceTester = require('./runner/service-tester'); const t = new ServiceTester({ id: 'github', title: 'Github' }); module.exports = t; +const validDateString = Joi.alternatives().try( + Joi.equal('today', 'yesterday'), + Joi.string().regex(/^last (sun|mon|tues|wednes|thurs|fri|satur)day$/), + Joi.string().regex(/^(january|february|march|april|may|june|july|august|september|october|november|december)( \d{4})?$/)); + t.create('License') .get('/license/badges/shields.json') .expectJSONTypes(Joi.object().keys({ @@ -335,10 +340,7 @@ t.create('commit activity (1 week)') t.create('last commit (recent)') .get('/last-commit/eslint/eslint.json') - .expectJSONTypes(Joi.object().keys({ - name: Joi.equal('last commit'), - value: Joi.string().regex(/^today|yesterday|last (?:sun|mon|tues|wednes|thurs|fri|satur)day/), - })); + .expectJSONTypes(Joi.object().keys({ name: 'last commit', value: validDateString })); t.create('last commit (ancient)') .get('/last-commit/badges/badgr.co.json') @@ -353,3 +355,51 @@ t.create('last commit (on branch)') name: Joi.equal('last commit'), value: Joi.equal('july 2013'), })); + +t.create('github issue state') + .get('/issues/detail/s/badges/shields/979.json') + .expectJSONTypes(Joi.object().keys({ + name: 'issue 979', + value: Joi.equal('open', 'closed'), + })); + +t.create('github issue title') + .get('/issues/detail/title/badges/shields/979.json') + .expectJSONTypes(Joi.object().keys({ + name: 'issue 979', + value: 'Github rate limits cause transient service test failures in CI', + })); + +t.create('github issue author') + .get('/issues/detail/u/badges/shields/979.json') + .expectJSONTypes(Joi.object().keys({ name: 'author', value: 'paulmelnikow' })); + +t.create('github issue label') + .get('/issues/detail/label/badges/shields/979.json') + .expectJSONTypes(Joi.object().keys({ + name: 'label', + value: Joi.equal('bug | developer-experience', 'developer-experience | bug'), + })); + +t.create('github issue comments') + .get('/issues/detail/comments/badges/shields/979.json') + .expectJSONTypes(Joi.object().keys({ + name: 'comments', + value: Joi.number().greater(15), + })); + +t.create('github issue age') + .get('/issues/detail/age/badges/shields/979.json') + .expectJSONTypes(Joi.object().keys({ name: 'created', value: validDateString })); + +t.create('github issue update') + .get('/issues/detail/last-update/badges/shields/979.json') + .expectJSONTypes(Joi.object().keys({ name: 'updated', value: validDateString })); + +t.create('github pull request check state') + .get('/status/s/pulls/badges/shields/1110.json') + .expectJSONTypes(Joi.object().keys({ name: 'checks', value: 'failure' })); + +t.create('github pull request check contexts') + .get('/status/contexts/pulls/badges/shields/1110.json') + .expectJSONTypes(Joi.object().keys({ name: 'checks', value: '1 failure' })); diff --git a/try.html b/try.html index c37d943bd9..da4aa04a63 100644 --- a/try.html +++ b/try.html @@ -880,6 +880,42 @@ Pixel-perfect   Retina-ready   Fast   Consistent   Hackable https://img.shields.io/github/issues-pr-raw/badges/shields/vendor-badge.svg + GitHub issue state: + + https://img.shields.io/github/issues/detail/badges/shields/979.svg + + GitHub issue title: + + https://img.shields.io/github/issues/detail/s/badges/shields/979.svg + + GitHub issue author: + + https://img.shields.io/github/issues/detail/u/badges/shields/979.svg + + GitHub issue label: + + https://img.shields.io/github/issues/detail/label/badges/shields/979.svg + + GitHub issue comments: + + https://img.shields.io/github/issues/detail/comments/badges/shields/979.svg + + GitHub issue age: + + https://img.shields.io/github/issues/detail/age/badges/shields/979.svg + + GitHub issue last update: + + https://img.shields.io/github/issues/detail/last-update/badges/shields/979.svg + + GitHub pull request check state: + + https://img.shields.io/github/status/s/pulls/badges/shields/1110.svg + + GitHub pull request check contexts: + + https://img.shields.io/github/status/contexts/pulls/badges/shields/1110.svg + GitHub contributors: https://img.shields.io/github/contributors/cdnjs/cdnjs.svg