diff --git a/services/github/github-common-fetch.js b/services/github/github-common-fetch.js index 7defc30804df5..e30a109e3cab1 100644 --- a/services/github/github-common-fetch.js +++ b/services/github/github-common-fetch.js @@ -24,9 +24,9 @@ const contentSchema = Joi.object({ encoding: Joi.equal('base64').required(), }).required() -async function fetchJsonFromRepo( +async function fetchRepoContent( serviceInstance, - { schema, user, repo, branch = 'master', filename } + { user, repo, branch = 'master', filename } ) { const errorMessages = errorMessagesFor( `repo not found, branch not found, or ${filename} missing` @@ -41,13 +41,32 @@ async function fetchJsonFromRepo( errorMessages, }) - let decoded try { - decoded = Buffer.from(content, 'base64').toString('utf-8') + return Buffer.from(content, 'base64').toString('utf-8') } catch (e) { throw new InvalidResponse({ prettyMessage: 'undecodable content' }) } - const json = serviceInstance._parseJson(decoded) + } else { + const url = `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${filename}` + return serviceInstance._request({ + url, + errorMessages, + }) + } +} + +async function fetchJsonFromRepo( + serviceInstance, + { schema, user, repo, branch = 'master', filename } +) { + if (serviceInstance.staticAuthConfigured) { + const buffer = await fetchRepoContent(serviceInstance, { + user, + repo, + branch, + filename, + }) + const json = serviceInstance._parseJson(buffer) return serviceInstance.constructor._validate(json, schema) } else { const url = `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${filename}` diff --git a/services/github/github-helpers.js b/services/github/github-helpers.js index 18430fdd3a92d..edf44ce9c7646 100644 --- a/services/github/github-helpers.js +++ b/services/github/github-helpers.js @@ -7,10 +7,9 @@ const { InvalidResponse, NotFound } = require('..') const documentation = `

If your GitHub badge errors, it might be because you hit GitHub's rate limits. -
You can increase Shields.io's rate limit by - going to this page to add - Shields as a GitHub application on your GitHub account. + adding the Shields GitHub + application using your GitHub account.

` diff --git a/services/github/github-pipenv.service.js b/services/github/github-pipenv.service.js new file mode 100644 index 0000000000000..eeeae1753df23 --- /dev/null +++ b/services/github/github-pipenv.service.js @@ -0,0 +1,202 @@ +'use strict' + +const { renderVersionBadge } = require('../version') +const { isLockfile, getDependencyVersion } = require('../pipenv-helpers') +const { addv } = require('../text-formatters') +const { ConditionalGithubAuthV3Service } = require('./github-auth-service') +const { fetchJsonFromRepo } = require('./github-common-fetch') +const { documentation: githubDocumentation } = require('./github-helpers') +const { NotFound } = require('..') + +const keywords = ['pipfile'] + +const documentation = ` +

+ Pipenv is a dependency + manager for Python which manages a + virtualenv for + projects. It adds/removes packages from your Pipfile as + you install/uninstall packages and generates the ever-important + Pipfile.lock, which can be checked in to source control + in order to produce deterministic builds. +

+ +

+ The GitHub Pipenv badges are intended for applications using Pipenv + which are hosted on GitHub. +

+ +

+ When Pipfile.lock is checked in, the GitHub Pipenv + locked dependency version badge displays the locked version of + a dependency listed in [packages] or + [dev-packages] (or any of their transitive dependencies). +

+ +

+ Usually a Python version is specified in the Pipfile, which + pipenv lock then places in Pipfile.lock. The + GitHub Pipenv Python version badge displays that version. +

+ +${githubDocumentation} +` + +class GithubPipenvLockedPythonVersion extends ConditionalGithubAuthV3Service { + static get category() { + return 'platform-support' + } + + static get route() { + return { + base: 'github/pipenv/locked/python-version', + pattern: ':user/:repo/:branch*', + } + } + + static get examples() { + return [ + { + title: 'GitHub Pipenv locked Python version', + pattern: ':user/:repo', + namedParams: { + user: 'metabolize', + repo: 'rq-dashboard-on-heroku', + }, + staticPreview: this.render({ version: '3.7' }), + documentation, + keywords, + }, + { + title: 'GitHub Pipenv locked Python version (branch)', + pattern: ':user/:repo/:branch', + namedParams: { + user: 'metabolize', + repo: 'rq-dashboard-on-heroku', + branch: 'master', + }, + staticPreview: this.render({ version: '3.7', branch: 'master' }), + documentation, + keywords, + }, + ] + } + + static get defaultBadgeData() { + return { + label: 'python', + } + } + + static render({ version, branch }) { + return renderVersionBadge({ + version, + tag: branch, + defaultLabel: 'python', + }) + } + + async handle({ user, repo, branch }) { + const { + _meta: { + requires: { python_version: version }, + }, + } = await fetchJsonFromRepo(this, { + schema: isLockfile, + user, + repo, + branch, + filename: 'Pipfile.lock', + }) + if (version === undefined) { + throw new NotFound({ prettyMessage: 'version not specified' }) + } + return this.constructor.render({ version, branch }) + } +} + +class GithubPipenvLockedDependencyVersion extends ConditionalGithubAuthV3Service { + static get category() { + return 'dependencies' + } + + static get route() { + return { + base: 'github/pipenv/locked/dependency-version', + pattern: ':user/:repo/:kind(dev)?/:packageName/:branch*', + } + } + + static get examples() { + return [ + { + title: 'GitHub Pipenv locked dependency version', + pattern: ':user/:repo/:kind(dev)?/:packageName', + namedParams: { + user: 'metabolize', + repo: 'rq-dashboard-on-heroku', + packageName: 'flask', + }, + staticPreview: this.render({ + dependency: 'flask', + version: '1.1.1', + }), + documentation, + keywords: ['python', ...keywords], + }, + { + title: 'GitHub Pipenv locked dependency version (branch)', + pattern: ':user/:repo/:kind(dev)?/:packageName/:branch', + namedParams: { + user: 'metabolize', + repo: 'rq-dashboard-on-heroku', + kind: 'dev', + packageName: 'black', + branch: 'master', + }, + staticPreview: this.render({ dependency: 'black', version: '19.3b0' }), + documentation, + keywords: ['python', ...keywords], + }, + ] + } + + static get defaultBadgeData() { + return { + label: 'dependency', + } + } + + static render({ dependency, version, ref }) { + return { + label: dependency, + message: version ? addv(version) : ref, + color: 'blue', + } + } + + async handle({ user, repo, kind, branch, packageName }) { + const lockfileData = await fetchJsonFromRepo(this, { + schema: isLockfile, + user, + repo, + branch, + filename: 'Pipfile.lock', + }) + const { version, ref } = getDependencyVersion({ + kind, + wantedDependency: packageName, + lockfileData, + }) + return this.constructor.render({ + dependency: packageName, + version, + ref, + }) + } +} + +module.exports = [ + GithubPipenvLockedPythonVersion, + GithubPipenvLockedDependencyVersion, +] diff --git a/services/github/github-pipenv.tester.js b/services/github/github-pipenv.tester.js new file mode 100644 index 0000000000000..88019391a8ff5 --- /dev/null +++ b/services/github/github-pipenv.tester.js @@ -0,0 +1,93 @@ +'use strict' + +const Joi = require('@hapi/joi') +const { ServiceTester } = require('../tester') +const { + isVPlusDottedVersionAtLeastOne, + isVPlusDottedVersionNClausesWithOptionalSuffix, +} = require('../test-validators') + +// e.g. v19.3b0 +const isBlackVersion = Joi.string().regex(/^v\d+(\.\d+)*(.*)?$/) +const isShortSha = Joi.string().regex(/[0-9a-f]{7}/) + +const t = (module.exports = new ServiceTester({ + id: 'GithubPipenv', + title: 'GithubPipenv', + pathPrefix: '/github/pipenv', +})) + +t.create('Locked Python version') + .get('/locked/python-version/metabolize/rq-dashboard-on-heroku.json') + .expectBadge({ + label: 'python', + message: isVPlusDottedVersionAtLeastOne, + }) + +t.create('Locked Python version (no pipfile.lock)') + .get('/locked/python-version/metabolize/react-flexbox-svg.json') + .expectBadge({ + label: 'python', + message: 'repo not found, branch not found, or Pipfile.lock missing', + }) + +t.create('Locked Python version (pipfile.lock has no python version)') + .get('/locked/python-version/fikovnik/ShiftIt.json') + .expectBadge({ + label: 'python', + message: 'version not specified', + }) + +t.create('Locked version of default dependency') + .get( + '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard.json' + ) + .expectBadge({ + label: 'rq-dashboard', + message: isVPlusDottedVersionNClausesWithOptionalSuffix, + }) + +t.create('Locked version of default dependency (branch)') + .get( + '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard/master.json' + ) + .expectBadge({ + label: 'rq-dashboard', + message: isVPlusDottedVersionNClausesWithOptionalSuffix, + }) + +t.create('Locked version of dev dependency') + .get( + '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black.json' + ) + .expectBadge({ + label: 'black', + message: isBlackVersion, + }) + +t.create('Locked version of dev dependency (branch)') + .get( + '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black/master.json' + ) + .expectBadge({ + label: 'black', + message: isBlackVersion, + }) + +t.create('Locked version of unknown dependency') + .get( + '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/i-made-this-up.json' + ) + .expectBadge({ + label: 'dependency', + message: 'dev dependency not found', + }) + +t.create('Locked version of VCS dependency') + .get( + '/locked/dependency-version/DemocracyClub/aggregator-api/dc-base-theme.json' + ) + .expectBadge({ + label: 'dc-base-theme', + message: isShortSha, + }) diff --git a/services/pipenv-helpers.js b/services/pipenv-helpers.js new file mode 100644 index 0000000000000..c77b7babf2e7a --- /dev/null +++ b/services/pipenv-helpers.js @@ -0,0 +1,58 @@ +'use strict' + +const Joi = require('@hapi/joi') +const { InvalidParameter } = require('.') + +const isDependency = Joi.alternatives( + Joi.object({ + version: Joi.string().required(), + }).required(), + Joi.object({ + ref: Joi.string().required(), + }).required() +) + +const isLockfile = Joi.object({ + _meta: Joi.object({ + requires: Joi.object({ + python_version: Joi.string(), + }).required(), + }).required(), + default: Joi.object().pattern(Joi.string().required(), isDependency), + develop: Joi.object().pattern(Joi.string().required(), isDependency), +}).required() + +function getDependencyVersion({ + kind = 'default', + wantedDependency, + lockfileData, +}) { + let dependenciesOfKind + if (kind === 'dev') { + dependenciesOfKind = lockfileData.develop + } else if (kind === 'default') { + dependenciesOfKind = lockfileData.default + } else { + throw Error(`Not very kind: ${kind}`) + } + + if (!(wantedDependency in dependenciesOfKind)) { + throw new InvalidParameter({ + prettyMessage: `${kind} dependency not found`, + }) + } + + const { version, ref } = dependenciesOfKind[wantedDependency] + + if (version) { + // Strip the `==` which is always present. + return { version: version.replace('==', '') } + } else { + return { ref: ref.substring(1, 8) } + } +} + +module.exports = { + isLockfile, + getDependencyVersion, +} diff --git a/services/pypi/pypi-python-versions.tester.js b/services/pypi/pypi-python-versions.tester.js index b55749d8a5e70..25552ab4f2306 100644 --- a/services/pypi/pypi-python-versions.tester.js +++ b/services/pypi/pypi-python-versions.tester.js @@ -3,7 +3,6 @@ const Joi = require('@hapi/joi') const t = (module.exports = require('../tester').createServiceTester()) -// These regexes are the same, but declared separately for clarity. const isPipeSeparatedPythonVersions = Joi.string().regex( /^([0-9]+\.[0-9]+(?: \| )?)+$/ )