Skip to content

Commit

Permalink
Add [npm] badges for collaborator count and dependency version (#2461)
Browse files Browse the repository at this point in the history
This adds a badge for collaborator count. When evaluating a library, it can be useful to know that there's not a single-contributor bottleneck for publishing. Having more than one collaborator is a sign of library maturity.

It adds another badge for dependency version of published dependencies, which solves a similar problem as the node-version badge. I will find this useful for making sure dependencies are up to date in a library.
  • Loading branch information
paulmelnikow committed Dec 12, 2018
1 parent 38ca6fc commit a3a5252
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 9 deletions.
2 changes: 1 addition & 1 deletion services/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('loadServiceClasses function', function() {
).to.have.length(5)
})

it('can collect the service definitions', function() {
it('check the service definitions', function() {
// When this fails, it will throw AssertionErrors. Wrapping this in an
// `expect().not.to.throw()` makes the error output unreadable.
collectDefinitions()
Expand Down
25 changes: 17 additions & 8 deletions services/npm/npm-base.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
'use strict'

const Joi = require('joi')
const serverSecrets = require('../../lib/server-secrets')
const BaseJsonService = require('../base-json')
const { InvalidResponse, NotFound } = require('../errors')
const serverSecrets = require('../../lib/server-secrets')
const { semverRange } = require('../validators')

const deprecatedLicenseObjectSchema = Joi.object({
type: Joi.string().required(),
})
const dependencyMap = Joi.object()
.pattern(/./, semverRange)
.default({})
const schema = Joi.object({
devDependencies: Joi.object()
.pattern(/./, Joi.string())
.default({}),
dependencies: dependencyMap,
devDependencies: dependencyMap,
peerDependencies: dependencyMap,
engines: Joi.object().pattern(/./, Joi.string()),
license: Joi.alternatives().try(
Joi.string(),
Expand All @@ -20,6 +24,10 @@ const schema = Joi.object({
Joi.alternatives(Joi.string(), deprecatedLicenseObjectSchema)
)
),
maintainers: Joi.array()
// We don't need the keys here, just the length.
.items(Joi.object({}))
.required(),
types: Joi.string(),
files: Joi.array()
.items(Joi.string())
Expand All @@ -33,15 +41,15 @@ module.exports = class NpmBase extends BaseJsonService {
if (withTag) {
return {
base,
format: '(?:@([^/]+))?/?([^/]*)/?([^/]*)',
// The trailing optional means this has to be a regex.
format: '(?:(@[^/]+)/)?([^/]*)/?([^/]*)',
capture: ['scope', 'packageName', 'tag'],
queryParams: ['registry_uri'],
}
} else {
return {
base,
format: '(?:@([^/]+)/)?([^/]+)',
capture: ['scope', 'packageName'],
pattern: ':scope(@[^/]+)?/:packageName',
queryParams: ['registry_uri'],
}
}
Expand All @@ -60,8 +68,9 @@ module.exports = class NpmBase extends BaseJsonService {
}

static encodeScopedPackage({ scope, packageName }) {
const scopeWithoutAt = scope.replace(/^@/, '')
// e.g. https://registry.npmjs.org/@cedx%2Fgulp-david
const encoded = encodeURIComponent(`${scope}/${packageName}`)
const encoded = encodeURIComponent(`${scopeWithoutAt}/${packageName}`)
return `@${encoded}`
}

Expand Down
71 changes: 71 additions & 0 deletions services/npm/npm-collaborators.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use strict'

const NpmBase = require('./npm-base')

const keywords = ['node']

module.exports = class NpmCollaborators extends NpmBase {
static get category() {
return 'activity'
}

static get route() {
return this.buildRoute('npm/collaborators', { withTag: false })
}

static get examples() {
return [
{
title: 'npm collaborators',
pattern: ':packageName',
namedParams: { packageName: 'prettier' },
staticPreview: this.render({ collaborators: 6 }),
keywords,
},
{
title: 'npm collaborators',
pattern: ':packageName',
namedParams: { packageName: 'prettier' },
queryParams: { registry_uri: 'https://registry.npmjs.com' },
staticPreview: this.render({ collaborators: 6 }),
keywords,
},
]
}

static get defaultBadgeData() {
return {
label: 'npm collaborators',
}
}

static render({ collaborators }) {
let color
if (collaborators > 2) {
color = 'blue'
} else if (collaborators === 2) {
color = 'yellow'
} else {
color = 'red'
}

return {
message: collaborators,
color,
}
}

async handle(namedParams, queryParams) {
const { scope, packageName, registryUrl } = this.constructor.unpackParams(
namedParams,
queryParams
)
const { maintainers } = await this.fetchPackageData({
scope,
packageName,
registryUrl,
})
const collaborators = maintainers.length
return this.constructor.render({ collaborators })
}
}
24 changes: 24 additions & 0 deletions services/npm/npm-collaborators.tester.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict'

const Joi = require('joi')
const { nonNegativeInteger } = require('../validators')
const t = (module.exports = require('../create-service-tester')())

t.create('gets the contributor count')
.get('/prettier.json')
.expectJSONTypes(
Joi.object({ name: 'npm collaborators', value: nonNegativeInteger })
)

t.create('gets the contributor count from a custom registry')
.get('/prettier.json?registry_uri=https://registry.npmjs.com')
.expectJSONTypes(
Joi.object({ name: 'npm collaborators', value: nonNegativeInteger })
)

t.create('contributor count for unknown package')
.get('/npm-registry-does-not-have-this-package.json')
.expectJSON({
name: 'npm collaborators',
value: 'package not found',
})
135 changes: 135 additions & 0 deletions services/npm/npm-dependency-version.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
'use strict'

const { InvalidParameter } = require('../errors')
const NpmBase = require('./npm-base')

const keywords = ['node']

module.exports = class NpmDependencyVersion extends NpmBase {
static get category() {
return 'platform-support'
}

static get route() {
const { queryParams } = this.buildRoute('')
return {
base: 'npm/dependency-version',
pattern: ':scope(@[^/]+)?/:packageName/:kind(dev|peer)?/:dependency',
queryParams,
}
}

static get examples() {
return [
{
title: 'npm peer dependency version',
pattern: ':packageName/peer/:dependency',
namedParams: {
packageName: 'react-boxplot',
dependency: 'prop-types',
},
staticPreview: this.render({
dependency: 'prop-types',
range: '^15.5.4',
}),
keywords,
},
{
title: 'npm dev dependency version',
pattern: ':packageName/dev/:dependency',
namedParams: {
packageName: 'react-boxplot',
kind: 'dev',
dependency: 'eslint-config-standard',
},
staticPreview: this.render({
dependency: 'eslint-config-standard',
range: '^12.0.0',
}),
keywords,
},
{
title: 'npm (prod) dependency version',
pattern: ':packageName/:dependency',
namedParams: {
packageName: 'react-boxplot',
dependency: 'simple-statistics',
},
staticPreview: this.render({
dependency: 'simple-statistics',
range: '^6.1.1',
}),
keywords,
},
]
}

static get defaultBadgeData() {
return {
label: 'dependency',
}
}

static render({ dependency, range }) {
return {
label: dependency,
message: range,
color: 'blue',
}
}

transform({
kind,
wantedDependency,
dependencies,
devDependencies,
peerDependencies,
}) {
let dependenciesOfKind
if (kind === 'peer') {
dependenciesOfKind = peerDependencies
} else if (kind === 'dev') {
dependenciesOfKind = devDependencies
} else {
dependenciesOfKind = dependencies
}

const range = dependenciesOfKind[wantedDependency]
if (range === undefined) {
throw new InvalidParameter({ prettyMessage: 'not found' })
}

return { range }
}

async handle(namedParams, queryParams) {
const { scope, packageName, registryUrl } = this.constructor.unpackParams(
namedParams,
queryParams
)
const { kind, dependency: wantedDependency } = namedParams

const {
dependencies,
devDependencies,
peerDependencies,
} = await this.fetchPackageData({
scope,
packageName,
registryUrl,
})

const { range } = this.transform({
kind,
wantedDependency,
dependencies,
devDependencies,
peerDependencies,
})

return this.constructor.render({
dependency: wantedDependency,
range,
})
}
}
48 changes: 48 additions & 0 deletions services/npm/npm-dependency-version.tester.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict'

const Joi = require('joi')
const { semverRange } = require('../validators')
const t = (module.exports = require('../create-service-tester')())

t.create('gets the peer dependency version')
.get('/react-boxplot/peer/react.json')
.expectJSONTypes(
Joi.object({
name: 'react',
value: semverRange,
})
)

t.create('gets the dev dependency version')
.get('/react-boxplot/dev/react.json?label=react%20tested')
.expectJSONTypes(
Joi.object({
name: 'react tested',
value: semverRange,
})
)

t.create('gets the dev dependency version (scoped)')
.get('/@metabolize/react-flexbox-svg/dev/eslint.json?')
.expectJSONTypes(
Joi.object({
name: 'eslint',
value: semverRange,
})
)

t.create('gets the prod dependency version')
.get('/react-boxplot/simple-statistics.json')
.expectJSONTypes(
Joi.object({
name: 'simple-statistics',
value: semverRange,
})
)

t.create('unknown dependency')
.get('/react-boxplot/dev/i-made-this-up.json')
.expectJSON({
name: 'dependency',
value: 'not found',
})
3 changes: 3 additions & 0 deletions services/npm/npm-license.tester.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ t.create('license for package without a license property')
.get('/package-without-license/latest')
.reply(200, {
name: 'package-without-license',
maintainers: [],
})
)
.expectJSON({ name: 'license', value: 'missing', colorB: colorsB.red })
Expand All @@ -61,6 +62,7 @@ t.create('license for package with a license object')
type: 'MIT',
url: 'https://www.opensource.org/licenses/mit-license.php',
},
maintainers: [],
})
)
.expectJSON({ name: 'license', value: 'MIT', colorB: colorsB.green })
Expand All @@ -73,6 +75,7 @@ t.create('license for package with a license array')
.reply(200, {
name: 'package-license-object',
license: ['MPL-2.0', 'MIT'],
maintainers: [],
})
)
.expectJSON({
Expand Down
4 changes: 4 additions & 0 deletions services/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ module.exports = {
.valid()
.required(),

semverRange: Joi.semver()
.validRange()
.required(),

// TODO This accepts URLs with query strings and fragments, which for some
// purposes should be rejected.
optionalUrl: Joi.string().uri({ scheme: ['http', 'https'] }),
Expand Down

0 comments on commit a3a5252

Please sign in to comment.