Compare commits
112 Commits
github-oau
...
server-202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e74285ac04 | ||
|
|
b79e00db14 | ||
|
|
32dd99c540 | ||
|
|
eb07b60cf0 | ||
|
|
8fcde9de85 | ||
|
|
c3d213c65c | ||
|
|
89410389e0 | ||
|
|
ee544d13b6 | ||
|
|
753eae6d57 | ||
|
|
54d1612fb1 | ||
|
|
ff14c337bd | ||
|
|
9754e1257e | ||
|
|
4803bf2f13 | ||
|
|
56b4088753 | ||
|
|
c294b12f42 | ||
|
|
1011cd5483 | ||
|
|
ae58e4a211 | ||
|
|
6e100bf274 | ||
|
|
e4e7b09009 | ||
|
|
6df390e43f | ||
|
|
a2a9780f98 | ||
|
|
50c0be0029 | ||
|
|
1dc40b83d8 | ||
|
|
e30a7f2735 | ||
|
|
d1da25b808 | ||
|
|
379a721ab9 | ||
|
|
222c66a106 | ||
|
|
7725c7d488 | ||
|
|
f62dbed843 | ||
|
|
14f64d17a8 | ||
|
|
a8214b74cf | ||
|
|
bab818b1c6 | ||
|
|
46a366840b | ||
|
|
f36c34cffa | ||
|
|
4109d36ad9 | ||
|
|
a43c98a89a | ||
|
|
f8f1286dbb | ||
|
|
694b07d636 | ||
|
|
d7316f98b7 | ||
|
|
99cad0f362 | ||
|
|
c0895f1963 | ||
|
|
796bd963d3 | ||
|
|
833cd0fb60 | ||
|
|
90eee44a70 | ||
|
|
e4812ea136 | ||
|
|
8732e3ca8f | ||
|
|
f546f104b4 | ||
|
|
0854e90e90 | ||
|
|
8fcdb7642c | ||
|
|
03d1e2d741 | ||
|
|
f04826a661 | ||
|
|
bfc2f96dc0 | ||
|
|
8a6eba330e | ||
|
|
4c8a211feb | ||
|
|
5b5ffce5b2 | ||
|
|
887ec5b441 | ||
|
|
b3199b23b3 | ||
|
|
de5bd01557 | ||
|
|
4d8a5fdbd1 | ||
|
|
835e69d63d | ||
|
|
5a982f62e7 | ||
|
|
8bcf4e35d6 | ||
|
|
6ed79b74d8 | ||
|
|
bc4a61b9e9 | ||
|
|
abaf0fe65d | ||
|
|
b4364303bd | ||
|
|
47d6135a0c | ||
|
|
2e3e619da4 | ||
|
|
3da7e194d2 | ||
|
|
b191b3bb8a | ||
|
|
67555ef2d2 | ||
|
|
b12a4a9964 | ||
|
|
b209f5c63a | ||
|
|
0ce98396d7 | ||
|
|
0054a4c30b | ||
|
|
42a9ca6444 | ||
|
|
4ad822c42e | ||
|
|
a3601c9b3e | ||
|
|
1593793c27 | ||
|
|
1ff1fc0c58 | ||
|
|
c02f9d9396 | ||
|
|
a3c2ada96e | ||
|
|
403bb557c7 | ||
|
|
db8d556671 | ||
|
|
3471a99edf | ||
|
|
ca927fd5d8 | ||
|
|
4a8c1d1d0f | ||
|
|
aa38625b86 | ||
|
|
a3e2b2ff28 | ||
|
|
28cf98fff4 | ||
|
|
14a65ba38f | ||
|
|
ca0defa0a7 | ||
|
|
5e96598fcb | ||
|
|
926e62f927 | ||
|
|
0fe5839757 | ||
|
|
b69b13a72d | ||
|
|
8a9efb2fc9 | ||
|
|
e9153ab97a | ||
|
|
d79cf79f8a | ||
|
|
d0734a313a | ||
|
|
2c9068e976 | ||
|
|
8b6a61c764 | ||
|
|
2ae4d179df | ||
|
|
b171af4f82 | ||
|
|
74ffb87012 | ||
|
|
16620695e2 | ||
|
|
3d6c7971d1 | ||
|
|
f222668682 | ||
|
|
1f94f8d571 | ||
|
|
16813841b7 | ||
|
|
6dbee4457f | ||
|
|
c3dd1fbc5c |
@@ -22,7 +22,7 @@ labels: 'keep-service-tests-green'
|
||||
|
||||
<!-- Provide a link to the failing test in CircleCI. -->
|
||||
|
||||
:beetle: **Stack trace**
|
||||
:lady_beetle: **Stack trace**
|
||||
|
||||
```
|
||||
<!-- Provide the complete stack trace from the CircleCI test summary. -->
|
||||
|
||||
20
.github/actions/close-bot/package-lock.json
generated
vendored
20
.github/actions/close-bot/package-lock.json
generated
vendored
@@ -9,14 +9,17 @@
|
||||
"version": "0.0.0",
|
||||
"license": "CC0",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.5.0",
|
||||
"@actions/core": "^1.6.0",
|
||||
"@actions/github": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.5.0.tgz",
|
||||
"integrity": "sha512-eDOLH1Nq9zh+PJlYLqEMkS/jLQxhksPNmUGNBHfa4G+tQmnIhzpctxmchETtVGyBOvXgOVVpYuE40+eS4cUnwQ=="
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
||||
"dependencies": {
|
||||
"@actions/http-client": "^1.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/github": {
|
||||
"version": "5.0.0",
|
||||
@@ -193,9 +196,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.5.0.tgz",
|
||||
"integrity": "sha512-eDOLH1Nq9zh+PJlYLqEMkS/jLQxhksPNmUGNBHfa4G+tQmnIhzpctxmchETtVGyBOvXgOVVpYuE40+eS4cUnwQ=="
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.6.0.tgz",
|
||||
"integrity": "sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==",
|
||||
"requires": {
|
||||
"@actions/http-client": "^1.0.11"
|
||||
}
|
||||
},
|
||||
"@actions/github": {
|
||||
"version": "5.0.0",
|
||||
|
||||
2
.github/actions/close-bot/package.json
vendored
2
.github/actions/close-bot/package.json
vendored
@@ -10,7 +10,7 @@
|
||||
"author": "chris48s",
|
||||
"license": "CC0",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.5.0",
|
||||
"@actions/core": "^1.6.0",
|
||||
"@actions/github": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -4,6 +4,40 @@ Note: this changelog is for the shields.io server. The changelog for the badge-m
|
||||
|
||||
---
|
||||
|
||||
## server-2021-11-04
|
||||
|
||||
- migrate regularUpdate() from request-->got [#7215](https://github.com/badges/shields/issues/7215)
|
||||
- migrate github badges to use got instead of request; affects [github librariesio] [#7212](https://github.com/badges/shields/issues/7212)
|
||||
- deprecate David badges [#7197](https://github.com/badges/shields/issues/7197)
|
||||
- fix: ensure libraries.io header values are processed numerically [#7196](https://github.com/badges/shields/issues/7196)
|
||||
- Add authentication for Libraries.io-based badges, run [Libraries Bower] [#7080](https://github.com/badges/shields/issues/7080)
|
||||
- fixes and tests for pipenv helpers [#7194](https://github.com/badges/shields/issues/7194)
|
||||
- add GitLab Release badge, run all [GitLab] [#7021](https://github.com/badges/shields/issues/7021)
|
||||
- set content-length header on badge responses [#7179](https://github.com/badges/shields/issues/7179)
|
||||
- fix [github] release/tag/download schema [#7170](https://github.com/badges/shields/issues/7170)
|
||||
- Supported nested groups on [GitLabPipeline] badge [#7159](https://github.com/badges/shields/issues/7159)
|
||||
- Support nested groups on [GitLabTag] badge [#7158](https://github.com/badges/shields/issues/7158)
|
||||
- Fixing incorrect JetBrains Plugin rating values for [JetBrainsRating] [#7140](https://github.com/badges/shields/issues/7140)
|
||||
- support using release or tag name in [GitHub] Release version badge [#7075](https://github.com/badges/shields/issues/7075)
|
||||
- feat: support branches in sonar badges [#7065](https://github.com/badges/shields/issues/7065)
|
||||
- Add [Modrinth] total downloads badge [#7132](https://github.com/badges/shields/issues/7132)
|
||||
- remove [github] admin routes [#7105](https://github.com/badges/shields/issues/7105)
|
||||
- Dependency updates
|
||||
|
||||
## server-2021-10-04
|
||||
|
||||
- feat: add 2021 support to GitHub Hacktoberfest [#7086](https://github.com/badges/shields/issues/7086)
|
||||
- Add [ClearlyDefined] service [#6944](https://github.com/badges/shields/issues/6944)
|
||||
- handle null licenses in crates.io response schema, run [crates] [#7074](https://github.com/badges/shields/issues/7074)
|
||||
- [OBS] add Open Build Service service-badge [#6993](https://github.com/badges/shields/issues/6993)
|
||||
- Correction of badges url in self-hosting configuration with a custom port. Issue 7025 [#7036](https://github.com/badges/shields/issues/7036)
|
||||
- fix: support gitlab token via env var [#7023](https://github.com/badges/shields/issues/7023)
|
||||
- Add API-based support for [GitLab] badges, add new GitLab Tag badge [#6988](https://github.com/badges/shields/issues/6988)
|
||||
- [freecodecamp]: allow + symbol in username [#7016](https://github.com/badges/shields/issues/7016)
|
||||
- Rename Riot to Element in Matrix badge help [#6996](https://github.com/badges/shields/issues/6996)
|
||||
- Fixed Reddit Negative Karma Issue [#6992](https://github.com/badges/shields/issues/6992)
|
||||
- Dependency updates
|
||||
|
||||
## server-2021-09-01
|
||||
|
||||
- use multi-stage build to reduce size of docker images [#6938](https://github.com/badges/shields/issues/6938)
|
||||
|
||||
@@ -33,7 +33,7 @@ class XmlElement {
|
||||
* @param {object} attrs Refer to individual attrs
|
||||
* @param {string} attrs.name
|
||||
* Name of the XML tag
|
||||
* @param {Array.<string|module:badge-maker/lib/xml-element~XmlElement>} [attrs.content=[]]
|
||||
* @param {Array.<string|module:badge-maker/lib/xml~XmlElement>} [attrs.content=[]]
|
||||
* Array of objects to render inside the tag. content may contain a mix of
|
||||
* string and XmlElement objects. If content is `[]` or ommitted the
|
||||
* element will be rendered as a self-closing element.
|
||||
|
||||
@@ -50,6 +50,8 @@ public:
|
||||
authorizedOrigins: 'NEXUS_ORIGINS'
|
||||
npm:
|
||||
authorizedOrigins: 'NPM_ORIGINS'
|
||||
obs:
|
||||
authorizedOrigins: 'OBS_ORIGINS'
|
||||
sonar:
|
||||
authorizedOrigins: 'SONAR_ORIGINS'
|
||||
teamcity:
|
||||
@@ -84,12 +86,14 @@ private:
|
||||
jenkins_pass: 'JENKINS_PASS'
|
||||
jira_user: 'JIRA_USER'
|
||||
jira_pass: 'JIRA_PASS'
|
||||
librariesio_tokens: 'LIBRARIESIO_TOKENS'
|
||||
nexus_user: 'NEXUS_USER'
|
||||
nexus_pass: 'NEXUS_PASS'
|
||||
npm_token: 'NPM_TOKEN'
|
||||
obs_user: 'OBS_USER'
|
||||
obs_pass: 'OBS_PASS'
|
||||
redis_url: 'REDIS_URL'
|
||||
sentry_dsn: 'SENTRY_DSN'
|
||||
shields_secret: 'SHIELDS_SECRET'
|
||||
sl_insight_userUuid: 'SL_INSIGHT_USER_UUID'
|
||||
sl_insight_apiToken: 'SL_INSIGHT_API_TOKEN'
|
||||
sonarqube_token: 'SONARQUBE_TOKEN'
|
||||
|
||||
@@ -22,6 +22,8 @@ public:
|
||||
debug:
|
||||
enabled: false
|
||||
intervalSeconds: 200
|
||||
obs:
|
||||
authorizedOrigins: 'https://api.opensuse.org'
|
||||
weblate:
|
||||
authorizedOrigins: 'https://hosted.weblate.org'
|
||||
trace: false
|
||||
|
||||
@@ -6,6 +6,8 @@ private:
|
||||
# preferable for self hosting.
|
||||
gh_token: '...'
|
||||
gitlab_token: '...'
|
||||
obs_user: '...'
|
||||
obs_pass: '...'
|
||||
twitch_client_id: '...'
|
||||
twitch_client_secret: '...'
|
||||
weblate_api_key: '...'
|
||||
|
||||
@@ -420,7 +420,13 @@ class BaseService {
|
||||
}
|
||||
|
||||
static register(
|
||||
{ camp, handleRequest, githubApiProvider, metricInstance },
|
||||
{
|
||||
camp,
|
||||
handleRequest,
|
||||
githubApiProvider,
|
||||
librariesIoApiProvider,
|
||||
metricInstance,
|
||||
},
|
||||
serviceConfig
|
||||
) {
|
||||
const { cacheHeaders: cacheHeaderConfig, fetchLimitBytes } = serviceConfig
|
||||
@@ -444,9 +450,9 @@ class BaseService {
|
||||
const namedParams = namedParamsForMatch(captureNames, match, this)
|
||||
const serviceData = await this.invoke(
|
||||
{
|
||||
sendAndCacheRequest: fetcher,
|
||||
sendAndCacheRequestWithCallbacks: request,
|
||||
sendAndCacheRequest: fetcher, // TODO: rename sendAndCacheRequest
|
||||
githubApiProvider,
|
||||
librariesIoApiProvider,
|
||||
metricHelper,
|
||||
},
|
||||
serviceConfig,
|
||||
|
||||
@@ -124,15 +124,11 @@ describe('BaseService', function () {
|
||||
})
|
||||
|
||||
describe('Logging', function () {
|
||||
let sandbox
|
||||
beforeEach(function () {
|
||||
sandbox = sinon.createSandbox()
|
||||
sinon.stub(trace, 'logTrace')
|
||||
})
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
})
|
||||
beforeEach(function () {
|
||||
sandbox.stub(trace, 'logTrace')
|
||||
sinon.restore()
|
||||
})
|
||||
it('Invokes the logger as expected', async function () {
|
||||
await DummyService.invoke(
|
||||
@@ -426,15 +422,11 @@ describe('BaseService', function () {
|
||||
})
|
||||
|
||||
describe('request', function () {
|
||||
let sandbox
|
||||
beforeEach(function () {
|
||||
sandbox = sinon.createSandbox()
|
||||
sinon.stub(trace, 'logTrace')
|
||||
})
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
})
|
||||
beforeEach(function () {
|
||||
sandbox.stub(trace, 'logTrace')
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('logs appropriate information', async function () {
|
||||
|
||||
@@ -99,14 +99,11 @@ describe('Cache header functions', function () {
|
||||
})
|
||||
|
||||
describe('setHeadersForCacheLength', function () {
|
||||
let sandbox
|
||||
beforeEach(function () {
|
||||
sandbox = sinon.createSandbox()
|
||||
sandbox.useFakeTimers()
|
||||
sinon.useFakeTimers()
|
||||
})
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
sandbox = undefined
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('should set the correct Date header', function () {
|
||||
|
||||
@@ -64,7 +64,8 @@ async function sendRequest(gotWrapper, url, options) {
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFactory(fetchLimitBytes) {
|
||||
const TEN_MB = 10485760
|
||||
function fetchFactory(fetchLimitBytes = TEN_MB) {
|
||||
const gotWithLimit = got.extend({
|
||||
handlers: [
|
||||
(options, next) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Inaccessible,
|
||||
InvalidParameter,
|
||||
Deprecated,
|
||||
ImproperlyConfigured,
|
||||
} from './errors.js'
|
||||
|
||||
export {
|
||||
@@ -29,5 +30,6 @@ export {
|
||||
InvalidResponse,
|
||||
Inaccessible,
|
||||
InvalidParameter,
|
||||
ImproperlyConfigured,
|
||||
Deprecated,
|
||||
}
|
||||
|
||||
@@ -11,12 +11,14 @@ function streamFromString(str) {
|
||||
|
||||
function sendSVG(res, askres, end) {
|
||||
askres.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
|
||||
askres.setHeader('Content-Length', Buffer.byteLength(res, 'utf8'))
|
||||
end(null, { template: streamFromString(res) })
|
||||
}
|
||||
|
||||
function sendJSON(res, askres, end) {
|
||||
askres.setHeader('Content-Type', 'application/json')
|
||||
askres.setHeader('Access-Control-Allow-Origin', '*')
|
||||
askres.setHeader('Content-Length', Buffer.byteLength(res, 'utf8'))
|
||||
end(null, { template: streamFromString(res) })
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,11 @@ describe('validate', function () {
|
||||
requiredString: Joi.string().required(),
|
||||
}).required()
|
||||
|
||||
let sandbox
|
||||
beforeEach(function () {
|
||||
sandbox = sinon.createSandbox()
|
||||
sinon.stub(trace, 'logTrace')
|
||||
})
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
})
|
||||
beforeEach(function () {
|
||||
sandbox.stub(trace, 'logTrace')
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const ErrorClass = InvalidParameter
|
||||
|
||||
@@ -1,93 +1,62 @@
|
||||
import requestModule from 'request'
|
||||
import { Inaccessible, InvalidResponse } from '../base-service/errors.js'
|
||||
/**
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { InvalidResponse } from '../base-service/errors.js'
|
||||
import { fetchFactory } from '../../core/base-service/got.js'
|
||||
import checkErrorResponse from '../../core/base-service/check-error-response.js'
|
||||
const fetcher = fetchFactory()
|
||||
|
||||
// Map from URL to { timestamp: last fetch time, data: data }.
|
||||
let regularUpdateCache = Object.create(null)
|
||||
|
||||
// url: a string, scraper: a function that takes string data at that URL.
|
||||
// interval: number in milliseconds.
|
||||
// cb: a callback function that takes an error and data returned by the scraper.
|
||||
//
|
||||
// To use this from a service:
|
||||
//
|
||||
// import { promisify } from 'util'
|
||||
// import { regularUpdate } from '../../core/legacy/regular-update.js'
|
||||
//
|
||||
// function getThing() {
|
||||
// return promisify(regularUpdate)({
|
||||
// url: ...,
|
||||
// ...
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// in handle():
|
||||
//
|
||||
// const thing = await getThing()
|
||||
|
||||
function regularUpdate(
|
||||
{
|
||||
url,
|
||||
intervalMillis,
|
||||
json = true,
|
||||
scraper = buffer => buffer,
|
||||
options = {},
|
||||
request = requestModule,
|
||||
},
|
||||
cb
|
||||
) {
|
||||
/**
|
||||
* Make a HTTP request using an in-memory cache
|
||||
*
|
||||
* @param {object} attrs Refer to individual attrs
|
||||
* @param {string} attrs.url URL to request
|
||||
* @param {number} attrs.intervalMillis Number of milliseconds to keep cached value for
|
||||
* @param {boolean} [attrs.json=true] True if we expect to parse the response as JSON
|
||||
* @param {Function} [attrs.scraper=buffer => buffer] Function to extract value from the response
|
||||
* @param {object} [attrs.options={}] Options to pass to got
|
||||
* @param {Function} [attrs.requestFetcher=fetcher] Custom fetch function
|
||||
* @returns {*} Parsed response
|
||||
*/
|
||||
async function regularUpdate({
|
||||
url,
|
||||
intervalMillis,
|
||||
json = true,
|
||||
scraper = buffer => buffer,
|
||||
options = {},
|
||||
requestFetcher = fetcher,
|
||||
}) {
|
||||
const timestamp = Date.now()
|
||||
const cached = regularUpdateCache[url]
|
||||
if (cached != null && timestamp - cached.timestamp < intervalMillis) {
|
||||
cb(null, cached.data)
|
||||
return
|
||||
return cached.data
|
||||
}
|
||||
request(url, options, (err, res, buffer) => {
|
||||
if (err != null) {
|
||||
cb(
|
||||
new Inaccessible({
|
||||
prettyMessage: 'intermediate resource inaccessible',
|
||||
underlyingError: err,
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
cb(
|
||||
new InvalidResponse({
|
||||
prettyMessage: 'intermediate resource inaccessible',
|
||||
})
|
||||
)
|
||||
}
|
||||
const { buffer } = await checkErrorResponse({})(
|
||||
await requestFetcher(url, options)
|
||||
)
|
||||
|
||||
let reqData
|
||||
if (json) {
|
||||
try {
|
||||
reqData = JSON.parse(buffer)
|
||||
} catch (e) {
|
||||
cb(
|
||||
new InvalidResponse({
|
||||
prettyMessage: 'unparseable intermediate json response',
|
||||
underlyingError: e,
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
reqData = buffer
|
||||
}
|
||||
|
||||
let data
|
||||
let reqData
|
||||
if (json) {
|
||||
try {
|
||||
data = scraper(reqData)
|
||||
reqData = JSON.parse(buffer)
|
||||
} catch (e) {
|
||||
cb(e)
|
||||
return
|
||||
throw new InvalidResponse({
|
||||
prettyMessage: 'unparseable intermediate json response',
|
||||
underlyingError: e,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
reqData = buffer
|
||||
}
|
||||
|
||||
regularUpdateCache[url] = { timestamp, data }
|
||||
cb(null, data)
|
||||
})
|
||||
const data = scraper(reqData)
|
||||
regularUpdateCache[url] = { timestamp, data }
|
||||
return data
|
||||
}
|
||||
|
||||
function clearRegularUpdateCache() {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
function constEq(a, b) {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
let zero = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
zero |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
||||
}
|
||||
return zero === 0
|
||||
}
|
||||
|
||||
function makeSecretIsValid(shieldsSecret) {
|
||||
return function secretIsValid(secret = '') {
|
||||
return shieldsSecret && constEq(secret, shieldsSecret)
|
||||
}
|
||||
}
|
||||
|
||||
export { makeSecretIsValid }
|
||||
@@ -11,6 +11,7 @@ import Camp from '@shields_io/camp'
|
||||
import originalJoi from 'joi'
|
||||
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
||||
import GithubConstellation from '../../services/github/github-constellation.js'
|
||||
import LibrariesIoConstellation from '../../services/librariesio/librariesio-constellation.js'
|
||||
import { setRoutes } from '../../services/suggest.js'
|
||||
import { loadServiceClasses } from '../base-service/loader.js'
|
||||
import { makeSend } from '../base-service/legacy-result-sender.js'
|
||||
@@ -134,6 +135,7 @@ const publicConfigSchema = Joi.object({
|
||||
}).default({ authorizedOrigins: [] }),
|
||||
nexus: defaultService,
|
||||
npm: defaultService,
|
||||
obs: defaultService,
|
||||
sonar: defaultService,
|
||||
teamcity: defaultService,
|
||||
weblate: defaultService,
|
||||
@@ -169,12 +171,14 @@ const privateConfigSchema = Joi.object({
|
||||
jira_pass: Joi.string(),
|
||||
bitbucket_server_username: Joi.string(),
|
||||
bitbucket_server_password: Joi.string(),
|
||||
librariesio_tokens: Joi.arrayFromString().items(Joi.string()),
|
||||
nexus_user: Joi.string(),
|
||||
nexus_pass: Joi.string(),
|
||||
npm_token: Joi.string(),
|
||||
obs_user: Joi.string(),
|
||||
obs_pass: Joi.string(),
|
||||
redis_url: Joi.string().uri({ scheme: ['redis', 'rediss'] }),
|
||||
sentry_dsn: Joi.string(),
|
||||
shields_secret: Joi.string(),
|
||||
sl_insight_userUuid: Joi.string(),
|
||||
sl_insight_apiToken: Joi.string(),
|
||||
sonarqube_token: Joi.string(),
|
||||
@@ -239,6 +243,10 @@ class Server {
|
||||
private: privateConfig,
|
||||
})
|
||||
|
||||
this.librariesioConstellation = new LibrariesIoConstellation({
|
||||
private: privateConfig,
|
||||
})
|
||||
|
||||
if (publicConfig.metrics.prometheus.enabled) {
|
||||
this.metricInstance = new PrometheusMetrics()
|
||||
if (publicConfig.metrics.influx.enabled) {
|
||||
@@ -411,10 +419,17 @@ class Server {
|
||||
async registerServices() {
|
||||
const { config, camp, metricInstance } = this
|
||||
const { apiProvider: githubApiProvider } = this.githubConstellation
|
||||
|
||||
const { apiProvider: librariesIoApiProvider } =
|
||||
this.librariesioConstellation
|
||||
;(await loadServiceClasses()).forEach(serviceClass =>
|
||||
serviceClass.register(
|
||||
{ camp, handleRequest, githubApiProvider, metricInstance },
|
||||
{
|
||||
camp,
|
||||
handleRequest,
|
||||
githubApiProvider,
|
||||
librariesIoApiProvider,
|
||||
metricInstance,
|
||||
},
|
||||
{
|
||||
handleInternalErrors: config.public.handleInternalErrors,
|
||||
cacheHeaders: config.public.cacheHeaders,
|
||||
|
||||
@@ -87,12 +87,28 @@ describe('The server', function () {
|
||||
)
|
||||
})
|
||||
|
||||
it('should produce json badges', async function () {
|
||||
it('should produce SVG badges with expected headers', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}:fruit-apple-green.svg`
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(headers['content-type']).to.equal('image/svg+xml;charset=utf-8')
|
||||
expect(headers['content-length']).to.equal('1130')
|
||||
})
|
||||
|
||||
it('correctly calculates the content-length header for multi-byte unicode characters', async function () {
|
||||
const { headers } = await got(`${baseUrl}:fruit-apple🍏-green.json`)
|
||||
expect(headers['content-length']).to.equal('100')
|
||||
})
|
||||
|
||||
it('should produce JSON badges with expected headers', async function () {
|
||||
const { statusCode, body, headers } = await got(
|
||||
`${baseUrl}twitter/follow/_Pyves.json`
|
||||
`${baseUrl}:fruit-apple-green.json`
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(headers['content-type']).to.equal('application/json')
|
||||
expect(headers['access-control-allow-origin']).to.equal('*')
|
||||
expect(headers['content-length']).to.equal('92')
|
||||
expect(() => JSON.parse(body)).not.to.throw()
|
||||
})
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@ class Token {
|
||||
return this.usesRemaining <= 0 && !this.hasReset
|
||||
}
|
||||
|
||||
get decrementedUsesRemaining() {
|
||||
return this._usesRemaining - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the uses remaining and next reset time for a token.
|
||||
*
|
||||
@@ -328,29 +332,6 @@ class TokenPool {
|
||||
this.fifoQueue.forEach(visit)
|
||||
this.priorityQueue.forEach(visit)
|
||||
}
|
||||
|
||||
allValidTokenIds() {
|
||||
const result = []
|
||||
this.forEach(({ id }) => result.push(id))
|
||||
return result
|
||||
}
|
||||
|
||||
serializeDebugInfo({ sanitize = true } = {}) {
|
||||
const maybeSanitize = sanitize ? id => sanitizeToken(id) : id => id
|
||||
|
||||
const priorityQueue = []
|
||||
this.priorityQueue.forEach(t =>
|
||||
priorityQueue.push(t.getDebugInfo({ sanitize }))
|
||||
)
|
||||
|
||||
return {
|
||||
utcEpochSeconds: getUtcEpochSeconds(),
|
||||
allValidTokenIds: this.allValidTokenIds().map(maybeSanitize),
|
||||
fifoQueue: this.fifoQueue.map(t => t.getDebugInfo({ sanitize })),
|
||||
priorityQueue,
|
||||
sanitized: sanitize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { sanitizeToken, Token, TokenPool }
|
||||
|
||||
@@ -19,10 +19,6 @@ describe('The token pool', function () {
|
||||
ids.forEach(id => tokenPool.add(id))
|
||||
})
|
||||
|
||||
it('allValidTokenIds() should return the full list', function () {
|
||||
expect(tokenPool.allValidTokenIds()).to.deep.equal(ids)
|
||||
})
|
||||
|
||||
it('should yield the expected tokens', function () {
|
||||
ids.forEach(id =>
|
||||
times(batchSize, () => expect(tokenPool.next().id).to.equal(id))
|
||||
@@ -38,67 +34,6 @@ describe('The token pool', function () {
|
||||
)
|
||||
})
|
||||
|
||||
describe('serializeDebugInfo should initially return the expected', function () {
|
||||
beforeEach(function () {
|
||||
sinon.useFakeTimers({ now: 1544307744484 })
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
context('sanitize is not specified', function () {
|
||||
it('returns fully sanitized results', function () {
|
||||
// This is `sha()` of '1', '2', '3', '4', '5'. These are written
|
||||
// literally for avoidance of doubt as to whether sanitization is
|
||||
// happening.
|
||||
const sanitizedIds = [
|
||||
'6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b',
|
||||
'd4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35',
|
||||
'4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce',
|
||||
'4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a',
|
||||
'ef2d127de37b942baad06145e54b0c619a1f22327b2ebbcfbec78f5564afe39d',
|
||||
]
|
||||
|
||||
expect(tokenPool.serializeDebugInfo()).to.deep.equal({
|
||||
allValidTokenIds: sanitizedIds,
|
||||
priorityQueue: [],
|
||||
fifoQueue: sanitizedIds.map(id => ({
|
||||
data: '[redacted]',
|
||||
id,
|
||||
isFrozen: false,
|
||||
isValid: true,
|
||||
nextReset: Token.nextResetNever,
|
||||
usesRemaining: batchSize,
|
||||
})),
|
||||
sanitized: true,
|
||||
utcEpochSeconds: 1544307744,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('with sanitize: false', function () {
|
||||
it('returns unsanitized results', function () {
|
||||
expect(tokenPool.serializeDebugInfo({ sanitize: false })).to.deep.equal(
|
||||
{
|
||||
allValidTokenIds: ids,
|
||||
priorityQueue: [],
|
||||
fifoQueue: ids.map(id => ({
|
||||
data: undefined,
|
||||
id,
|
||||
isFrozen: false,
|
||||
isValid: true,
|
||||
nextReset: Token.nextResetNever,
|
||||
usesRemaining: batchSize,
|
||||
})),
|
||||
sanitized: false,
|
||||
utcEpochSeconds: 1544307744,
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('tokens are marked exhausted immediately', function () {
|
||||
it('should be exhausted', function () {
|
||||
ids.forEach(() => {
|
||||
|
||||
22
doc/adding-new-config-values.md
Normal file
22
doc/adding-new-config-values.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Adding New Config Values
|
||||
|
||||
The Badge Server supports a [variety of methods for defining configuration settings and secrets](./server-secrets.md), and provides a framework for loading those values during bootstrapping.
|
||||
|
||||
Any new configuration setting or secret must be correctly registered so that it will be loaded at startup along with the others.
|
||||
|
||||
This generally includes adding the corresponding information for your new setting(s)/secret(s) to the following locations:
|
||||
|
||||
- [core/server/server.js](https://github.com/badges/shields/blob/master/core/server/server.js) - Add the new values to the [schemas](https://github.com/badges/shields/blob/master/core/server/server.js#L118-L193). Secrets/tokens/etc. should go in the `privateConfigSchema` while non-secret configuration settings should go in the `publicConfigSchema`.
|
||||
- [config/custom-environment-variables.yml](https://github.com/badges/shields/blob/master/config/custom-environment-variables.yml)
|
||||
- [docs/server-secrets.md](https://github.com/badges/shields/blob/master/doc/server-secrets.md) (only applicable for secrets)
|
||||
- [config/default.yml](https://github.com/badges/shields/blob/master/config/default.yml) (optional)
|
||||
- Any other template config files (e.g. `config/local.template.yml`) (optional)
|
||||
|
||||
The exact values needed will depend on what type of secret/setting you are adding, but for reference a few commits are included below which added secrets and or settings:
|
||||
|
||||
- (secret) [8a9efb2fc99f97e78ab133c836ab1685803bf4df](https://github.com/badges/shields/commit/8a9efb2fc99f97e78ab133c836ab1685803bf4df)
|
||||
- (secret) [bd6f4ee1465d14a8f188c37823748a21b6a46762](https://github.com/badges/shields/commit/bd6f4ee1465d14a8f188c37823748a21b6a46762)
|
||||
- (secret) [0fd557d7bb623e3852c92cebac586d5f6d6d89d8](https://github.com/badges/shields/commit/0fd557d7bb623e3852c92cebac586d5f6d6d89d8)
|
||||
- (configuration setting) [b1fc4925928c061234e9492f3794c0797467e123](https://github.com/badges/shields/commit/b1fc4925928c061234e9492f3794c0797467e123)
|
||||
|
||||
Don't hesitate to reach out if you're unsure of the exact values needed for your new secret/setting, or have any other questions. Feel free to post questions on your corresponding Issue/Pull Request, and/or ping us on the `contributing` channel on our Discord server.
|
||||
@@ -16,7 +16,7 @@ Production hosting is managed by the Shields ops team:
|
||||
|
||||
| Component | Subcomponent | People with access |
|
||||
| ----------------------------- | ------------------------------- | --------------------------------------------------------------- |
|
||||
| shields-production-us | Account owner | @paulmelnikow |
|
||||
| shields-production-us | Account owner | @calebcartwright, @paulmelnikow |
|
||||
| shields-production-us | Full access | @calebcartwright, @chris48s, @paulmelnikow, @pyvesb |
|
||||
| shields-production-us | Access management | @calebcartwright, @chris48s, @paulmelnikow, @pyvesb |
|
||||
| Compose.io Redis | Account owner | @paulmelnikow |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Shields is a community project that is stewarded by a handful of core maintainers who contribute on a volunteer basis. We do our best to maintain the availability and reliability of the service, and enhance and improve the project overall. However, if you've spotted something wrong or would like to see a specific feature implemented, please consider helping us resolve it by submitting a pull request. All community contributions, even documentation improvements, are welcome!
|
||||
|
||||
https://github.com/badges/shields is a monorepo and hosts the Shields frontend and server code as well as the [badge-maker][npm package] NPM library (and the [badge design specification](https://github.com/badges/shields/tree/master/spec)). The packaging and release processes for these items is described in the respective sections below.
|
||||
https://github.com/badges/shields is a monorepo and hosts the Shields frontend and server code as well as the [badge-maker][npm package] NPM library (and the [badge design specification](https://github.com/badges/shields/tree/master/spec)). The packaging and release processes for these items are described in the respective sections below.
|
||||
|
||||
## badge-maker package
|
||||
|
||||
|
||||
@@ -94,20 +94,18 @@ Sending build context to Docker daemon 3.923 MB
|
||||
Successfully built 4471b442c220
|
||||
```
|
||||
|
||||
Optionally, create a file called `shields.env` that contains the needed
|
||||
configuration. See [server-secrets.md](server-secrets.md) and [config/custom-environment-variables.yml](/config/custom-environment-variables.yml) for examples.
|
||||
Optionally, alter the default values for configuration by setting them via [environment variables](https://docs.docker.com/engine/reference/commandline/run/#set-environment-variables--e---env---env-file).
|
||||
See [server-secrets.md](server-secrets.md) and [config/custom-environment-variables.yml](/config/custom-environment-variables.yml) for possible values.
|
||||
In [config/custom-environment-variables.yml](/config/custom-environment-variables.yml), environment variable names are specified as the quoted, uppercase key values (e.g. `GH_TOKEN`).
|
||||
|
||||
Then run the container:
|
||||
Then run the container, and be sure to specify the same mapped port as the one Shields is listening on :
|
||||
|
||||
```console
|
||||
$ docker run --rm -p 8080:80 --name shields shields
|
||||
# or if you have shields.env file, run the following instead
|
||||
$ docker run --rm -p 8080:80 --env-file shields.env --name shields shields
|
||||
$ docker run --rm -p 8080:8080 --env PORT=8080 --name shields shieldsio/shields:next
|
||||
|
||||
> badge-maker@3.0.0 start /usr/src/app
|
||||
> node server.js
|
||||
|
||||
http://[::1]/
|
||||
Configuration:
|
||||
...
|
||||
0916211515 Server is starting up: http://0.0.0.0:8080/
|
||||
```
|
||||
|
||||
Assuming Docker is running locally, you should be able to get to the
|
||||
@@ -117,8 +115,6 @@ If you run Docker in a virtual machine (such as boot2docker or Docker Machine)
|
||||
then you will need to replace `localhost` with the IP address of that virtual
|
||||
machine.
|
||||
|
||||
[shields.example.env]: ../shields.example.env
|
||||
|
||||
## Raster server
|
||||
|
||||
If you want to host PNG badges, you can also self-host a [raster server][]
|
||||
|
||||
@@ -174,6 +174,24 @@ access to a private Jenkins CI instance.
|
||||
Provide a username and password to give your self-hosted Shields installation
|
||||
access to a private JIRA instance.
|
||||
|
||||
### Libraries.io/Bower
|
||||
|
||||
- `LIBRARIESIO_TOKENS` (yml: `private.librariesio_tokens`)
|
||||
|
||||
Note that the Bower badges utilize the Libraries.io API, so use this secret for both Libraries.io badges and/or Bower badges.
|
||||
|
||||
Just like the `*_ORIGINS` type secrets, this value can accept a single token as a string, or a group of tokens provided as an array of strings. For example:
|
||||
|
||||
```yaml
|
||||
private:
|
||||
librariesio_tokens: my-token
|
||||
## Or
|
||||
private:
|
||||
librariesio_tokens: [my-token some-other-token]
|
||||
```
|
||||
|
||||
When using the environment variable with multiple tokens, be sure to use a space to separate the tokens, e.g. `LIBRARIESIO_TOKENS="my-token some-other-token"`
|
||||
|
||||
### Nexus
|
||||
|
||||
- `NEXUS_ORIGINS` (yml: `public.services.nexus.authorizedOrigins`)
|
||||
@@ -193,6 +211,21 @@ installation access to private npm packages
|
||||
|
||||
[npm token]: https://docs.npmjs.com/getting-started/working_with_tokens
|
||||
|
||||
## Open Build Service
|
||||
|
||||
- `OBS_USER` (yml: `private.obs_user`)
|
||||
- `OBS_PASS` (yml: `private.obs_user`)
|
||||
|
||||
Only authenticated users are allowed to access the Open Build Service API.
|
||||
Authentication is done by sending a Basic HTTP Authorisation header. A user
|
||||
account for the [reference instance](https://build.opensuse.org) is a SUSE
|
||||
IdP account, which can be created [here](https://idp-portal.suse.com/univention/self-service/#page=createaccount).
|
||||
|
||||
While OBS supports [API tokens](https://openbuildservice.org/help/manuals/obs-user-guide/cha.obs.authorization.token.html#id-1.5.10.16.4),
|
||||
they can only be scoped to execute specific actions on a POST request. This
|
||||
means however, that an actual account is required to read the build status
|
||||
of a package.
|
||||
|
||||
### SymfonyInsight (formerly Sensiolabs)
|
||||
|
||||
- `SL_INSIGHT_USER_UUID` (yml: `private.sl_insight_userUuid`)
|
||||
|
||||
@@ -254,7 +254,7 @@ By checking code coverage, we can make sure we've covered all our bases.
|
||||
We can generate a coverage report and open it:
|
||||
|
||||
```
|
||||
npm run coverage:test:services -- --only=wercker
|
||||
npm run coverage:test:services -- -- --only=wercker
|
||||
npm run coverage:report:open
|
||||
```
|
||||
|
||||
|
||||
@@ -43,9 +43,12 @@ function Example({
|
||||
exampleData: RenderableExample
|
||||
isBadgeSuggestion: boolean
|
||||
}): JSX.Element {
|
||||
function handleClick(): void {
|
||||
onClick(exampleData, isBadgeSuggestion)
|
||||
}
|
||||
const handleClick = React.useCallback(
|
||||
function (): void {
|
||||
onClick(exampleData, isBadgeSuggestion)
|
||||
},
|
||||
[exampleData, isBadgeSuggestion, onClick]
|
||||
)
|
||||
|
||||
let exampleUrl, previewUrl
|
||||
if (isBadgeSuggestion) {
|
||||
|
||||
@@ -50,13 +50,16 @@ function _CopiedContentIndicator(
|
||||
},
|
||||
}))
|
||||
|
||||
function handlePoseComplete(): void {
|
||||
if (pose === 'effectStart') {
|
||||
setPose('effectEnd')
|
||||
} else {
|
||||
setPose('hidden')
|
||||
}
|
||||
}
|
||||
const handlePoseComplete = React.useCallback(
|
||||
function (): void {
|
||||
if (pose === 'effectStart') {
|
||||
setPose('effectEnd')
|
||||
} else {
|
||||
setPose('hidden')
|
||||
}
|
||||
},
|
||||
[pose, setPose]
|
||||
)
|
||||
|
||||
return (
|
||||
<ContentAnchor>
|
||||
|
||||
@@ -40,10 +40,13 @@ export default function Customizer({
|
||||
const [markup, setMarkup] = useState<string>()
|
||||
const [message, setMessage] = useState<string>()
|
||||
|
||||
function generateBuiltBadgeUrl(): string {
|
||||
const suffix = queryString ? `?${queryString}` : ''
|
||||
return `${baseUrl}${path}${suffix}`
|
||||
}
|
||||
const generateBuiltBadgeUrl = React.useCallback(
|
||||
function (): string {
|
||||
const suffix = queryString ? `?${queryString}` : ''
|
||||
return `${baseUrl}${path}${suffix}`
|
||||
},
|
||||
[baseUrl, path, queryString]
|
||||
)
|
||||
|
||||
function renderLivePreview(): JSX.Element {
|
||||
// There are some usability issues here. It would be better if the message
|
||||
@@ -67,28 +70,31 @@ export default function Customizer({
|
||||
)
|
||||
}
|
||||
|
||||
async function copyMarkup(markupFormat: MarkupFormat): Promise<void> {
|
||||
const builtBadgeUrl = generateBuiltBadgeUrl()
|
||||
const markup = generateMarkup({
|
||||
badgeUrl: builtBadgeUrl,
|
||||
link,
|
||||
title,
|
||||
markupFormat,
|
||||
})
|
||||
const copyMarkup = React.useCallback(
|
||||
async function (markupFormat: MarkupFormat): Promise<void> {
|
||||
const builtBadgeUrl = generateBuiltBadgeUrl()
|
||||
const markup = generateMarkup({
|
||||
badgeUrl: builtBadgeUrl,
|
||||
link,
|
||||
title,
|
||||
markupFormat,
|
||||
})
|
||||
|
||||
try {
|
||||
await clipboardCopy(markup)
|
||||
} catch (e) {
|
||||
setMessage('Copy failed')
|
||||
setMarkup(markup)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await clipboardCopy(markup)
|
||||
} catch (e) {
|
||||
setMessage('Copy failed')
|
||||
setMarkup(markup)
|
||||
return
|
||||
}
|
||||
|
||||
setMarkup(markup)
|
||||
if (indicatorRef.current) {
|
||||
indicatorRef.current.trigger()
|
||||
}
|
||||
}
|
||||
if (indicatorRef.current) {
|
||||
indicatorRef.current.trigger()
|
||||
}
|
||||
},
|
||||
[generateBuiltBadgeUrl, link, title, setMessage, setMarkup]
|
||||
)
|
||||
|
||||
function renderMarkupAndLivePreview(): JSX.Element {
|
||||
return (
|
||||
@@ -110,26 +116,32 @@ export default function Customizer({
|
||||
)
|
||||
}
|
||||
|
||||
function handlePathChange({
|
||||
path,
|
||||
isComplete,
|
||||
}: {
|
||||
path: string
|
||||
isComplete: boolean
|
||||
}): void {
|
||||
setPath(path)
|
||||
setPathIsComplete(isComplete)
|
||||
}
|
||||
const handlePathChange = React.useCallback(
|
||||
function ({
|
||||
path,
|
||||
isComplete,
|
||||
}: {
|
||||
path: string
|
||||
isComplete: boolean
|
||||
}): void {
|
||||
setPath(path)
|
||||
setPathIsComplete(isComplete)
|
||||
},
|
||||
[setPath, setPathIsComplete]
|
||||
)
|
||||
|
||||
function handleQueryStringChange({
|
||||
queryString,
|
||||
isComplete,
|
||||
}: {
|
||||
queryString: string
|
||||
isComplete: boolean
|
||||
}): void {
|
||||
setQueryString(queryString)
|
||||
}
|
||||
const handleQueryStringChange = React.useCallback(
|
||||
function ({
|
||||
queryString,
|
||||
isComplete,
|
||||
}: {
|
||||
queryString: string
|
||||
isComplete: boolean
|
||||
}): void {
|
||||
setQueryString(queryString)
|
||||
},
|
||||
[setQueryString]
|
||||
)
|
||||
|
||||
return (
|
||||
<form action="">
|
||||
|
||||
@@ -149,14 +149,17 @@ export default function PathBuilder({
|
||||
}
|
||||
}, [tokens, namedParams, onChange])
|
||||
|
||||
function handleTokenChange({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement | HTMLSelectElement>): void {
|
||||
setNamedParams({
|
||||
...namedParams,
|
||||
[name]: value,
|
||||
})
|
||||
}
|
||||
const handleTokenChange = React.useCallback(
|
||||
function ({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement | HTMLSelectElement>): void {
|
||||
setNamedParams({
|
||||
...namedParams,
|
||||
[name]: value,
|
||||
})
|
||||
},
|
||||
[setNamedParams, namedParams]
|
||||
)
|
||||
|
||||
function renderLiteral(
|
||||
literal: string,
|
||||
|
||||
@@ -270,18 +270,24 @@ export default function QueryStringBuilder({
|
||||
}, {} as Record<BadgeOptionName, string>)
|
||||
)
|
||||
|
||||
function handleServiceQueryParamChange({
|
||||
target: { name, type: targetType, checked, value },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
const outValue = targetType === 'checkbox' ? checked : value
|
||||
setQueryParams({ ...queryParams, [name]: outValue })
|
||||
}
|
||||
const handleServiceQueryParamChange = React.useCallback(
|
||||
function ({
|
||||
target: { name, type: targetType, checked, value },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
const outValue = targetType === 'checkbox' ? checked : value
|
||||
setQueryParams({ ...queryParams, [name]: outValue })
|
||||
},
|
||||
[setQueryParams, queryParams]
|
||||
)
|
||||
|
||||
function handleBadgeOptionChange({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
setBadgeOptions({ ...badgeOptions, [name]: value })
|
||||
}
|
||||
const handleBadgeOptionChange = React.useCallback(
|
||||
function ({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
setBadgeOptions({ ...badgeOptions, [name]: value })
|
||||
},
|
||||
[setBadgeOptions, badgeOptions]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
|
||||
@@ -86,24 +86,30 @@ export default function GetMarkupButton({
|
||||
Select<Option>
|
||||
>
|
||||
|
||||
async function onControlMouseDown(event: MouseEvent): Promise<void> {
|
||||
if (onMarkupRequested) {
|
||||
await onMarkupRequested('link')
|
||||
}
|
||||
if (selectRef.current) {
|
||||
selectRef.current.blur()
|
||||
}
|
||||
}
|
||||
const onControlMouseDown = React.useCallback(
|
||||
async function (event: MouseEvent): Promise<void> {
|
||||
if (onMarkupRequested) {
|
||||
await onMarkupRequested('link')
|
||||
}
|
||||
if (selectRef.current) {
|
||||
selectRef.current.blur()
|
||||
}
|
||||
},
|
||||
[onMarkupRequested, selectRef]
|
||||
)
|
||||
|
||||
async function onOptionClick(
|
||||
// Eeesh.
|
||||
value: Option | readonly Option[] | null | undefined
|
||||
): Promise<void> {
|
||||
const { value: markupFormat } = value as Option
|
||||
if (onMarkupRequested) {
|
||||
await onMarkupRequested(markupFormat)
|
||||
}
|
||||
}
|
||||
const onOptionClick = React.useCallback(
|
||||
async function onOptionClick(
|
||||
// Eeesh.
|
||||
value: Option | readonly Option[] | null | undefined
|
||||
): Promise<void> {
|
||||
const { value: markupFormat } = value as Option
|
||||
if (onMarkupRequested) {
|
||||
await onMarkupRequested(markupFormat)
|
||||
}
|
||||
},
|
||||
[onMarkupRequested]
|
||||
)
|
||||
|
||||
return (
|
||||
// TODO It doesn't seem to be possible to check the types and wrap with
|
||||
|
||||
@@ -44,33 +44,39 @@ export default function DynamicBadgeMaker({
|
||||
const isValid =
|
||||
values.datatype && values.label && values.dataUrl && values.query
|
||||
|
||||
function onChange({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement | HTMLSelectElement>): void {
|
||||
setValues({
|
||||
...values,
|
||||
[name]: value,
|
||||
})
|
||||
}
|
||||
const onChange = React.useCallback(
|
||||
function ({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement | HTMLSelectElement>): void {
|
||||
setValues({
|
||||
...values,
|
||||
[name]: value,
|
||||
})
|
||||
},
|
||||
[values]
|
||||
)
|
||||
|
||||
function onSubmit(e: React.FormEvent): void {
|
||||
e.preventDefault()
|
||||
const onSubmit = React.useCallback(
|
||||
function onSubmit(e: React.FormEvent): void {
|
||||
e.preventDefault()
|
||||
|
||||
const { datatype, label, dataUrl, query, color, prefix, suffix } = values
|
||||
window.open(
|
||||
dynamicBadgeUrl({
|
||||
baseUrl,
|
||||
datatype,
|
||||
label,
|
||||
dataUrl,
|
||||
query,
|
||||
color,
|
||||
prefix,
|
||||
suffix,
|
||||
}),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
const { datatype, label, dataUrl, query, color, prefix, suffix } = values
|
||||
window.open(
|
||||
dynamicBadgeUrl({
|
||||
baseUrl,
|
||||
datatype,
|
||||
label,
|
||||
dataUrl,
|
||||
query,
|
||||
color,
|
||||
prefix,
|
||||
suffix,
|
||||
}),
|
||||
'_blank'
|
||||
)
|
||||
},
|
||||
[baseUrl, values]
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
|
||||
@@ -54,24 +54,28 @@ export default function Main({
|
||||
const searchTimeout = useRef(0)
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
function performSearch(query: string): void {
|
||||
setSearchIsInProgress(false)
|
||||
const performSearch = React.useCallback(
|
||||
function (query: string): void {
|
||||
setSearchIsInProgress(false)
|
||||
|
||||
setQueryIsTooShort(query.length === 1)
|
||||
setQueryIsTooShort(query.length === 1)
|
||||
|
||||
if (query.length >= 2) {
|
||||
const flat = ServiceDefinitionSetHelper.create(services)
|
||||
.notDeprecated()
|
||||
.search(query)
|
||||
.toArray()
|
||||
setSearchResults(groupBy(flat, 'category'))
|
||||
} else {
|
||||
setSearchResults(undefined)
|
||||
}
|
||||
}
|
||||
if (query.length >= 2) {
|
||||
const flat = ServiceDefinitionSetHelper.create(services)
|
||||
.notDeprecated()
|
||||
.search(query)
|
||||
.toArray()
|
||||
setSearchResults(groupBy(flat, 'category'))
|
||||
} else {
|
||||
setSearchResults(undefined)
|
||||
}
|
||||
},
|
||||
[setSearchIsInProgress, setQueryIsTooShort, setSearchResults]
|
||||
)
|
||||
|
||||
function searchQueryChanged(query: string): void {
|
||||
/*
|
||||
const searchQueryChanged = React.useCallback(
|
||||
function (query: string): void {
|
||||
/*
|
||||
Add a small delay before showing search results
|
||||
so that we wait until the user has stopped typing
|
||||
before we start loading stuff.
|
||||
@@ -81,22 +85,27 @@ export default function Main({
|
||||
b) stops the page from 'flashing' as the user types, like this:
|
||||
https://user-images.githubusercontent.com/7288322/42600206-9b278470-85b5-11e8-9f63-eb4a0c31cb4a.gif
|
||||
*/
|
||||
setSearchIsInProgress(true)
|
||||
window.clearTimeout(searchTimeout.current)
|
||||
searchTimeout.current = window.setTimeout(() => performSearch(query), 500)
|
||||
}
|
||||
setSearchIsInProgress(true)
|
||||
window.clearTimeout(searchTimeout.current)
|
||||
searchTimeout.current = window.setTimeout(() => performSearch(query), 500)
|
||||
},
|
||||
[setSearchIsInProgress, performSearch]
|
||||
)
|
||||
|
||||
function exampleClicked(
|
||||
example: RenderableExample,
|
||||
isSuggestion: boolean
|
||||
): void {
|
||||
setSelectedExample(example)
|
||||
setSelectedExampleIsSuggestion(isSuggestion)
|
||||
}
|
||||
const exampleClicked = React.useCallback(
|
||||
function (example: RenderableExample, isSuggestion: boolean): void {
|
||||
setSelectedExample(example)
|
||||
setSelectedExampleIsSuggestion(isSuggestion)
|
||||
},
|
||||
[setSelectedExample, setSelectedExampleIsSuggestion]
|
||||
)
|
||||
|
||||
function dismissMarkupModal(): void {
|
||||
setSelectedExample(undefined)
|
||||
}
|
||||
const dismissMarkupModal = React.useCallback(
|
||||
function (): void {
|
||||
setSelectedExample(undefined)
|
||||
},
|
||||
[setSelectedExample]
|
||||
)
|
||||
|
||||
function Category({
|
||||
category,
|
||||
|
||||
@@ -18,21 +18,27 @@ export default function StaticBadgeMaker({
|
||||
|
||||
const isValid = values.message && values.color
|
||||
|
||||
function onChange({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement | HTMLSelectElement>): void {
|
||||
setValues({
|
||||
...values,
|
||||
[name]: value,
|
||||
})
|
||||
}
|
||||
const onChange = React.useCallback(
|
||||
function onChange({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement | HTMLSelectElement>): void {
|
||||
setValues({
|
||||
...values,
|
||||
[name]: value,
|
||||
})
|
||||
},
|
||||
[setValues, values]
|
||||
)
|
||||
|
||||
function onSubmit(e: React.FormEvent): void {
|
||||
e.preventDefault()
|
||||
const onSubmit = React.useCallback(
|
||||
function (e: React.FormEvent): void {
|
||||
e.preventDefault()
|
||||
|
||||
const { label, message, color } = values
|
||||
window.open(staticBadgeUrl({ baseUrl, label, message, color }), '_blank')
|
||||
}
|
||||
const { label, message, color } = values
|
||||
window.open(staticBadgeUrl({ baseUrl, label, message, color }), '_blank')
|
||||
},
|
||||
[baseUrl, values]
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
|
||||
@@ -41,41 +41,47 @@ export default function SuggestionAndSearch({
|
||||
const [projectUrl, setProjectUrl] = useState<string>()
|
||||
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([])
|
||||
|
||||
function onQueryChanged({
|
||||
target: { value: query },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
const isUrl = query.startsWith('https://') || query.startsWith('http://')
|
||||
setIsUrl(isUrl)
|
||||
setProjectUrl(isUrl ? query : undefined)
|
||||
const onQueryChanged = React.useCallback(
|
||||
function ({
|
||||
target: { value: query },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
const isUrl = query.startsWith('https://') || query.startsWith('http://')
|
||||
setIsUrl(isUrl)
|
||||
setProjectUrl(isUrl ? query : undefined)
|
||||
|
||||
queryChangedDebounced.current(query)
|
||||
}
|
||||
queryChangedDebounced.current(query)
|
||||
},
|
||||
[setIsUrl, setProjectUrl, queryChangedDebounced]
|
||||
)
|
||||
|
||||
async function getSuggestions(): Promise<void> {
|
||||
if (!projectUrl) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
const getSuggestions = React.useCallback(
|
||||
async function (): Promise<void> {
|
||||
if (!projectUrl) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
setInProgress(true)
|
||||
setInProgress(true)
|
||||
|
||||
const fetch = window.fetch || fetchPonyfill
|
||||
const res = await fetch(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(projectUrl)}`
|
||||
)
|
||||
let suggestions = [] as SuggestionItem[]
|
||||
try {
|
||||
const json = (await res.json()) as SuggestionResponse
|
||||
// This doesn't validate the response. The default value here prevents
|
||||
// a crash if the server returns {"err":"Disallowed"}.
|
||||
suggestions = json.suggestions || []
|
||||
} catch (e) {
|
||||
suggestions = []
|
||||
}
|
||||
const fetch = window.fetch || fetchPonyfill
|
||||
const res = await fetch(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(projectUrl)}`
|
||||
)
|
||||
let suggestions = [] as SuggestionItem[]
|
||||
try {
|
||||
const json = (await res.json()) as SuggestionResponse
|
||||
// This doesn't validate the response. The default value here prevents
|
||||
// a crash if the server returns {"err":"Disallowed"}.
|
||||
suggestions = json.suggestions || []
|
||||
} catch (e) {
|
||||
suggestions = []
|
||||
}
|
||||
|
||||
setInProgress(false)
|
||||
setSuggestions(suggestions)
|
||||
}
|
||||
setInProgress(false)
|
||||
setSuggestions(suggestions)
|
||||
},
|
||||
[setSuggestions, setInProgress, baseUrl, projectUrl]
|
||||
)
|
||||
|
||||
function renderSuggestions(): JSX.Element | null {
|
||||
if (suggestions.length === 0) {
|
||||
@@ -105,6 +111,8 @@ export default function SuggestionAndSearch({
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Warning: A future version of React will block javascript: URLs as a security precaution
|
||||
// how else to do this?
|
||||
return (
|
||||
<section>
|
||||
<form action="javascript:void 0" autoComplete="off">
|
||||
|
||||
@@ -21,11 +21,11 @@ export function getBaseUrl(): string {
|
||||
https://img.shields.io/
|
||||
*/
|
||||
try {
|
||||
const { protocol, hostname } = window.location
|
||||
const { protocol, hostname, port } = window.location
|
||||
if (['shields.io', 'www.shields.io'].includes(hostname)) {
|
||||
return 'https://img.shields.io'
|
||||
}
|
||||
return `${protocol}//${hostname}`
|
||||
return `${protocol}//${hostname}:${port}`
|
||||
} catch (e) {
|
||||
// server-side rendering
|
||||
return ''
|
||||
|
||||
9723
package-lock.json
generated
9723
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
88
package.json
88
package.json
@@ -24,7 +24,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource/lato": "^4.5.0",
|
||||
"@fontsource/lekton": "^4.5.0",
|
||||
"@sentry/node": "^6.12.0",
|
||||
"@sentry/node": "^6.13.3",
|
||||
"@shields_io/camp": "^18.1.1",
|
||||
"badge-maker": "file:badge-maker",
|
||||
"bytes": "^3.1.0",
|
||||
@@ -34,16 +34,16 @@
|
||||
"cloudflare-middleware": "^1.0.4",
|
||||
"config": "^3.3.6",
|
||||
"cross-env": "^7.0.3",
|
||||
"decamelize": "^5.0.0",
|
||||
"decamelize": "^6.0.0",
|
||||
"emojic": "^1.1.16",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"fast-xml-parser": "^3.20.0",
|
||||
"glob": "^7.1.7",
|
||||
"fast-xml-parser": "^3.21.0",
|
||||
"glob": "^7.2.0",
|
||||
"global-agent": "^3.0.0",
|
||||
"got": "11.8.2",
|
||||
"graphql": "^15.5.3",
|
||||
"graphql": "^15.6.1",
|
||||
"graphql-tag": "^2.12.5",
|
||||
"ioredis": "4.27.9",
|
||||
"ioredis": "4.28.0",
|
||||
"joi": "17.4.2",
|
||||
"joi-extension-semver": "5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
@@ -57,12 +57,12 @@
|
||||
"path-to-regexp": "^6.2.0",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"priorityqueuejs": "^2.0.0",
|
||||
"prom-client": "^13.2.0",
|
||||
"prom-client": "^14.0.0",
|
||||
"qs": "^6.10.1",
|
||||
"query-string": "^7.0.1",
|
||||
"request": "~2.88.2",
|
||||
"semver": "~7.3.5",
|
||||
"simple-icons": "5.14.0",
|
||||
"simple-icons": "5.20.0",
|
||||
"webextension-store-meta": "^1.0.4",
|
||||
"xmldom": "~0.6.0",
|
||||
"xpath": "~0.0.32"
|
||||
@@ -142,25 +142,25 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/core": "^7.15.8",
|
||||
"@babel/polyfill": "^7.12.1",
|
||||
"@babel/register": "7.15.3",
|
||||
"@mapbox/react-click-to-select": "^2.2.1",
|
||||
"@types/chai": "^4.2.21",
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/lodash.groupby": "^4.6.6",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^16.7.10",
|
||||
"@types/react-helmet": "^6.1.2",
|
||||
"@types/react-modal": "^3.12.1",
|
||||
"@types/react-helmet": "^6.1.4",
|
||||
"@types/react-modal": "^3.13.1",
|
||||
"@types/react-select": "^4.0.17",
|
||||
"@types/styled-components": "5.1.14",
|
||||
"@typescript-eslint/eslint-plugin": "^4.31.0",
|
||||
"@typescript-eslint/parser": "^4.30.0",
|
||||
"@types/styled-components": "5.1.15",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
"@typescript-eslint/parser": "^4.32.0",
|
||||
"babel-plugin-inline-react-svg": "^2.0.1",
|
||||
"babel-plugin-istanbul": "^6.0.0",
|
||||
"babel-preset-gatsby": "^1.13.0",
|
||||
"c8": "^7.9.0",
|
||||
"babel-plugin-istanbul": "^6.1.1",
|
||||
"babel-preset-gatsby": "^2.0.0",
|
||||
"c8": "^7.10.0",
|
||||
"caller": "^1.0.1",
|
||||
"chai": "^4.3.4",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
@@ -168,9 +168,9 @@
|
||||
"chai-string": "^1.4.0",
|
||||
"child-process-promise": "^2.2.1",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"concurrently": "^6.2.1",
|
||||
"cypress": "^8.4.0",
|
||||
"danger": "^10.6.6",
|
||||
"concurrently": "^6.3.0",
|
||||
"cypress": "^8.7.0",
|
||||
"danger": "^10.7.0",
|
||||
"danger-plugin-no-test-shortcuts": "^2.0.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"eslint": "^7.32.0",
|
||||
@@ -180,41 +180,41 @@
|
||||
"eslint-config-standard-react": "^11.0.1",
|
||||
"eslint-plugin-chai-friendly": "^0.7.2",
|
||||
"eslint-plugin-cypress": "^2.12.1",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-jsdoc": "^36.1.0",
|
||||
"eslint-plugin-import": "^2.25.2",
|
||||
"eslint-plugin-jsdoc": "^37.0.3",
|
||||
"eslint-plugin-mocha": "^9.0.0",
|
||||
"eslint-plugin-no-extension-in-require": "^0.2.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"eslint-plugin-react": "^7.24.0",
|
||||
"eslint-plugin-promise": "^5.1.1",
|
||||
"eslint-plugin-react": "^7.26.1",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"eslint-plugin-sort-class-members": "^1.11.0",
|
||||
"eslint-plugin-sort-class-members": "^1.12.0",
|
||||
"fetch-ponyfill": "^7.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
"gatsby": "3.13.1",
|
||||
"gatsby-plugin-catch-links": "^3.13.0",
|
||||
"gatsby-plugin-page-creator": "^3.13.0",
|
||||
"gatsby-plugin-react-helmet": "^4.13.0",
|
||||
"gatsby-plugin-remove-trailing-slashes": "^3.13.0",
|
||||
"gatsby-plugin-styled-components": "^4.13.0",
|
||||
"gatsby-plugin-typescript": "^3.2.0",
|
||||
"humanize-string": "^2.1.0",
|
||||
"gatsby": "3.14.5",
|
||||
"gatsby-plugin-catch-links": "^3.14.0",
|
||||
"gatsby-plugin-page-creator": "^3.14.0",
|
||||
"gatsby-plugin-react-helmet": "^4.14.0",
|
||||
"gatsby-plugin-remove-trailing-slashes": "^3.14.0",
|
||||
"gatsby-plugin-styled-components": "^4.14.0",
|
||||
"gatsby-plugin-typescript": "^3.14.0",
|
||||
"humanize-string": "^3.0.0",
|
||||
"icedfrisby": "4.0.0",
|
||||
"icedfrisby-nock": "^2.1.0",
|
||||
"is-svg": "^4.3.1",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdoc": "^3.6.7",
|
||||
"lint-staged": "^11.1.2",
|
||||
"lint-staged": "^11.2.6",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"minimist": "^1.2.5",
|
||||
"mocha": "^9.1.1",
|
||||
"mocha": "^9.1.3",
|
||||
"mocha-env-reporter": "^4.0.0",
|
||||
"mocha-junit-reporter": "^2.0.0",
|
||||
"mocha-junit-reporter": "^2.0.2",
|
||||
"mocha-yaml-loader": "^1.0.3",
|
||||
"nock": "13.1.3",
|
||||
"node-mocks-http": "^1.10.1",
|
||||
"nodemon": "^2.0.12",
|
||||
"nock": "13.1.4",
|
||||
"node-mocks-http": "^1.11.0",
|
||||
"nodemon": "^2.0.14",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"open-cli": "^7.0.1",
|
||||
"portfinder": "^1.0.28",
|
||||
@@ -230,15 +230,15 @@
|
||||
"redis-server": "^1.2.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"sazerac": "^2.0.0",
|
||||
"simple-git-hooks": "^2.6.1",
|
||||
"simple-git-hooks": "^2.7.0",
|
||||
"sinon": "^11.1.2",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"snap-shot-it": "^7.9.6",
|
||||
"start-server-and-test": "1.14.0",
|
||||
"styled-components": "^5.3.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"ts-mocha": "^8.0.0",
|
||||
"tsd": "^0.17.0",
|
||||
"typescript": "^4.4.3"
|
||||
"tsd": "^0.18.0",
|
||||
"typescript": "^4.4.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.1",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env fish
|
||||
#
|
||||
# Back up the GitHub tokens from each production server.
|
||||
#
|
||||
|
||||
if test (count $argv) -lt 1
|
||||
echo Usage: (basename (status -f)) shields_secret
|
||||
end
|
||||
|
||||
set shields_secret $argv[1]
|
||||
|
||||
function do_backup
|
||||
set server $argv[1]
|
||||
curl --insecure -u ":$shields_secret" "https://$server.servers.shields.io/\$github-auth/tokens" > "$server""_tokens.json"
|
||||
end
|
||||
|
||||
for server in s0 s1 s2
|
||||
do_backup $server
|
||||
end
|
||||
@@ -1,5 +1,4 @@
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { downloadCount } from '../color-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { redirector } from '../index.js'
|
||||
import { BaseAmoService, keywords } from './amo-base.js'
|
||||
|
||||
@@ -28,10 +27,7 @@ class AmoWeeklyDownloads extends BaseAmoService {
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
static render({ downloads }) {
|
||||
return {
|
||||
message: `${metric(downloads)}/week`,
|
||||
color: downloadCount(downloads),
|
||||
}
|
||||
return renderDownloadsBadge({ downloads, interval: 'week' })
|
||||
}
|
||||
|
||||
async handle({ addonId }) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { BaseAmoService, keywords } from './amo-base.js'
|
||||
|
||||
export default class AmoUsers extends BaseAmoService {
|
||||
@@ -16,11 +16,8 @@ export default class AmoUsers extends BaseAmoService {
|
||||
|
||||
static defaultBadgeData = { label: 'users' }
|
||||
|
||||
static render({ users }) {
|
||||
return {
|
||||
message: metric(users),
|
||||
color: 'blue',
|
||||
}
|
||||
static render({ users: downloads }) {
|
||||
return renderDownloadsBadge({ downloads, colorOverride: 'blue' })
|
||||
}
|
||||
|
||||
async handle({ addonId }) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Joi from 'joi'
|
||||
import { downloadCount } from '../color-formatters.js'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
|
||||
@@ -32,22 +31,15 @@ class AnsibleGalaxyRoleDownloads extends AnsibleGalaxyRole {
|
||||
{
|
||||
title: 'Ansible Role',
|
||||
namedParams: { roleId: '3078' },
|
||||
staticPreview: this.render({ downloads: 76 }),
|
||||
staticPreview: renderDownloadsBadge({ downloads: 76 }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'role downloads' }
|
||||
|
||||
static render({ downloads }) {
|
||||
return {
|
||||
message: metric(downloads),
|
||||
color: downloadCount(downloads),
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ roleId }) {
|
||||
const json = await this.fetch({ roleId })
|
||||
return this.constructor.render({ downloads: json.download_count })
|
||||
return renderDownloadsBadge({ downloads: json.download_count })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Joi from 'joi'
|
||||
import { renderLicenseBadge } from '../licenses.js'
|
||||
import { renderVersionBadge } from '../version.js'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { BaseJsonService, InvalidResponse } from '../index.js'
|
||||
|
||||
@@ -45,7 +45,7 @@ class APMDownloads extends BaseAPMService {
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
static render({ downloads }) {
|
||||
return { message: metric(downloads), color: 'green' }
|
||||
return renderDownloadsBadge({ downloads, colorOverride: 'green' })
|
||||
}
|
||||
|
||||
async handle({ packageName }) {
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
import {
|
||||
testResultQueryParamSchema,
|
||||
renderTestResultBadge,
|
||||
documentation,
|
||||
} from '../test-results.js'
|
||||
import AppVeyorBase from './appveyor-base.js'
|
||||
|
||||
const documentation = `
|
||||
<p>
|
||||
You may change the "passed", "failed" and "skipped" text on this badge by supplying query parameters <code>&passed_label=</code>, <code>&failed_label=</code> and <code>&skipped_label=</code> respectively.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
For example, if you want to use a different terminology:
|
||||
<br>
|
||||
<code>/appveyor/tests/NZSmartie/coap-net-iu0to.svg?passed_label=good&failed_label=bad&skipped_label=n%2Fa</code>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Or symbols:
|
||||
<br>
|
||||
<code>/appveyor/tests/NZSmartie/coap-net-iu0to.svg?compact_message&passed_label=%F0%9F%8E%89&failed_label=%F0%9F%92%A2&skipped_label=%F0%9F%A4%B7</code>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
There is also a <code>&compact_message</code> query parameter, which will default to displaying ✔, ✘ and ➟, separated by a horizontal bar |.
|
||||
</p>
|
||||
`
|
||||
|
||||
const commonPreviewProps = {
|
||||
passed: 477,
|
||||
failed: 2,
|
||||
|
||||
@@ -1,38 +1,26 @@
|
||||
import queryString from 'querystring'
|
||||
import Joi from 'joi'
|
||||
import {
|
||||
isDefaultTestTotals,
|
||||
isDefaultCompactTestTotals,
|
||||
isCustomTestTotals,
|
||||
isCustomCompactTestTotals,
|
||||
} from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
const isAppveyorTestTotals = Joi.string().regex(
|
||||
/^[0-9]+ passed(, [0-9]+ failed)?(, [0-9]+ skipped)?$/
|
||||
)
|
||||
|
||||
const isCompactAppveyorTestTotals = Joi.string().regex(
|
||||
/^✔ [0-9]+( \| ✘ [0-9]+)?( \| ➟ [0-9]+)?$/
|
||||
)
|
||||
|
||||
const isCustomAppveyorTestTotals = Joi.string().regex(
|
||||
/^[0-9]+ good(, [0-9]+ bad)?(, [0-9]+ n\/a)?$/
|
||||
)
|
||||
|
||||
const isCompactCustomAppveyorTestTotals = Joi.string().regex(
|
||||
/^💃 [0-9]+( \| 🤦♀️ [0-9]+)?( \| 🤷 [0-9]+)?$/
|
||||
)
|
||||
|
||||
t.create('Test status')
|
||||
.timeout(10000)
|
||||
.get('/NZSmartie/coap-net-iu0to.json')
|
||||
.expectBadge({ label: 'tests', message: isAppveyorTestTotals })
|
||||
.expectBadge({ label: 'tests', message: isDefaultTestTotals })
|
||||
|
||||
t.create('Test status on branch')
|
||||
.timeout(10000)
|
||||
.get('/NZSmartie/coap-net-iu0to/master.json')
|
||||
.expectBadge({ label: 'tests', message: isAppveyorTestTotals })
|
||||
.expectBadge({ label: 'tests', message: isDefaultTestTotals })
|
||||
|
||||
t.create('Test status with compact message')
|
||||
.timeout(10000)
|
||||
.get('/NZSmartie/coap-net-iu0to.json?compact_message')
|
||||
.expectBadge({ label: 'tests', message: isCompactAppveyorTestTotals })
|
||||
.expectBadge({ label: 'tests', message: isDefaultCompactTestTotals })
|
||||
|
||||
t.create('Test status with custom labels')
|
||||
.timeout(10000)
|
||||
@@ -43,21 +31,21 @@ t.create('Test status with custom labels')
|
||||
skipped_label: 'n/a',
|
||||
},
|
||||
})
|
||||
.expectBadge({ label: 'tests', message: isCustomAppveyorTestTotals })
|
||||
.expectBadge({ label: 'tests', message: isCustomTestTotals })
|
||||
|
||||
t.create('Test status with compact message and custom labels')
|
||||
.timeout(10000)
|
||||
.get(
|
||||
`/NZSmartie/coap-net-iu0to.json?${queryString.stringify({
|
||||
.get('/NZSmartie/coap-net-iu0to.json', {
|
||||
qs: {
|
||||
compact_message: null,
|
||||
passed_label: '💃',
|
||||
failed_label: '🤦♀️',
|
||||
skipped_label: '🤷',
|
||||
})}`
|
||||
)
|
||||
},
|
||||
})
|
||||
.expectBadge({
|
||||
label: 'tests',
|
||||
message: isCompactCustomAppveyorTestTotals,
|
||||
message: isCustomCompactTestTotals,
|
||||
})
|
||||
|
||||
t.create('Test status on non-existent project')
|
||||
|
||||
@@ -2,11 +2,24 @@ import Joi from 'joi'
|
||||
import {
|
||||
testResultQueryParamSchema,
|
||||
renderTestResultBadge,
|
||||
documentation as commonDocumentation,
|
||||
} from '../test-results.js'
|
||||
import AzureDevOpsBase from './azure-devops-base.js'
|
||||
|
||||
const commonAttrs = {
|
||||
keywords: ['vso', 'vsts', 'azure-devops'],
|
||||
namedParams: {
|
||||
organization: 'azuredevops-powershell',
|
||||
project: 'azuredevops-powershell',
|
||||
definitionId: '1',
|
||||
branch: 'master',
|
||||
},
|
||||
queryParams: {
|
||||
passed_label: 'passed',
|
||||
failed_label: 'failed',
|
||||
skipped_label: 'skipped',
|
||||
compact_message: null,
|
||||
},
|
||||
documentation: `
|
||||
<p>
|
||||
To obtain your own badge, you need to get 3 pieces of information:
|
||||
@@ -26,19 +39,7 @@ const commonAttrs = {
|
||||
Optionally, you can specify a named branch:
|
||||
<code>https://img.shields.io/azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID/NAMED_BRANCH.svg</code>.
|
||||
</p>
|
||||
<p>
|
||||
You may change the "passed", "failed" and "skipped" text on this badge by supplying query parameters <code>&passed_label=</code>, <code>&failed_label=</code> and <code>&skipped_label=</code> respectively.
|
||||
<br>
|
||||
There is also a <code>&compact_message</code> query parameter, which will default to displaying ✔, ✘ and ➟, separated by a horizontal bar |.
|
||||
<br>
|
||||
For example, if you want to use a different terminology:
|
||||
<br>
|
||||
<code>/azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID.svg?passed_label=good&failed_label=bad&skipped_label=n%2Fa</code>
|
||||
<br>
|
||||
Or, use symbols:
|
||||
<br>
|
||||
<code>/azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID.svg?compact_message&passed_label=%F0%9F%8E%89&failed_label=%F0%9F%92%A2&skipped_label=%F0%9F%A4%B7</code>
|
||||
</p>
|
||||
${commonDocumentation}
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -71,29 +72,6 @@ export default class AzureDevOpsTests extends AzureDevOpsBase {
|
||||
static examples = [
|
||||
{
|
||||
title: 'Azure DevOps tests',
|
||||
pattern: ':organization/:project/:definitionId',
|
||||
namedParams: {
|
||||
organization: 'azuredevops-powershell',
|
||||
project: 'azuredevops-powershell',
|
||||
definitionId: '1',
|
||||
},
|
||||
staticPreview: this.render({
|
||||
passed: 20,
|
||||
failed: 1,
|
||||
skipped: 1,
|
||||
total: 22,
|
||||
}),
|
||||
...commonAttrs,
|
||||
},
|
||||
{
|
||||
title: 'Azure DevOps tests (branch)',
|
||||
pattern: ':organization/:project/:definitionId/:branch',
|
||||
namedParams: {
|
||||
organization: 'azuredevops-powershell',
|
||||
project: 'azuredevops-powershell',
|
||||
definitionId: '1',
|
||||
branch: 'master',
|
||||
},
|
||||
staticPreview: this.render({
|
||||
passed: 20,
|
||||
failed: 1,
|
||||
@@ -104,15 +82,6 @@ export default class AzureDevOpsTests extends AzureDevOpsBase {
|
||||
},
|
||||
{
|
||||
title: 'Azure DevOps tests (compact)',
|
||||
pattern: ':organization/:project/:definitionId',
|
||||
namedParams: {
|
||||
organization: 'azuredevops-powershell',
|
||||
project: 'azuredevops-powershell',
|
||||
definitionId: '1',
|
||||
},
|
||||
queryParams: {
|
||||
compact_message: null,
|
||||
},
|
||||
staticPreview: this.render({
|
||||
passed: 20,
|
||||
failed: 1,
|
||||
@@ -124,16 +93,11 @@ export default class AzureDevOpsTests extends AzureDevOpsBase {
|
||||
},
|
||||
{
|
||||
title: 'Azure DevOps tests with custom labels',
|
||||
pattern: ':organization/:project/:definitionId',
|
||||
namedParams: {
|
||||
organization: 'azuredevops-powershell',
|
||||
project: 'azuredevops-powershell',
|
||||
definitionId: '1',
|
||||
},
|
||||
queryParams: {
|
||||
passed_label: 'good',
|
||||
failed_label: 'bad',
|
||||
skipped_label: 'n/a',
|
||||
compact_message: null,
|
||||
},
|
||||
staticPreview: this.render({
|
||||
passed: 20,
|
||||
@@ -172,15 +136,16 @@ export default class AzureDevOpsTests extends AzureDevOpsBase {
|
||||
})
|
||||
}
|
||||
|
||||
async handle(
|
||||
{ organization, project, definitionId, branch },
|
||||
{
|
||||
compact_message: compactMessage,
|
||||
passed_label: passedLabel,
|
||||
failed_label: failedLabel,
|
||||
skipped_label: skippedLabel,
|
||||
}
|
||||
) {
|
||||
static transform({ aggregatedResultsAnalysis }) {
|
||||
const { totalTests: total, resultsByOutcome } = aggregatedResultsAnalysis
|
||||
const passed = resultsByOutcome.Passed ? resultsByOutcome.Passed.count : 0
|
||||
const failed = resultsByOutcome.Failed ? resultsByOutcome.Failed.count : 0
|
||||
// assume the rest has been skipped
|
||||
const skipped = total - passed - failed
|
||||
return { passed, failed, skipped, total }
|
||||
}
|
||||
|
||||
async fetchTestResults({ organization, project, definitionId, branch }) {
|
||||
const errorMessages = {
|
||||
404: 'build pipeline or test result summary not found',
|
||||
}
|
||||
@@ -193,8 +158,7 @@ export default class AzureDevOpsTests extends AzureDevOpsBase {
|
||||
)
|
||||
|
||||
// https://dev.azure.com/azuredevops-powershell/azuredevops-powershell/_apis/test/ResultSummaryByBuild?buildId=20
|
||||
|
||||
const json = await this.fetch({
|
||||
return await this.fetch({
|
||||
url: `https://dev.azure.com/${organization}/${project}/_apis/test/ResultSummaryByBuild`,
|
||||
options: {
|
||||
qs: { buildId },
|
||||
@@ -202,24 +166,24 @@ export default class AzureDevOpsTests extends AzureDevOpsBase {
|
||||
schema: buildTestResultSummarySchema,
|
||||
errorMessages,
|
||||
})
|
||||
}
|
||||
|
||||
const total = json.aggregatedResultsAnalysis.totalTests
|
||||
|
||||
let passed = 0
|
||||
const passedTests = json.aggregatedResultsAnalysis.resultsByOutcome.Passed
|
||||
if (passedTests) {
|
||||
passed = passedTests.count
|
||||
async handle(
|
||||
{ organization, project, definitionId, branch },
|
||||
{
|
||||
compact_message: compactMessage,
|
||||
passed_label: passedLabel,
|
||||
failed_label: failedLabel,
|
||||
skipped_label: skippedLabel,
|
||||
}
|
||||
|
||||
let failed = 0
|
||||
const failedTests = json.aggregatedResultsAnalysis.resultsByOutcome.Failed
|
||||
if (failedTests) {
|
||||
failed = failedTests.count
|
||||
}
|
||||
|
||||
// assume the rest has been skipped
|
||||
const skipped = total - passed - failed
|
||||
const isCompact = compactMessage !== undefined
|
||||
) {
|
||||
const json = await this.fetchTestResults({
|
||||
organization,
|
||||
project,
|
||||
definitionId,
|
||||
branch,
|
||||
})
|
||||
const { passed, failed, skipped, total } = this.constructor.transform(json)
|
||||
return this.constructor.render({
|
||||
passed,
|
||||
failed,
|
||||
@@ -228,7 +192,7 @@ export default class AzureDevOpsTests extends AzureDevOpsBase {
|
||||
passedLabel,
|
||||
failedLabel,
|
||||
skippedLabel,
|
||||
isCompact,
|
||||
isCompact: compactMessage !== undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +1,27 @@
|
||||
import Joi from 'joi'
|
||||
import {
|
||||
isDefaultTestTotals,
|
||||
isDefaultCompactTestTotals,
|
||||
isCustomTestTotals,
|
||||
isCustomCompactTestTotals,
|
||||
} from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
const org = 'azuredevops-powershell'
|
||||
const project = 'azuredevops-powershell'
|
||||
const definitionId = 1
|
||||
const nonExistentDefinitionId = 9999
|
||||
const buildId = 20
|
||||
const uriPrefix = `/${org}/${project}`
|
||||
const azureDevOpsApiBaseUri = `https://dev.azure.com/${org}/${project}/_apis`
|
||||
const mockBadgeUriPath = `${uriPrefix}/${definitionId}`
|
||||
const mockBadgeUri = `${mockBadgeUriPath}.json`
|
||||
const mockBranchBadgeUri = `${mockBadgeUriPath}/master.json`
|
||||
const mockLatestBuildApiUriPath = `/build/builds?definitions=${definitionId}&%24top=1&statusFilter=completed&api-version=5.0-preview.4`
|
||||
const mockLatestBranchBuildApiUriPath = `/build/builds?definitions=${definitionId}&%24top=1&statusFilter=completed&api-version=5.0-preview.4&branchName=refs%2Fheads%2Fmaster`
|
||||
const mockNonExistentBuildApiUriPath = `/build/builds?definitions=${nonExistentDefinitionId}&%24top=1&statusFilter=completed&api-version=5.0-preview.4`
|
||||
const mockTestResultSummaryApiUriPath = `/test/ResultSummaryByBuild?buildId=${buildId}`
|
||||
const latestBuildResponse = {
|
||||
count: 1,
|
||||
value: [{ id: buildId }],
|
||||
}
|
||||
const mockEmptyTestResultSummaryResponse = {
|
||||
aggregatedResultsAnalysis: {
|
||||
totalTests: 0,
|
||||
resultsByOutcome: {},
|
||||
},
|
||||
}
|
||||
const mockTestResultSummaryResponse = {
|
||||
aggregatedResultsAnalysis: {
|
||||
totalTests: 3,
|
||||
resultsByOutcome: {
|
||||
Passed: {
|
||||
count: 1,
|
||||
},
|
||||
Failed: {
|
||||
count: 1,
|
||||
},
|
||||
Skipped: {
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const mockTestResultSummarySetup = nock =>
|
||||
nock(azureDevOpsApiBaseUri)
|
||||
.get(mockLatestBuildApiUriPath)
|
||||
.reply(200, latestBuildResponse)
|
||||
.get(mockTestResultSummaryApiUriPath)
|
||||
.reply(200, mockTestResultSummaryResponse)
|
||||
const mockBranchTestResultSummarySetup = nock =>
|
||||
nock(azureDevOpsApiBaseUri)
|
||||
.get(mockLatestBranchBuildApiUriPath)
|
||||
.reply(200, latestBuildResponse)
|
||||
.get(mockTestResultSummaryApiUriPath)
|
||||
.reply(200, mockTestResultSummaryResponse)
|
||||
|
||||
const expectedDefaultAzureDevOpsTestTotals = '1 passed, 1 failed, 1 skipped'
|
||||
const expectedCompactAzureDevOpsTestTotals = '✔ 1 | ✘ 1 | ➟ 1'
|
||||
const expectedCustomAzureDevOpsTestTotals = '1 good, 1 bad, 1 n/a'
|
||||
const expectedCompactCustomAzureDevOpsTestTotals = '💃 1 | 🤦♀️ 1 | 🤷 1'
|
||||
|
||||
function getLabelRegex(label, isCompact) {
|
||||
return isCompact ? `(?:${label} [0-9]*)` : `(?:[0-9]* ${label})`
|
||||
}
|
||||
|
||||
function isAzureDevOpsTestTotals(
|
||||
passedLabel,
|
||||
failedLabel,
|
||||
skippedLabel,
|
||||
isCompact
|
||||
) {
|
||||
const passedRegex = getLabelRegex(passedLabel, isCompact)
|
||||
const failedRegex = getLabelRegex(failedLabel, isCompact)
|
||||
const skippedRegex = getLabelRegex(skippedLabel, isCompact)
|
||||
const separator = isCompact ? ' | ' : ', '
|
||||
const regexStrings = [
|
||||
`^${passedRegex}$`,
|
||||
`^${failedRegex}$`,
|
||||
`^${skippedRegex}$`,
|
||||
`^${passedRegex}${separator}${failedRegex}$`,
|
||||
`^${failedRegex}${separator}${skippedRegex}$`,
|
||||
`^${passedRegex}${separator}${skippedRegex}$`,
|
||||
`^${passedRegex}${separator}${failedRegex}${separator}${skippedRegex}$`,
|
||||
`^no tests$`,
|
||||
]
|
||||
|
||||
return Joi.alternatives().try(
|
||||
...regexStrings.map(regexStr => Joi.string().regex(new RegExp(regexStr)))
|
||||
)
|
||||
}
|
||||
|
||||
const isDefaultAzureDevOpsTestTotals = isAzureDevOpsTestTotals(
|
||||
'passed',
|
||||
'failed',
|
||||
'skipped'
|
||||
)
|
||||
const isCompactAzureDevOpsTestTotals = isAzureDevOpsTestTotals(
|
||||
'✔',
|
||||
'✘',
|
||||
'➟',
|
||||
true
|
||||
)
|
||||
const isCustomAzureDevOpsTestTotals = isAzureDevOpsTestTotals(
|
||||
'good',
|
||||
'bad',
|
||||
'n\\/a'
|
||||
)
|
||||
const isCompactCustomAzureDevOpsTestTotals = isAzureDevOpsTestTotals(
|
||||
'💃',
|
||||
'🤦♀️',
|
||||
'🤷',
|
||||
true
|
||||
)
|
||||
|
||||
t.create('unknown build definition')
|
||||
.get(`${uriPrefix}/${nonExistentDefinitionId}.json`)
|
||||
.get(`/swellaby/opensource/99999999.json`)
|
||||
.expectBadge({ label: 'tests', message: 'build pipeline not found' })
|
||||
|
||||
t.create('404 latest build error response')
|
||||
.get(mockBadgeUri)
|
||||
.get('/swellaby/fake/14.json')
|
||||
.intercept(nock =>
|
||||
nock(azureDevOpsApiBaseUri).get(mockLatestBuildApiUriPath).reply(404)
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'tests',
|
||||
message: 'build pipeline or test result summary not found',
|
||||
})
|
||||
|
||||
t.create('no build response')
|
||||
.get(`${uriPrefix}/${nonExistentDefinitionId}.json`)
|
||||
.intercept(nock =>
|
||||
nock(azureDevOpsApiBaseUri).get(mockNonExistentBuildApiUriPath).reply(200, {
|
||||
count: 0,
|
||||
value: [],
|
||||
})
|
||||
)
|
||||
.expectBadge({ label: 'tests', message: 'build pipeline not found' })
|
||||
|
||||
t.create('no test result summary response')
|
||||
.get(mockBadgeUri)
|
||||
.intercept(nock =>
|
||||
nock(azureDevOpsApiBaseUri)
|
||||
.get(mockLatestBuildApiUriPath)
|
||||
.reply(200, latestBuildResponse)
|
||||
.get(mockTestResultSummaryApiUriPath)
|
||||
nock('https://dev.azure.com/swellaby/fake/_apis')
|
||||
.get('/build/builds')
|
||||
.query({
|
||||
definitions: 14,
|
||||
$top: 1,
|
||||
statusFilter: 'completed',
|
||||
'api-version': '5.0-preview.4',
|
||||
})
|
||||
.reply(404)
|
||||
)
|
||||
.expectBadge({
|
||||
@@ -151,113 +29,73 @@ t.create('no test result summary response')
|
||||
message: 'build pipeline or test result summary not found',
|
||||
})
|
||||
|
||||
t.create('invalid test result summary response')
|
||||
.get(mockBadgeUri)
|
||||
t.create('no test result summary response')
|
||||
.get('/swellaby/fake/14.json')
|
||||
.intercept(nock =>
|
||||
nock(azureDevOpsApiBaseUri)
|
||||
.get(mockLatestBuildApiUriPath)
|
||||
.reply(200, latestBuildResponse)
|
||||
.get(mockTestResultSummaryApiUriPath)
|
||||
.reply(200, {})
|
||||
nock('https://dev.azure.com/swellaby/fake/_apis')
|
||||
.get('/build/builds')
|
||||
.query({
|
||||
definitions: 14,
|
||||
$top: 1,
|
||||
statusFilter: 'completed',
|
||||
'api-version': '5.0-preview.4',
|
||||
})
|
||||
.reply(200, { count: 1, value: [{ id: 1234 }] })
|
||||
.get('/test/ResultSummaryByBuild')
|
||||
.query({ buildId: 1234 })
|
||||
.reply(404)
|
||||
)
|
||||
.expectBadge({ label: 'tests', message: 'invalid response data' })
|
||||
|
||||
t.create('no tests in test result summary response')
|
||||
.get(mockBadgeUri)
|
||||
.intercept(nock =>
|
||||
nock(azureDevOpsApiBaseUri)
|
||||
.get(mockLatestBuildApiUriPath)
|
||||
.reply(200, latestBuildResponse)
|
||||
.get(mockTestResultSummaryApiUriPath)
|
||||
.reply(200, mockEmptyTestResultSummaryResponse)
|
||||
)
|
||||
.expectBadge({ label: 'tests', message: 'no tests' })
|
||||
|
||||
t.create('test status')
|
||||
.get(mockBadgeUri)
|
||||
.intercept(mockTestResultSummarySetup)
|
||||
.expectBadge({
|
||||
label: 'tests',
|
||||
message: expectedDefaultAzureDevOpsTestTotals,
|
||||
message: 'build pipeline or test result summary not found',
|
||||
})
|
||||
|
||||
t.create('no build response')
|
||||
.get(`/swellaby/opensource/174.json`)
|
||||
.expectBadge({ label: 'tests', message: 'build pipeline not found' })
|
||||
|
||||
t.create('no tests in test result summary response')
|
||||
.get('/swellaby/opensource/14.json')
|
||||
.expectBadge({ label: 'tests', message: 'no tests' })
|
||||
|
||||
t.create('test status').get('/swellaby/opensource/25.json').expectBadge({
|
||||
label: 'tests',
|
||||
message: isDefaultTestTotals,
|
||||
})
|
||||
|
||||
t.create('test status on branch')
|
||||
.get(mockBranchBadgeUri)
|
||||
.intercept(mockBranchTestResultSummarySetup)
|
||||
.get('/swellaby/opensource/25/master.json')
|
||||
.expectBadge({
|
||||
label: 'tests',
|
||||
message: expectedDefaultAzureDevOpsTestTotals,
|
||||
message: isDefaultTestTotals,
|
||||
})
|
||||
|
||||
t.create('test status with compact message')
|
||||
.get(mockBadgeUri, {
|
||||
.get('/swellaby/opensource/25.json', {
|
||||
qs: {
|
||||
compact_message: null,
|
||||
},
|
||||
})
|
||||
.intercept(mockTestResultSummarySetup)
|
||||
.expectBadge({
|
||||
label: 'tests',
|
||||
message: expectedCompactAzureDevOpsTestTotals,
|
||||
message: isDefaultCompactTestTotals,
|
||||
})
|
||||
|
||||
t.create('test status with custom labels')
|
||||
.get(mockBadgeUri, {
|
||||
.get('/swellaby/opensource/25.json', {
|
||||
qs: {
|
||||
passed_label: 'good',
|
||||
failed_label: 'bad',
|
||||
skipped_label: 'n/a',
|
||||
},
|
||||
})
|
||||
.intercept(mockTestResultSummarySetup)
|
||||
.expectBadge({
|
||||
label: 'tests',
|
||||
message: expectedCustomAzureDevOpsTestTotals,
|
||||
message: isCustomTestTotals,
|
||||
})
|
||||
|
||||
t.create('test status with compact message and custom labels')
|
||||
.get(mockBadgeUri, {
|
||||
qs: {
|
||||
compact_message: null,
|
||||
passed_label: '💃',
|
||||
failed_label: '🤦♀️',
|
||||
skipped_label: '🤷',
|
||||
},
|
||||
})
|
||||
.intercept(mockTestResultSummarySetup)
|
||||
.expectBadge({
|
||||
label: 'tests',
|
||||
message: expectedCompactCustomAzureDevOpsTestTotals,
|
||||
})
|
||||
|
||||
t.create('live test status')
|
||||
.get(mockBadgeUri)
|
||||
.expectBadge({ label: 'tests', message: isDefaultAzureDevOpsTestTotals })
|
||||
|
||||
t.create('live test status on branch')
|
||||
.get(mockBranchBadgeUri)
|
||||
.expectBadge({ label: 'tests', message: isDefaultAzureDevOpsTestTotals })
|
||||
|
||||
t.create('live test status with compact message')
|
||||
.get(mockBadgeUri, {
|
||||
qs: {
|
||||
compact_message: null,
|
||||
},
|
||||
})
|
||||
.expectBadge({ label: 'tests', message: isCompactAzureDevOpsTestTotals })
|
||||
|
||||
t.create('live test status with custom labels')
|
||||
.get(mockBadgeUri, {
|
||||
qs: {
|
||||
passed_label: 'good',
|
||||
failed_label: 'bad',
|
||||
skipped_label: 'n/a',
|
||||
},
|
||||
})
|
||||
.expectBadge({ label: 'tests', message: isCustomAzureDevOpsTestTotals })
|
||||
|
||||
t.create('live test status with compact message and custom labels')
|
||||
.get(mockBadgeUri, {
|
||||
.get('/swellaby/opensource/25.json', {
|
||||
qs: {
|
||||
compact_message: null,
|
||||
passed_label: '💃',
|
||||
@@ -267,5 +105,5 @@ t.create('live test status with compact message and custom labels')
|
||||
})
|
||||
.expectBadge({
|
||||
label: 'tests',
|
||||
message: isCompactCustomAzureDevOpsTestTotals,
|
||||
message: isCustomCompactTestTotals,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Joi from 'joi'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import LibrariesIoBase from '../librariesio/librariesio-base.js'
|
||||
|
||||
const schema = Joi.object()
|
||||
.keys({
|
||||
@@ -17,11 +17,11 @@ const schema = Joi.object()
|
||||
})
|
||||
.required()
|
||||
|
||||
export default class BaseBowerService extends BaseJsonService {
|
||||
export default class BaseBowerService extends LibrariesIoBase {
|
||||
async fetch({ packageName }) {
|
||||
return this._requestJson({
|
||||
schema,
|
||||
url: `https://libraries.io/api/bower/${packageName}`,
|
||||
url: `/bower/${packageName}`,
|
||||
errorMessages: {
|
||||
404: 'package not found',
|
||||
},
|
||||
|
||||
@@ -6,15 +6,6 @@ t.create('licence')
|
||||
.get('/bootstrap.json')
|
||||
.expectBadge({ label: 'license', message: 'MIT' })
|
||||
|
||||
t.create('license not declared')
|
||||
.get('/bootstrap.json')
|
||||
.intercept(nock =>
|
||||
nock('https://libraries.io')
|
||||
.get('/api/bower/bootstrap')
|
||||
.reply(200, { normalized_licenses: [] })
|
||||
)
|
||||
.expectBadge({ label: 'license', message: 'missing' })
|
||||
|
||||
t.create('licence for Invalid Package')
|
||||
.timeout(10000)
|
||||
.get('/it-is-a-invalid-package-should-error.json')
|
||||
|
||||
@@ -27,9 +27,7 @@ class BowerVersion extends BaseBowerService {
|
||||
|
||||
static defaultBadgeData = { label: 'bower' }
|
||||
|
||||
async handle({ packageName }, queryParams) {
|
||||
const data = await this.fetch({ packageName })
|
||||
const includePrereleases = queryParams.include_prereleases !== undefined
|
||||
static transform(data, includePrereleases) {
|
||||
const version = includePrereleases
|
||||
? data.latest_release_number
|
||||
: data.latest_stable_release_number
|
||||
@@ -38,6 +36,14 @@ class BowerVersion extends BaseBowerService {
|
||||
throw new InvalidResponse({ prettyMessage: 'no releases' })
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
async handle({ packageName }, queryParams) {
|
||||
const data = await this.fetch({ packageName })
|
||||
const includePrereleases = queryParams.include_prereleases !== undefined
|
||||
const version = this.constructor.transform(data, includePrereleases)
|
||||
|
||||
return renderVersionBadge({ version })
|
||||
}
|
||||
}
|
||||
|
||||
92
services/bower/bower-version.spec.js
Normal file
92
services/bower/bower-version.spec.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect } from 'chai'
|
||||
import { test, given } from 'sazerac'
|
||||
import nock from 'nock'
|
||||
import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
|
||||
import { InvalidResponse } from '../index.js'
|
||||
import LibrariesIoApiProvider from '../librariesio/librariesio-api-provider.js'
|
||||
import { BowerVersion } from './bower-version.service.js'
|
||||
|
||||
describe('BowerVersion', function () {
|
||||
test(BowerVersion.transform, () => {
|
||||
given(
|
||||
{
|
||||
latest_release_number: '2.0.0-beta',
|
||||
latest_stable_release_number: '1.8.3',
|
||||
},
|
||||
false
|
||||
).expect('1.8.3')
|
||||
given(
|
||||
{
|
||||
latest_release_number: '2.0.0-beta',
|
||||
latest_stable_release_number: '1.8.3',
|
||||
},
|
||||
true
|
||||
).expect('2.0.0-beta')
|
||||
})
|
||||
|
||||
it('throws `no releases` InvalidResponse if no stable version', function () {
|
||||
expect(() =>
|
||||
BowerVersion.transform({ latest_release_number: 'panda' }, false)
|
||||
)
|
||||
.to.throw(InvalidResponse)
|
||||
.with.property('prettyMessage', 'no releases')
|
||||
})
|
||||
|
||||
it('throws `no releases` InvalidResponse if no prereleases', function () {
|
||||
expect(() =>
|
||||
BowerVersion.transform({ latest_stable_release_number: 'penguin' }, true)
|
||||
)
|
||||
.to.throw(InvalidResponse)
|
||||
.with.property('prettyMessage', 'no releases')
|
||||
})
|
||||
|
||||
context('auth', function () {
|
||||
cleanUpNockAfterEach()
|
||||
const fakeApiKey = 'fakeness'
|
||||
const response = {
|
||||
normalized_licenses: [],
|
||||
latest_release_number: '2.0.0-beta',
|
||||
latest_stable_release_number: '1.8.3',
|
||||
}
|
||||
const config = {
|
||||
private: {
|
||||
librariesio_tokens: fakeApiKey,
|
||||
},
|
||||
}
|
||||
const librariesIoApiProvider = new LibrariesIoApiProvider({
|
||||
baseUrl: 'https://libraries.io/api',
|
||||
tokens: [fakeApiKey],
|
||||
})
|
||||
|
||||
it('sends the auth information as configured', async function () {
|
||||
const scope = nock('https://libraries.io/api')
|
||||
// This ensures that the expected credentials are actually being sent with the HTTP request.
|
||||
// Without this the request wouldn't match and the test would fail.
|
||||
.get(`/bower/bootstrap?api_key=${fakeApiKey}`)
|
||||
.reply(200, response)
|
||||
|
||||
expect(
|
||||
await BowerVersion.invoke(
|
||||
{
|
||||
...defaultContext,
|
||||
librariesIoApiProvider,
|
||||
},
|
||||
config,
|
||||
{
|
||||
platform: 'bower',
|
||||
packageName: 'bootstrap',
|
||||
},
|
||||
{
|
||||
include_prereleases: '',
|
||||
}
|
||||
)
|
||||
).to.deep.equal({
|
||||
message: 'v2.0.0-beta',
|
||||
color: 'orange',
|
||||
label: undefined,
|
||||
})
|
||||
|
||||
scope.done()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -34,24 +34,6 @@ t.create('Pre Version for Invalid Package')
|
||||
.get('/v/it-is-a-invalid-package-should-error.json?include_prereleases')
|
||||
.expectBadge({ label: 'bower', message: 'package not found' })
|
||||
|
||||
t.create('Version label should be `no releases` if no stable version')
|
||||
.get('/v/bootstrap.json')
|
||||
.intercept(nock =>
|
||||
nock('https://libraries.io')
|
||||
.get('/api/bower/bootstrap')
|
||||
.reply(200, { normalized_licenses: [], latest_stable_release: null })
|
||||
)
|
||||
.expectBadge({ label: 'bower', message: 'no releases' })
|
||||
|
||||
t.create('Version label should be `no releases` if no pre-release')
|
||||
.get('/v/bootstrap.json?include_prereleases')
|
||||
.intercept(nock =>
|
||||
nock('https://libraries.io')
|
||||
.get('/api/bower/bootstrap')
|
||||
.reply(200, { normalized_licenses: [], latest_release_number: null })
|
||||
)
|
||||
.expectBadge({ label: 'bower', message: 'no releases' })
|
||||
|
||||
t.create('Version (legacy redirect: vpre)')
|
||||
.get('/vpre/bootstrap.svg')
|
||||
.expectRedirect('/bower/v/bootstrap.svg?include_prereleases')
|
||||
|
||||
@@ -12,6 +12,7 @@ const greenStatuses = [
|
||||
const orangeStatuses = ['partially succeeded', 'unstable', 'timeout']
|
||||
|
||||
const redStatuses = [
|
||||
'broken',
|
||||
'error',
|
||||
'errored',
|
||||
'failed',
|
||||
|
||||
@@ -53,6 +53,7 @@ test(renderBuildStatusBadge, () => {
|
||||
|
||||
test(renderBuildStatusBadge, () => {
|
||||
forCases([
|
||||
given({ status: 'broken' }),
|
||||
given({ status: 'error' }),
|
||||
given({ status: 'errored' }),
|
||||
given({ status: 'failed' }),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { downloadCount } from '../color-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { redirector, NotFound } from '../index.js'
|
||||
import BaseChromeWebStoreService from './chrome-web-store-base.js'
|
||||
|
||||
@@ -11,26 +10,19 @@ class ChromeWebStoreUsers extends BaseChromeWebStoreService {
|
||||
{
|
||||
title: 'Chrome Web Store',
|
||||
namedParams: { storeId: 'ogffaloegjglncjfehdfplabnoondfjo' },
|
||||
staticPreview: this.render({ downloads: 573 }),
|
||||
staticPreview: renderDownloadsBadge({ downloads: 573 }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'users' }
|
||||
|
||||
static render({ downloads }) {
|
||||
return {
|
||||
message: `${metric(downloads)}`,
|
||||
color: downloadCount(downloads),
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ storeId }) {
|
||||
const chromeWebStore = await this.fetch({ storeId })
|
||||
const downloads = chromeWebStore.users()
|
||||
if (downloads == null) {
|
||||
throw new NotFound({ prettyMessage: 'not found' })
|
||||
}
|
||||
return this.constructor.render({ downloads })
|
||||
return renderDownloadsBadge({ downloads })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
74
services/clearlydefined/clearlydefined-score.service.js
Normal file
74
services/clearlydefined/clearlydefined-score.service.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import Joi from 'joi'
|
||||
import {
|
||||
nonNegativeInteger,
|
||||
optionalNonNegativeInteger,
|
||||
} from '../validators.js'
|
||||
import { floorCount as floorCountColor } from '../color-formatters.js'
|
||||
import { BaseJsonService, NotFound } from '../index.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
scores: Joi.object({
|
||||
effective: nonNegativeInteger,
|
||||
}).required(),
|
||||
described: Joi.object({
|
||||
files: optionalNonNegativeInteger,
|
||||
}),
|
||||
}).required()
|
||||
|
||||
// This service based on the REST API for clearlydefined.io
|
||||
// https://api.clearlydefined.io/api-docs/
|
||||
export default class ClearlyDefinedService extends BaseJsonService {
|
||||
static category = 'analysis'
|
||||
static route = {
|
||||
base: 'clearlydefined',
|
||||
pattern: 'score/:type/:provider/:namespace/:name/:revision',
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'ClearlyDefined Score',
|
||||
namedParams: {
|
||||
type: 'npm',
|
||||
provider: 'npmjs',
|
||||
namespace: '-',
|
||||
name: 'jquery',
|
||||
revision: '3.4.1',
|
||||
},
|
||||
staticPreview: this.render({ score: 88 }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'score' }
|
||||
|
||||
static render({ score }) {
|
||||
score = Math.round(score)
|
||||
return {
|
||||
label: 'score',
|
||||
message: `${score}/100`,
|
||||
color: floorCountColor(score, 40, 60, 100),
|
||||
}
|
||||
}
|
||||
|
||||
async fetch({ type, provider, namespace, name, revision }) {
|
||||
return this._requestJson({
|
||||
schema,
|
||||
url: `https://api.clearlydefined.io/definitions/${type}/${provider}/${namespace}/${name}/${revision}`,
|
||||
errorMessages: {
|
||||
500: 'unknown type, provider, or upstream issue',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async handle({ type, provider, namespace, name, revision }) {
|
||||
const data = await this.fetch({ type, provider, namespace, name, revision })
|
||||
// Return score only if definition contains some files,
|
||||
// else it was an incomplete response due to unknown coordinates
|
||||
if (data.described.files > 0) {
|
||||
return this.constructor.render({ score: data.scores.effective })
|
||||
} else {
|
||||
throw new NotFound({
|
||||
prettyMessage: 'unknown namespace, name, or revision',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
24
services/clearlydefined/clearlydefined-score.tester.js
Normal file
24
services/clearlydefined/clearlydefined-score.tester.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import Joi from 'joi'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('ClearlyDefined Score')
|
||||
.get('/score/npm/npmjs/-/jquery/3.4.1.json')
|
||||
.expectBadge({
|
||||
label: 'score',
|
||||
message: Joi.string().regex(/^\d+\/\d+$/),
|
||||
})
|
||||
|
||||
t.create('ClearlyDefined Score (name not found)')
|
||||
.get('/score/npm/npmjs/-/not-a-real-package/0.0.0.json')
|
||||
.expectBadge({
|
||||
label: 'score',
|
||||
message: 'unknown namespace, name, or revision',
|
||||
})
|
||||
|
||||
t.create('ClearlyDefined Score (type not found)')
|
||||
.get('/score/abc/xyz/-/not-a-real-package/0.0.0.json')
|
||||
.expectBadge({
|
||||
label: 'score',
|
||||
message: 'unknown type, provider, or upstream issue',
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { downloadCount as downloadsColor } from '../color-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { BaseClojarsService } from './clojars-base.js'
|
||||
|
||||
export default class ClojarsDownloads extends BaseClojarsService {
|
||||
@@ -9,22 +8,14 @@ export default class ClojarsDownloads extends BaseClojarsService {
|
||||
static examples = [
|
||||
{
|
||||
namedParams: { clojar: 'prismic' },
|
||||
staticPreview: this.render({ downloads: 117 }),
|
||||
staticPreview: renderDownloadsBadge({ downloads: 117 }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
static render({ downloads }) {
|
||||
return {
|
||||
label: 'downloads',
|
||||
message: metric(downloads),
|
||||
color: downloadsColor(downloads),
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ clojar }) {
|
||||
const json = await this.fetch({ clojar })
|
||||
return this.constructor.render({ downloads: json.downloads })
|
||||
return renderDownloadsBadge({ downloads: json.downloads })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { downloadCount } from '../color-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import BaseCondaService from './conda-base.js'
|
||||
|
||||
export default class CondaDownloads extends BaseCondaService {
|
||||
@@ -16,11 +15,8 @@ export default class CondaDownloads extends BaseCondaService {
|
||||
]
|
||||
|
||||
static render({ variant, downloads }) {
|
||||
return {
|
||||
label: variant === 'dn' ? 'downloads' : 'conda|downloads',
|
||||
message: metric(downloads),
|
||||
color: downloadCount(downloads),
|
||||
}
|
||||
const labelOverride = variant === 'dn' ? 'downloads' : 'conda|downloads'
|
||||
return renderDownloadsBadge({ downloads, labelOverride })
|
||||
}
|
||||
|
||||
async handle({ variant, channel, pkg }) {
|
||||
|
||||
@@ -14,7 +14,7 @@ const crateSchema = Joi.object({
|
||||
.items(
|
||||
Joi.object({
|
||||
downloads: nonNegativeInteger,
|
||||
license: Joi.string().required(),
|
||||
license: Joi.string().required().allow(null),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
@@ -25,7 +25,7 @@ const versionSchema = Joi.object({
|
||||
version: Joi.object({
|
||||
downloads: nonNegativeInteger,
|
||||
num: Joi.string().required(),
|
||||
license: Joi.string().required(),
|
||||
license: Joi.string().required().allow(null),
|
||||
}).required(),
|
||||
}).required()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { downloadCount as downloadCountColor } from '../color-formatters.js'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { InvalidParameter, NotFound } from '../index.js'
|
||||
import { BaseCratesService, keywords } from './crates-base.js'
|
||||
|
||||
@@ -54,23 +53,16 @@ export default class CratesDownloads extends BaseCratesService {
|
||||
},
|
||||
]
|
||||
|
||||
static _getLabel(version, variant) {
|
||||
switch (variant) {
|
||||
case 'dv':
|
||||
return version ? `downloads@${version}` : 'downloads@latest'
|
||||
case 'dr':
|
||||
return 'recent downloads'
|
||||
default:
|
||||
return version ? `downloads@${version}` : 'downloads'
|
||||
}
|
||||
}
|
||||
|
||||
static render({ variant, downloads, version }) {
|
||||
return {
|
||||
label: this._getLabel(version, variant),
|
||||
message: metric(downloads),
|
||||
color: downloadCountColor(downloads),
|
||||
let labelOverride
|
||||
if (variant === 'dr') {
|
||||
labelOverride = 'recent downloads'
|
||||
} else if (variant === 'dv' && !version) {
|
||||
version = 'latest'
|
||||
} else if (!version) {
|
||||
labelOverride = 'downloads'
|
||||
}
|
||||
return renderDownloadsBadge({ downloads, labelOverride, version })
|
||||
}
|
||||
|
||||
transform({ variant, json }) {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { ServiceTester } from '../tester.js'
|
||||
import { isMetric } from '../test-validators.js'
|
||||
|
||||
export const t = new ServiceTester({
|
||||
id: 'crates',
|
||||
title: 'crates.io',
|
||||
pathPrefix: '/crates',
|
||||
})
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('total downloads')
|
||||
.get('/d/libc.json')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { InvalidResponse } from '../index.js'
|
||||
import { BaseCratesService, keywords } from './crates-base.js'
|
||||
|
||||
export default class CratesLicense extends BaseCratesService {
|
||||
@@ -21,28 +22,30 @@ export default class CratesLicense extends BaseCratesService {
|
||||
},
|
||||
]
|
||||
|
||||
static render({ license }) {
|
||||
return {
|
||||
label: 'license',
|
||||
message: license,
|
||||
color: 'blue',
|
||||
static defaultBadgeData = { label: 'license', color: 'blue' }
|
||||
|
||||
static render({ license: message }) {
|
||||
return { message }
|
||||
}
|
||||
|
||||
static transform({ errors, version, versions }) {
|
||||
// crates.io returns a 200 response with an errors object in
|
||||
// error scenarios, e.g. https://crates.io/api/v1/crates/libc/0.1
|
||||
if (errors) {
|
||||
throw new InvalidResponse({ prettyMessage: errors[0].detail })
|
||||
}
|
||||
|
||||
const license = version ? version.license : versions[0].license
|
||||
if (!license) {
|
||||
throw new InvalidResponse({ prettyMessage: 'invalid null license' })
|
||||
}
|
||||
|
||||
return { license }
|
||||
}
|
||||
|
||||
async handle({ crate, version }) {
|
||||
const json = await this.fetch({ crate, version })
|
||||
|
||||
if (json.errors) {
|
||||
/* a call like
|
||||
https://crates.io/api/v1/crates/libc/0.1
|
||||
or
|
||||
https://crates.io/api/v1/crates/libc/0.1.76
|
||||
returns a 200 OK with an errors object */
|
||||
return { message: json.errors[0].detail }
|
||||
}
|
||||
|
||||
return this.constructor.render({
|
||||
license: json.version ? json.version.license : json.versions[0].license,
|
||||
})
|
||||
const { license } = this.constructor.transform(json)
|
||||
return this.constructor.render({ license })
|
||||
}
|
||||
}
|
||||
|
||||
38
services/crates/crates-license.spec.js
Normal file
38
services/crates/crates-license.spec.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { expect } from 'chai'
|
||||
import { test, given } from 'sazerac'
|
||||
import { InvalidResponse } from '../index.js'
|
||||
import CratesLicense from './crates-license.service.js'
|
||||
|
||||
describe('CratesLicense', function () {
|
||||
test(CratesLicense.transform, () => {
|
||||
given({
|
||||
version: { num: '1.0.0', license: 'MIT' },
|
||||
versions: [{ license: 'MIT/Apache 2.0' }],
|
||||
}).expect({ license: 'MIT' })
|
||||
given({
|
||||
versions: [{ license: 'MIT/Apache 2.0' }],
|
||||
}).expect({ license: 'MIT/Apache 2.0' })
|
||||
})
|
||||
|
||||
it('throws InvalidResponse on error response', function () {
|
||||
expect(() =>
|
||||
CratesLicense.transform({ errors: [{ detail: 'invalid semver' }] })
|
||||
)
|
||||
.to.throw(InvalidResponse)
|
||||
.with.property('prettyMessage', 'invalid semver')
|
||||
})
|
||||
|
||||
it('throws InvalidResponse on null license with specific version', function () {
|
||||
expect(() =>
|
||||
CratesLicense.transform({ version: { num: '1.2.3', license: null } })
|
||||
)
|
||||
.to.throw(InvalidResponse)
|
||||
.with.property('prettyMessage', 'invalid null license')
|
||||
})
|
||||
|
||||
it('throws InvalidResponse on null license with latest version', function () {
|
||||
expect(() => CratesLicense.transform({ versions: [{ license: null }] }))
|
||||
.to.throw(InvalidResponse)
|
||||
.with.property('prettyMessage', 'invalid null license')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,5 @@
|
||||
import { ServiceTester } from '../tester.js'
|
||||
|
||||
export const t = new ServiceTester({
|
||||
id: 'crates',
|
||||
title: 'crates.io',
|
||||
pathPrefix: '/crates/l',
|
||||
})
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('license')
|
||||
.get('/libc.json')
|
||||
@@ -16,4 +11,13 @@ t.create('license (with version)')
|
||||
|
||||
t.create('license (not found)')
|
||||
.get('/not-a-real-package.json')
|
||||
.expectBadge({ label: 'crates.io', message: 'not found' })
|
||||
.expectBadge({ label: 'license', message: 'not found' })
|
||||
|
||||
// https://github.com/badges/shields/issues/7073
|
||||
t.create('license (null licenses in history)')
|
||||
.get('/stun.json')
|
||||
.expectBadge({ label: 'license', message: 'MIT/Apache-2.0' })
|
||||
|
||||
t.create('license (version with null license)')
|
||||
.get('/stun/0.0.1.json')
|
||||
.expectBadge({ label: 'license', message: 'invalid null license' })
|
||||
|
||||
@@ -1,98 +1,13 @@
|
||||
import Joi from 'joi'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import { deprecatedService } from '../index.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
status: Joi.allow(
|
||||
'insecure',
|
||||
'outofdate',
|
||||
'notsouptodate',
|
||||
'uptodate',
|
||||
'none'
|
||||
).required(),
|
||||
}).required()
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
path: Joi.string(),
|
||||
}).required()
|
||||
|
||||
const statusMap = {
|
||||
insecure: {
|
||||
color: 'red',
|
||||
message: 'insecure',
|
||||
},
|
||||
outofdate: {
|
||||
color: 'red',
|
||||
message: 'out of date',
|
||||
},
|
||||
notsouptodate: {
|
||||
color: 'yellow',
|
||||
message: 'up to date',
|
||||
},
|
||||
uptodate: {
|
||||
color: 'brightgreen',
|
||||
message: 'up to date',
|
||||
},
|
||||
none: {
|
||||
color: 'brightgreen',
|
||||
message: 'none',
|
||||
},
|
||||
}
|
||||
|
||||
export default class David extends BaseJsonService {
|
||||
static category = 'dependencies'
|
||||
static route = {
|
||||
base: 'david',
|
||||
pattern: ':kind(dev|optional|peer)?/:user/:repo',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'David',
|
||||
namedParams: { user: 'expressjs', repo: 'express' },
|
||||
staticPreview: this.render({ status: 'uptodate' }),
|
||||
export default [
|
||||
deprecatedService({
|
||||
category: 'dependencies',
|
||||
route: {
|
||||
base: 'david',
|
||||
pattern: ':various+',
|
||||
},
|
||||
{
|
||||
title: 'David (path)',
|
||||
namedParams: { user: 'babel', repo: 'babel' },
|
||||
queryParams: { path: 'packages/babel-core' },
|
||||
staticPreview: this.render({ status: 'uptodate' }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'dependencies' }
|
||||
|
||||
static render({ status, kind }) {
|
||||
return {
|
||||
message: statusMap[status].message,
|
||||
color: statusMap[status].color,
|
||||
label: `${kind ? `${kind} ` : ''}dependencies`,
|
||||
}
|
||||
}
|
||||
|
||||
async fetch({ kind, user, repo, path }) {
|
||||
const url = `https://david-dm.org/${user}/${repo}/${
|
||||
kind ? `${kind}-` : ''
|
||||
}info.json`
|
||||
|
||||
return this._requestJson({
|
||||
schema,
|
||||
url,
|
||||
options: { qs: { path } },
|
||||
errorMessages: {
|
||||
/* note:
|
||||
david returns a 504 response for 'not found'
|
||||
e.g: https://david-dm.org/foo/barbaz/info.json
|
||||
not a 404 so we can't handle 'not found' cleanly
|
||||
because this might also be some other error.
|
||||
*/
|
||||
504: 'repo or path not found or david internal error',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async handle({ kind, user, repo }, { path }) {
|
||||
const json = await this.fetch({ kind, user, repo, path })
|
||||
return this.constructor.render({ status: json.status, kind })
|
||||
}
|
||||
}
|
||||
label: 'david',
|
||||
dateAdded: new Date('2021-10-30'),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -1,70 +1,20 @@
|
||||
import Joi from 'joi'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
import { ServiceTester } from '../tester.js'
|
||||
|
||||
const isDependencyStatus = Joi.string().valid(
|
||||
'insecure',
|
||||
'up to date',
|
||||
'out of date'
|
||||
)
|
||||
export const t = new ServiceTester({
|
||||
id: 'david',
|
||||
title: 'David',
|
||||
})
|
||||
|
||||
t.create('david dependencies (valid)')
|
||||
t.create('no longer available (previously dependencies)')
|
||||
.get('/expressjs/express.json')
|
||||
.timeout(15000)
|
||||
.expectBadge({
|
||||
label: 'dependencies',
|
||||
message: isDependencyStatus,
|
||||
label: 'david',
|
||||
message: 'no longer available',
|
||||
})
|
||||
|
||||
t.create('david dev dependencies (valid)')
|
||||
t.create('no longer available (previously dev dependencies)')
|
||||
.get('/dev/expressjs/express.json')
|
||||
.timeout(15000)
|
||||
.expectBadge({
|
||||
label: 'dev dependencies',
|
||||
message: isDependencyStatus,
|
||||
})
|
||||
|
||||
t.create('david optional dependencies (valid)')
|
||||
.get('/optional/elnounch/byebye.json')
|
||||
.timeout(15000)
|
||||
.expectBadge({
|
||||
label: 'optional dependencies',
|
||||
message: isDependencyStatus,
|
||||
})
|
||||
|
||||
t.create('david peer dependencies (valid)')
|
||||
.get('/peer/webcomponents/generator-element.json')
|
||||
.timeout(15000)
|
||||
.expectBadge({
|
||||
label: 'peer dependencies',
|
||||
message: isDependencyStatus,
|
||||
})
|
||||
|
||||
t.create('david dependencies with path (valid)')
|
||||
.get('/babel/babel.json?path=packages/babel-core')
|
||||
.timeout(15000)
|
||||
.expectBadge({
|
||||
label: 'dependencies',
|
||||
message: isDependencyStatus,
|
||||
})
|
||||
|
||||
t.create('david dependencies (none)')
|
||||
.get('/peer/expressjs/express.json') // express does not specify peer dependencies
|
||||
.timeout(15000)
|
||||
.expectBadge({ label: 'peer dependencies', message: 'none' })
|
||||
|
||||
t.create('david dependencies (repo not found)')
|
||||
.get('/pyvesb/emptyrepo.json')
|
||||
.timeout(15000)
|
||||
.expectBadge({
|
||||
label: 'dependencies',
|
||||
message: 'repo or path not found or david internal error',
|
||||
})
|
||||
|
||||
t.create('david dependencies (path not found')
|
||||
.get('/babel/babel.json?path=invalid/path')
|
||||
.timeout(15000)
|
||||
.expectBadge({
|
||||
label: 'dependencies',
|
||||
message: 'repo or path not found or david internal error',
|
||||
label: 'david',
|
||||
message: 'no longer available',
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Joi from 'joi'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import {
|
||||
@@ -28,11 +28,8 @@ export default class DockerPulls extends BaseJsonService {
|
||||
|
||||
static defaultBadgeData = { label: 'docker pulls' }
|
||||
|
||||
static render({ count }) {
|
||||
return {
|
||||
message: metric(count),
|
||||
color: dockerBlue,
|
||||
}
|
||||
static render({ count: downloads }) {
|
||||
return renderDownloadsBadge({ downloads, colorOverride: dockerBlue })
|
||||
}
|
||||
|
||||
async fetch({ user, repo }) {
|
||||
|
||||
59
services/downloads.js
Normal file
59
services/downloads.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { downloadCount } from './color-formatters.js'
|
||||
import { metric } from './text-formatters.js'
|
||||
|
||||
/**
|
||||
* Handles rendering concerns of badges that display
|
||||
* download counts, with override/customization support
|
||||
*
|
||||
* @param {object} attrs Refer to individual attrs
|
||||
* @param {number} attrs.downloads Number of downloads
|
||||
* @param {string} [attrs.interval] Period or interval the downloads occurred
|
||||
* (e.g. day, week, month). If provided then this Will be reflected
|
||||
* in the badge message unless overridden by other message-related parameters
|
||||
* @param {string} [attrs.version] Version or tag that was downloaded
|
||||
* which will be reflected in the badge label (unless the label is overridden)
|
||||
* @param {string} [attrs.labelOverride] If provided then the badge label is set to this
|
||||
* value overriding any other label-related parameters
|
||||
* @param {string} [attrs.colorOverride] If provided then the badge color is set to this
|
||||
* value instead of the color being based on the count of downloads
|
||||
* @param {string} [attrs.messageSuffixOverride] If provided then the badge message will
|
||||
* will have this value added to the download count, separated with a space
|
||||
* @param {string} [attrs.versionedLabelPrefix] If provided then the badge label will use
|
||||
* this value as the prefix for versioned badges, e.g. `foobar@v1.23`. Defaults to 'downloads'
|
||||
* @returns {object} Badge
|
||||
*/
|
||||
function renderDownloadsBadge({
|
||||
downloads,
|
||||
interval,
|
||||
version,
|
||||
labelOverride,
|
||||
colorOverride,
|
||||
messageSuffixOverride,
|
||||
versionedLabelPrefix = 'downloads',
|
||||
}) {
|
||||
let messageSuffix = ''
|
||||
if (messageSuffixOverride) {
|
||||
messageSuffix = ` ${messageSuffixOverride}`
|
||||
} else if (interval) {
|
||||
messageSuffix = `/${interval}`
|
||||
}
|
||||
|
||||
let label
|
||||
if (labelOverride) {
|
||||
label = labelOverride
|
||||
} else if (version) {
|
||||
label = `${versionedLabelPrefix}@${version}`
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
color: colorOverride || downloadCount(downloads),
|
||||
message: `${metric(downloads)}${messageSuffix}`,
|
||||
}
|
||||
}
|
||||
|
||||
export { renderDownloadsBadge }
|
||||
52
services/downloads.spec.js
Normal file
52
services/downloads.spec.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { test, given } from 'sazerac'
|
||||
import { renderDownloadsBadge } from './downloads.js'
|
||||
import { downloadCount } from './color-formatters.js'
|
||||
import { metric } from './text-formatters.js'
|
||||
|
||||
const downloads = 2345
|
||||
const message = metric(downloads)
|
||||
const color = downloadCount(downloads)
|
||||
|
||||
describe('downloads', function () {
|
||||
test(renderDownloadsBadge, () => {
|
||||
given({ downloads }).expect({ label: undefined, color, message })
|
||||
given({ downloads, labelOverride: 'recent downloads' }).expect({
|
||||
label: 'recent downloads',
|
||||
color,
|
||||
message,
|
||||
})
|
||||
given({ downloads, version: 'v1.0.0' }).expect({
|
||||
label: 'downloads@v1.0.0',
|
||||
color,
|
||||
message,
|
||||
})
|
||||
given({
|
||||
downloads,
|
||||
versionedLabelPrefix: 'installs',
|
||||
version: 'v1.0.0',
|
||||
}).expect({
|
||||
label: 'installs@v1.0.0',
|
||||
color,
|
||||
message,
|
||||
})
|
||||
given({
|
||||
downloads,
|
||||
messageSuffixOverride: '[foo.tar.gz]',
|
||||
interval: 'week',
|
||||
}).expect({
|
||||
label: undefined,
|
||||
color,
|
||||
message: `${message} [foo.tar.gz]`,
|
||||
})
|
||||
given({ downloads, interval: 'year' }).expect({
|
||||
label: undefined,
|
||||
color,
|
||||
message: `${message}/year`,
|
||||
})
|
||||
given({ downloads, colorOverride: 'pink' }).expect({
|
||||
label: undefined,
|
||||
color: 'pink',
|
||||
message,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import Joi from 'joi'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { downloadCount as downloadCountColor } from '../color-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
|
||||
@@ -16,19 +15,19 @@ const schema = Joi.object({
|
||||
const intervalMap = {
|
||||
dd: {
|
||||
transform: json => json.downloads.daily,
|
||||
messageSuffix: '/day',
|
||||
interval: 'day',
|
||||
},
|
||||
dw: {
|
||||
transform: json => json.downloads.weekly,
|
||||
messageSuffix: '/week',
|
||||
interval: 'week',
|
||||
},
|
||||
dm: {
|
||||
transform: json => json.downloads.monthly,
|
||||
messageSuffix: '/month',
|
||||
interval: 'month',
|
||||
},
|
||||
dt: {
|
||||
transform: json => json.downloads.total,
|
||||
messageSuffix: '',
|
||||
interval: '',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,7 +42,7 @@ export default class DubDownloads extends BaseJsonService {
|
||||
{
|
||||
title: 'DUB',
|
||||
namedParams: { interval: 'dm', packageName: 'vibe-d' },
|
||||
staticPreview: this.render({ interval: 'dm', downloadCount: 5000 }),
|
||||
staticPreview: this.render({ interval: 'dm', downloads: 5000 }),
|
||||
},
|
||||
{
|
||||
title: 'DUB (version)',
|
||||
@@ -55,7 +54,7 @@ export default class DubDownloads extends BaseJsonService {
|
||||
staticPreview: this.render({
|
||||
interval: 'dm',
|
||||
version: '0.8.4',
|
||||
downloadCount: 100,
|
||||
downloads: 100,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -68,21 +67,19 @@ export default class DubDownloads extends BaseJsonService {
|
||||
staticPreview: this.render({
|
||||
interval: 'dm',
|
||||
version: 'latest',
|
||||
downloadCount: 100,
|
||||
downloads: 100,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
static render({ interval, version, downloadCount }) {
|
||||
const { messageSuffix } = intervalMap[interval]
|
||||
|
||||
return {
|
||||
label: version ? `downloads@${version}` : 'downloads',
|
||||
message: `${metric(downloadCount)}${messageSuffix}`,
|
||||
color: downloadCountColor(downloadCount),
|
||||
}
|
||||
static render({ interval, version, downloads }) {
|
||||
return renderDownloadsBadge({
|
||||
downloads,
|
||||
version,
|
||||
interval: intervalMap[interval].interval,
|
||||
})
|
||||
}
|
||||
|
||||
async fetch({ packageName, version }) {
|
||||
@@ -98,7 +95,7 @@ export default class DubDownloads extends BaseJsonService {
|
||||
const { transform } = intervalMap[interval]
|
||||
|
||||
const json = await this.fetch({ packageName, version })
|
||||
const downloadCount = transform(json)
|
||||
return this.constructor.render({ interval, downloadCount, version })
|
||||
const downloads = transform(json)
|
||||
return this.constructor.render({ interval, downloads, version })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Joi from 'joi'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { downloadCount as downloadCountColor } from '../color-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import EclipseMarketplaceBase from './eclipse-marketplace-base.js'
|
||||
|
||||
@@ -20,16 +19,16 @@ const totalResponseSchema = Joi.object({
|
||||
}),
|
||||
}).required()
|
||||
|
||||
function DownloadsForInterval(interval) {
|
||||
function DownloadsForInterval(downloadsInterval) {
|
||||
const {
|
||||
base,
|
||||
schema,
|
||||
messageSuffix = '',
|
||||
interval = '',
|
||||
name,
|
||||
} = {
|
||||
month: {
|
||||
base: 'eclipse-marketplace/dm',
|
||||
messageSuffix: '/month',
|
||||
interval: 'month',
|
||||
schema: monthlyResponseSchema,
|
||||
name: 'EclipseMarketplaceDownloadsMonth',
|
||||
},
|
||||
@@ -38,7 +37,7 @@ function DownloadsForInterval(interval) {
|
||||
schema: totalResponseSchema,
|
||||
name: 'EclipseMarketplaceDownloadsTotal',
|
||||
},
|
||||
}[interval]
|
||||
}[downloadsInterval]
|
||||
|
||||
return class EclipseMarketplaceDownloads extends EclipseMarketplaceBase {
|
||||
static name = name
|
||||
@@ -53,16 +52,13 @@ function DownloadsForInterval(interval) {
|
||||
]
|
||||
|
||||
static render({ downloads }) {
|
||||
return {
|
||||
message: `${metric(downloads)}${messageSuffix}`,
|
||||
color: downloadCountColor(downloads),
|
||||
}
|
||||
return renderDownloadsBadge({ downloads, interval })
|
||||
}
|
||||
|
||||
async handle({ name }) {
|
||||
const { marketplace } = await this.fetch({ name, schema })
|
||||
const downloads =
|
||||
interval === 'total'
|
||||
downloadsInterval === 'total'
|
||||
? marketplace.node.installstotal
|
||||
: marketplace.node.installsrecent
|
||||
return this.constructor.render({ downloads })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import semver from 'semver'
|
||||
import Joi from 'joi'
|
||||
import { downloadCount } from '../color-formatters.js'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { latest as latestVersion } from '../version.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { BaseJsonService, InvalidParameter, InvalidResponse } from '../index.js'
|
||||
@@ -81,18 +80,8 @@ export default class GemDownloads extends BaseJsonService {
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
static render({ variant, version, downloads }) {
|
||||
let label
|
||||
if (version) {
|
||||
label = `downloads@${version}`
|
||||
} else if (variant === 'dtv') {
|
||||
label = 'downloads@latest'
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
message: metric(downloads),
|
||||
color: downloadCount(downloads),
|
||||
}
|
||||
version = !version && variant === 'dtv' ? 'latest' : version
|
||||
return renderDownloadsBadge({ downloads, version })
|
||||
}
|
||||
|
||||
async fetchDownloadCountForVersion({ gem, version }) {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { makeSecretIsValid } from '../../../core/server/secret-is-valid.js'
|
||||
|
||||
function setRoutes({ shieldsSecret }, { apiProvider, server }) {
|
||||
const secretIsValid = makeSecretIsValid(shieldsSecret)
|
||||
|
||||
// Allow the admin to obtain the tokens for operational and debugging
|
||||
// purposes. This could be used to:
|
||||
//
|
||||
// - Ensure tokens have been propagated to all servers
|
||||
// - Debug GitHub badge failures
|
||||
//
|
||||
// The admin can authenticate with HTTP Basic Auth, with an empty/any
|
||||
// username and the shields secret in the password and an empty/any
|
||||
// password.
|
||||
//
|
||||
// e.g.
|
||||
// curl --insecure -u ':very-very-secret' 'https://img.shields.io/$github-auth/tokens'
|
||||
server.ajax.on('github-auth/tokens', (json, end, ask) => {
|
||||
if (!secretIsValid(ask.password)) {
|
||||
// An unknown entity tries to connect. Let the connection linger for a minute.
|
||||
return setTimeout(() => {
|
||||
ask.res.statusCode = 401
|
||||
ask.res.setHeader('Cache-Control', 'private')
|
||||
end('Invalid secret.')
|
||||
}, 10000)
|
||||
}
|
||||
ask.res.setHeader('Cache-Control', 'private')
|
||||
end(apiProvider.serializeDebugInfo({ sanitize: false }))
|
||||
})
|
||||
}
|
||||
|
||||
export { setRoutes }
|
||||
@@ -1,71 +0,0 @@
|
||||
import { expect } from 'chai'
|
||||
import Camp from '@shields_io/camp'
|
||||
import portfinder from 'portfinder'
|
||||
import got from '../../../core/got-test-client.js'
|
||||
import GithubApiProvider from '../github-api-provider.js'
|
||||
import { setRoutes } from './admin.js'
|
||||
|
||||
describe('GitHub admin route', function () {
|
||||
const shieldsSecret = '7'.repeat(40)
|
||||
|
||||
let port, baseUrl
|
||||
before(async function () {
|
||||
port = await portfinder.getPortPromise()
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
let camp
|
||||
before(async function () {
|
||||
camp = Camp.start({ port, hostname: '::' })
|
||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
||||
})
|
||||
after(async function () {
|
||||
if (camp) {
|
||||
await new Promise(resolve => camp.close(resolve))
|
||||
camp = undefined
|
||||
}
|
||||
})
|
||||
|
||||
before(function () {
|
||||
const apiProvider = new GithubApiProvider({ withPooling: true })
|
||||
setRoutes({ shieldsSecret }, { apiProvider, server: camp })
|
||||
})
|
||||
|
||||
context('the password is correct', function () {
|
||||
it('returns a valid JSON response', async function () {
|
||||
const { statusCode, body, headers } = await got(
|
||||
`${baseUrl}/$github-auth/tokens`,
|
||||
{
|
||||
username: '',
|
||||
password: shieldsSecret,
|
||||
responseType: 'json',
|
||||
}
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body).to.be.ok
|
||||
expect(headers['cache-control']).to.equal('private')
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled because this code isn't modified often and the test is very
|
||||
// slow. To run it, run `SLOW=true npm run test:core`
|
||||
//
|
||||
// I wasn't able to make this work with fake timers:
|
||||
// https://github.com/sinonjs/sinon/issues/1739
|
||||
if (process.env.SLOW) {
|
||||
context('the password is missing', function () {
|
||||
it('returns the expected message', async function () {
|
||||
this.timeout(11000)
|
||||
const { statusCode, body, headers } = await got(
|
||||
`${baseUrl}/$github-auth/tokens`,
|
||||
{
|
||||
throwHttpErrors: false,
|
||||
}
|
||||
)
|
||||
expect(statusCode).to.equal(401)
|
||||
expect(body).to.equal('"Invalid secret."')
|
||||
expect(headers['cache-control']).to.equal('private')
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect } from 'chai'
|
||||
import config from 'config'
|
||||
import request from 'request'
|
||||
import { fetchFactory } from '../../core/base-service/got.js'
|
||||
import GithubApiProvider from './github-api-provider.js'
|
||||
const requestFetcher = fetchFactory()
|
||||
|
||||
describe('Github API provider', function () {
|
||||
const baseUrl = process.env.GITHUB_URL || 'https://api.github.com'
|
||||
@@ -30,8 +31,8 @@ describe('Github API provider', function () {
|
||||
it('should be able to run 10 requests', async function () {
|
||||
this.timeout('20s')
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
await githubApiProvider.requestAsPromise(
|
||||
request,
|
||||
await githubApiProvider.fetch(
|
||||
requestFetcher,
|
||||
'/repos/rust-lang/rust',
|
||||
{}
|
||||
)
|
||||
@@ -52,8 +53,8 @@ describe('Github API provider', function () {
|
||||
|
||||
const headers = []
|
||||
async function performOneRequest() {
|
||||
const { res } = await githubApiProvider.requestAsPromise(
|
||||
request,
|
||||
const { res } = await githubApiProvider.fetch(
|
||||
requestFetcher,
|
||||
'/repos/rust-lang/rust',
|
||||
{}
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import log from '../../core/server/log.js'
|
||||
import { TokenPool } from '../../core/token-pooling/token-pool.js'
|
||||
import { userAgent } from '../../core/base-service/legacy-request-handler.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { ImproperlyConfigured } from '../index.js'
|
||||
|
||||
const headerSchema = Joi.object({
|
||||
'x-ratelimit-limit': nonNegativeInteger,
|
||||
@@ -54,18 +55,6 @@ class GithubApiProvider {
|
||||
}
|
||||
}
|
||||
|
||||
serializeDebugInfo({ sanitize = true } = {}) {
|
||||
if (this.withPooling) {
|
||||
return {
|
||||
standardTokens: this.standardTokens.serializeDebugInfo({ sanitize }),
|
||||
searchTokens: this.searchTokens.serializeDebugInfo({ sanitize }),
|
||||
graphqlTokens: this.graphqlTokens.serializeDebugInfo({ sanitize }),
|
||||
}
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
addToken(tokenString) {
|
||||
if (this.withPooling) {
|
||||
this.standardTokens.add(tokenString)
|
||||
@@ -151,10 +140,7 @@ class GithubApiProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Act like request(), but tweak headers and query to avoid hitting a rate
|
||||
// limit. Inject `request` so we can pass in `cachingRequest` from
|
||||
// `request-handler.js`.
|
||||
request(request, url, options = {}, callback) {
|
||||
async fetch(requestFetcher, url, options = {}) {
|
||||
const { baseUrl } = this
|
||||
|
||||
let token
|
||||
@@ -163,8 +149,10 @@ class GithubApiProvider {
|
||||
try {
|
||||
token = this.tokenForUrl(url)
|
||||
} catch (e) {
|
||||
callback(e)
|
||||
return
|
||||
log.error(e)
|
||||
throw new ImproperlyConfigured({
|
||||
prettyMessage: 'Unable to select next Github token from pool',
|
||||
})
|
||||
}
|
||||
tokenString = token.id
|
||||
} else {
|
||||
@@ -174,8 +162,6 @@ class GithubApiProvider {
|
||||
const mergedOptions = {
|
||||
...options,
|
||||
...{
|
||||
url,
|
||||
baseUrl,
|
||||
headers: {
|
||||
'User-Agent': userAgent,
|
||||
Authorization: `token ${tokenString}`,
|
||||
@@ -183,31 +169,15 @@ class GithubApiProvider {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
request(mergedOptions, (err, res, buffer) => {
|
||||
if (err === null) {
|
||||
if (this.withPooling) {
|
||||
if (res.statusCode === 401) {
|
||||
this.invalidateToken(token)
|
||||
} else if (res.statusCode < 500) {
|
||||
this.updateToken({ token, url, res })
|
||||
}
|
||||
}
|
||||
const response = await requestFetcher(`${baseUrl}${url}`, mergedOptions)
|
||||
if (this.withPooling) {
|
||||
if (response.res.statusCode === 401) {
|
||||
this.invalidateToken(token)
|
||||
} else if (response.res.statusCode < 500) {
|
||||
this.updateToken({ token, url, res: response.res })
|
||||
}
|
||||
callback(err, res, buffer)
|
||||
})
|
||||
}
|
||||
|
||||
requestAsPromise(request, url, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.request(request, url, options, (err, res, buffer) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve({ res, buffer })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,47 +21,35 @@ describe('Github API provider', function () {
|
||||
})
|
||||
|
||||
context('a search API request', function () {
|
||||
const mockRequest = (options, callback) => {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request(mockRequest, '/search', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).to.have.been.calledOnce
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
})
|
||||
it('should obtain an appropriate token', async function () {
|
||||
const mockResponse = { res: { headers: {} } }
|
||||
const mockRequest = sinon.stub().resolves(mockResponse)
|
||||
await provider.fetch(mockRequest, '/search', {})
|
||||
expect(provider.searchTokens.next).to.have.been.calledOnce
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
context('a graphql API request', function () {
|
||||
const mockRequest = (options, callback) => {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request(mockRequest, '/graphql', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).to.have.been.calledOnce
|
||||
done()
|
||||
})
|
||||
it('should obtain an appropriate token', async function () {
|
||||
const mockResponse = { res: { headers: {} } }
|
||||
const mockRequest = sinon.stub().resolves(mockResponse)
|
||||
await provider.fetch(mockRequest, '/graphql', {})
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).to.have.been.calledOnce
|
||||
})
|
||||
})
|
||||
|
||||
context('a core API request', function () {
|
||||
const mockRequest = (options, callback) => {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request(mockRequest, '/repo', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).to.have.been.calledOnce
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
})
|
||||
it('should obtain an appropriate token', async function () {
|
||||
const mockResponse = { res: { headers: {} } }
|
||||
const mockRequest = sinon.stub().resolves(mockResponse)
|
||||
await provider.fetch(mockRequest, '/repo', {})
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).to.have.been.calledOnce
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,40 +58,32 @@ describe('Github API provider', function () {
|
||||
const remaining = 7955
|
||||
const nextReset = 123456789
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
'x-ratelimit-limit': rateLimit,
|
||||
'x-ratelimit-remaining': remaining,
|
||||
'x-ratelimit-reset': nextReset,
|
||||
res: {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
'x-ratelimit-limit': rateLimit,
|
||||
'x-ratelimit-remaining': remaining,
|
||||
'x-ratelimit-reset': nextReset,
|
||||
},
|
||||
buffer: Buffer.alloc(0),
|
||||
},
|
||||
}
|
||||
const mockBuffer = Buffer.alloc(0)
|
||||
const mockRequest = (...args) => {
|
||||
const callback = args.pop()
|
||||
callback(null, mockResponse, mockBuffer)
|
||||
}
|
||||
const mockRequest = sinon.stub().resolves(mockResponse)
|
||||
|
||||
it('should invoke the callback', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
})
|
||||
it('should return the response', async function () {
|
||||
const res = await provider.fetch(mockRequest, '/repo', {})
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
})
|
||||
|
||||
it('should update the token with the expected values', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockStandardToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockStandardToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
})
|
||||
it('should update the token with the expected values', async function () {
|
||||
await provider.fetch(mockRequest, '/foo', {})
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockStandardToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockStandardToken.invalidate).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,9 +92,10 @@ describe('Github API provider', function () {
|
||||
const remaining = 7955
|
||||
const nextReset = 123456789
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: `{
|
||||
res: {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: `{
|
||||
"data": {
|
||||
"rateLimit": {
|
||||
"limit": 12500,
|
||||
@@ -124,67 +105,46 @@ describe('Github API provider', function () {
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
}
|
||||
const mockBuffer = Buffer.alloc(0)
|
||||
const mockRequest = (...args) => {
|
||||
const callback = args.pop()
|
||||
callback(null, mockResponse, mockBuffer)
|
||||
}
|
||||
const mockRequest = sinon.stub().resolves(mockResponse)
|
||||
|
||||
it('should invoke the callback', function (done) {
|
||||
provider.request(mockRequest, '/graphql', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
})
|
||||
it('should return the response', async function () {
|
||||
const res = await provider.fetch(mockRequest, '/graphql', {})
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
})
|
||||
|
||||
it('should update the token with the expected values', function (done) {
|
||||
provider.request(mockRequest, '/graphql', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockGraphqlToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockGraphqlToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
})
|
||||
it('should update the token with the expected values', async function () {
|
||||
await provider.fetch(mockRequest, '/graphql', {})
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockGraphqlToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockGraphqlToken.invalidate).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
context('an unauthorized response', function () {
|
||||
const mockResponse = { statusCode: 401 }
|
||||
const mockBuffer = Buffer.alloc(0)
|
||||
const mockRequest = (...args) => {
|
||||
const callback = args.pop()
|
||||
callback(null, mockResponse, mockBuffer)
|
||||
}
|
||||
|
||||
it('should invoke the callback and update the token with the expected values', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(mockStandardToken.invalidate).to.have.been.calledOnce
|
||||
expect(mockStandardToken.update).not.to.have.been.called
|
||||
done()
|
||||
})
|
||||
it('should invoke the callback and update the token with the expected values', async function () {
|
||||
const mockResponse = { res: { statusCode: 401, headers: {} } }
|
||||
const mockRequest = sinon.stub().resolves(mockResponse)
|
||||
await provider.fetch(mockRequest, '/foo', {})
|
||||
expect(mockStandardToken.invalidate).to.have.been.calledOnce
|
||||
expect(mockStandardToken.update).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
context('a connection error', function () {
|
||||
const mockRequest = (...args) => {
|
||||
const callback = args.pop()
|
||||
callback(Error('connection timeout'))
|
||||
}
|
||||
|
||||
it('should pass the error to the callback', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.an.instanceof(Error)
|
||||
expect(err.message).to.equal('connection timeout')
|
||||
done()
|
||||
})
|
||||
it('should throw an exception', function () {
|
||||
const msg = 'connection timeout'
|
||||
const requestError = new Error(msg)
|
||||
const mockRequest = sinon.stub().rejects(requestError)
|
||||
return expect(provider.fetch(mockRequest, '/foo', {})).to.be.rejectedWith(
|
||||
Error,
|
||||
'connection timeout'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,21 +2,15 @@ import gql from 'graphql-tag'
|
||||
import { mergeQueries } from '../../core/base-service/graphql.js'
|
||||
import { BaseGraphqlService, BaseJsonService } from '../index.js'
|
||||
|
||||
function createRequestFetcher(context, config) {
|
||||
const { sendAndCacheRequestWithCallbacks, githubApiProvider } = context
|
||||
|
||||
return async (url, options) =>
|
||||
githubApiProvider.requestAsPromise(
|
||||
sendAndCacheRequestWithCallbacks,
|
||||
url,
|
||||
options
|
||||
)
|
||||
function createRequestFetcher(context) {
|
||||
const { sendAndCacheRequest, githubApiProvider } = context
|
||||
return githubApiProvider.fetch.bind(githubApiProvider, sendAndCacheRequest)
|
||||
}
|
||||
|
||||
class GithubAuthV3Service extends BaseJsonService {
|
||||
constructor(context, config) {
|
||||
super(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context)
|
||||
this.staticAuthConfigured = true
|
||||
}
|
||||
}
|
||||
@@ -30,7 +24,7 @@ class ConditionalGithubAuthV3Service extends BaseJsonService {
|
||||
constructor(context, config) {
|
||||
super(context, config)
|
||||
if (context.githubApiProvider.globalToken) {
|
||||
this._requestFetcher = createRequestFetcher(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context)
|
||||
this.staticAuthConfigured = true
|
||||
} else {
|
||||
this.staticAuthConfigured = false
|
||||
@@ -41,7 +35,7 @@ class ConditionalGithubAuthV3Service extends BaseJsonService {
|
||||
class GithubAuthV4Service extends BaseGraphqlService {
|
||||
constructor(context, config) {
|
||||
super(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context)
|
||||
this.staticAuthConfigured = true
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('GithubAuthV3Service', function () {
|
||||
schema: Joi.object({
|
||||
requiredString: Joi.string().required(),
|
||||
}).required(),
|
||||
url: 'https://github-api.example.com/repos/badges/shields/check-runs',
|
||||
url: '/repos/badges/shields/check-runs',
|
||||
options: {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
@@ -26,10 +26,17 @@ describe('GithubAuthV3Service', function () {
|
||||
}
|
||||
|
||||
it('forwards custom Accept header', async function () {
|
||||
const sendAndCacheRequestWithCallbacks = sinon.stub().returns(
|
||||
const sendAndCacheRequest = sinon.stub().returns(
|
||||
Promise.resolve({
|
||||
buffer: '{"requiredString": "some-string"}',
|
||||
res: { statusCode: 200 },
|
||||
res: {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
'x-ratelimit-limit': 12500,
|
||||
'x-ratelimit-remaining': 7955,
|
||||
'x-ratelimit-reset': 123456789,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
const githubApiProvider = new GithubApiProvider({
|
||||
@@ -39,18 +46,19 @@ describe('GithubAuthV3Service', function () {
|
||||
sinon.stub(githubApiProvider.standardTokens, 'next').returns(mockToken)
|
||||
|
||||
DummyGithubAuthV3Service.invoke({
|
||||
sendAndCacheRequestWithCallbacks,
|
||||
sendAndCacheRequest,
|
||||
githubApiProvider,
|
||||
})
|
||||
|
||||
expect(sendAndCacheRequestWithCallbacks).to.have.been.calledOnceWith({
|
||||
headers: {
|
||||
'User-Agent': 'Shields.io/2003a',
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
Authorization: 'token undefined',
|
||||
},
|
||||
url: 'https://github-api.example.com/repos/badges/shields/check-runs',
|
||||
baseUrl: 'https://github-api.example.com',
|
||||
})
|
||||
expect(sendAndCacheRequest).to.have.been.calledOnceWith(
|
||||
'https://github-api.example.com/repos/badges/shields/check-runs',
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Shields.io/2003a',
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
Authorization: 'token undefined',
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ const releaseInfoSchema = Joi.object({
|
||||
})
|
||||
.required(),
|
||||
tag_name: Joi.string().required(),
|
||||
name: Joi.string().allow(null).allow(''),
|
||||
prerelease: Joi.boolean().required(),
|
||||
}).required()
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { AuthHelper } from '../../core/base-service/auth-helper.js'
|
||||
import RedisTokenPersistence from '../../core/token-pooling/redis-token-persistence.js'
|
||||
import log from '../../core/server/log.js'
|
||||
import GithubApiProvider from './github-api-provider.js'
|
||||
import { setRoutes as setAdminRoutes } from './auth/admin.js'
|
||||
import { setRoutes as setAcceptorRoutes } from './auth/acceptor.js'
|
||||
|
||||
// Convenience class with all the stuff related to the Github API and its
|
||||
@@ -23,7 +22,6 @@ class GithubConstellation {
|
||||
constructor(config) {
|
||||
this._debugEnabled = config.service.debug.enabled
|
||||
this._debugIntervalSeconds = config.service.debug.intervalSeconds
|
||||
this.shieldsSecret = config.private.shields_secret
|
||||
|
||||
const { redis_url: redisUrl, gh_token: globalToken } = config.private
|
||||
if (redisUrl) {
|
||||
@@ -74,9 +72,6 @@ class GithubConstellation {
|
||||
this.apiProvider.addToken(tokenString)
|
||||
})
|
||||
|
||||
const { shieldsSecret, apiProvider } = this
|
||||
setAdminRoutes({ shieldsSecret }, { apiProvider, server })
|
||||
|
||||
if (this.oauthHelper.isConfigured) {
|
||||
setAcceptorRoutes({
|
||||
server,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Joi from 'joi'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { downloadCount as downloadCountColor } from '../color-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { NotFound } from '../index.js'
|
||||
import { GithubAuthV3Service } from './github-auth-service.js'
|
||||
import { fetchLatestRelease } from './github-common-release.js'
|
||||
@@ -43,7 +42,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
},
|
||||
staticPreview: this.render({
|
||||
assetName: 'total',
|
||||
downloadCount: 857000,
|
||||
downloads: 857000,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -58,7 +57,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
staticPreview: this.render({
|
||||
tag: 'latest',
|
||||
assetName: 'total',
|
||||
downloadCount: 27000,
|
||||
downloads: 27000,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -74,7 +73,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
staticPreview: this.render({
|
||||
tag: 'latest',
|
||||
assetName: 'total',
|
||||
downloadCount: 27000,
|
||||
downloads: 27000,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -89,7 +88,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
staticPreview: this.render({
|
||||
tag: 'latest',
|
||||
assetName: 'total',
|
||||
downloadCount: 2000,
|
||||
downloads: 2000,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -105,7 +104,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
staticPreview: this.render({
|
||||
tag: 'latest',
|
||||
assetName: 'total',
|
||||
downloadCount: 2000,
|
||||
downloads: 2000,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -120,7 +119,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
staticPreview: this.render({
|
||||
tag: 'v0.190.0',
|
||||
assetName: 'total',
|
||||
downloadCount: 490000,
|
||||
downloads: 490000,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -136,7 +135,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
staticPreview: this.render({
|
||||
tag: 'latest',
|
||||
assetName: 'atom-amd64.deb',
|
||||
downloadCount: 3000,
|
||||
downloads: 3000,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -153,7 +152,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
staticPreview: this.render({
|
||||
tag: 'latest',
|
||||
assetName: 'atom-amd64.deb',
|
||||
downloadCount: 3000,
|
||||
downloads: 3000,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -169,7 +168,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
staticPreview: this.render({
|
||||
tag: 'latest',
|
||||
assetName: 'atom-amd64.deb',
|
||||
downloadCount: 237,
|
||||
downloads: 237,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -187,7 +186,7 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
staticPreview: this.render({
|
||||
tag: 'latest',
|
||||
assetName: 'atom-amd64.deb',
|
||||
downloadCount: 237,
|
||||
downloads: 237,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
@@ -195,19 +194,14 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
|
||||
static defaultBadgeData = { label: 'downloads', namedLogo: 'github' }
|
||||
|
||||
static render({ tag, assetName, downloadCount }) {
|
||||
return {
|
||||
label: tag ? `downloads@${tag}` : 'downloads',
|
||||
message:
|
||||
assetName === 'total'
|
||||
? metric(downloadCount)
|
||||
: `${metric(downloadCount)} [${assetName}]`,
|
||||
color: downloadCountColor(downloadCount),
|
||||
}
|
||||
static render({ tag: version, assetName, downloads }) {
|
||||
const messageSuffixOverride =
|
||||
assetName !== 'total' ? `[${assetName}]` : undefined
|
||||
return renderDownloadsBadge({ downloads, messageSuffixOverride, version })
|
||||
}
|
||||
|
||||
static transform({ releases, assetName }) {
|
||||
const downloadCount = releases.reduce((accum1, { assets }) => {
|
||||
const downloads = releases.reduce((accum1, { assets }) => {
|
||||
const filteredAssets =
|
||||
assetName === 'total'
|
||||
? assets
|
||||
@@ -217,12 +211,12 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
return (
|
||||
accum1 +
|
||||
filteredAssets.reduce(
|
||||
(accum2, { download_count: downloadCount }) => accum2 + downloadCount,
|
||||
(accum2, { download_count: downloads }) => accum2 + downloads,
|
||||
0
|
||||
)
|
||||
)
|
||||
}, 0)
|
||||
return { downloadCount }
|
||||
return { downloads }
|
||||
}
|
||||
|
||||
async handle({ kind, user, repo, tag, assetName }, { sort }) {
|
||||
@@ -256,11 +250,11 @@ export default class GithubDownloads extends GithubAuthV3Service {
|
||||
throw new NotFound({ prettyMessage: 'no releases' })
|
||||
}
|
||||
|
||||
const { downloadCount } = this.constructor.transform({
|
||||
const { downloads } = this.constructor.transform({
|
||||
releases,
|
||||
assetName,
|
||||
})
|
||||
|
||||
return this.constructor.render({ tag, assetName, downloadCount })
|
||||
return this.constructor.render({ tag, assetName, downloads })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi
|
||||
static category = 'issue-tracking'
|
||||
static route = {
|
||||
base: 'github/hacktoberfest',
|
||||
pattern: ':year(2019|2020)/:user/:repo',
|
||||
pattern: ':year(2019|2020|2021)/:user/:repo',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi
|
||||
{
|
||||
title: 'GitHub Hacktoberfest combined status',
|
||||
namedParams: {
|
||||
year: '2020',
|
||||
year: '2021',
|
||||
user: 'snyk',
|
||||
repo: 'snyk',
|
||||
},
|
||||
@@ -82,7 +82,7 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi
|
||||
{
|
||||
title: 'GitHub Hacktoberfest combined status (suggestion label override)',
|
||||
namedParams: {
|
||||
year: '2020',
|
||||
year: '2021',
|
||||
user: 'tmrowco',
|
||||
repo: 'tmrowapp-contrib',
|
||||
},
|
||||
@@ -90,7 +90,7 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi
|
||||
suggestion_label: 'help wanted',
|
||||
},
|
||||
staticPreview: this.render({
|
||||
year: '2020',
|
||||
year: '2021',
|
||||
suggestedIssueCount: 12,
|
||||
contributionCount: 8,
|
||||
daysLeft: 15,
|
||||
|
||||
@@ -89,9 +89,9 @@ t.create('Scoped dependency')
|
||||
})
|
||||
|
||||
t.create('Scoped dependency on branch')
|
||||
.get('/dependency-version/zeit/next.js/dev/babel-eslint/alpha.json')
|
||||
.get('/dependency-version/zeit/next.js/dev/@babel/eslint-parser/canary.json')
|
||||
.expectBadge({
|
||||
label: 'babel-eslint',
|
||||
label: '@babel/eslint-parser',
|
||||
message: semverRange,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Joi from 'joi'
|
||||
import { addv } from '../text-formatters.js'
|
||||
import { version as versionColor } from '../color-formatters.js'
|
||||
import { redirector } from '../index.js'
|
||||
@@ -8,19 +9,23 @@ import {
|
||||
} from './github-common-release.js'
|
||||
import { documentation } from './github-helpers.js'
|
||||
|
||||
const extendedQueryParamSchema = Joi.object({
|
||||
display_name: Joi.string().valid('tag', 'release').default('tag'),
|
||||
})
|
||||
|
||||
class GithubRelease extends GithubAuthV3Service {
|
||||
static category = 'version'
|
||||
static route = {
|
||||
base: 'github/v/release',
|
||||
pattern: ':user/:repo',
|
||||
queryParamSchema,
|
||||
queryParamSchema: queryParamSchema.concat(extendedQueryParamSchema),
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'GitHub release (latest by date)',
|
||||
namedParams: { user: 'expressjs', repo: 'express' },
|
||||
queryParams: {},
|
||||
queryParams: { display_name: 'tag' },
|
||||
staticPreview: this.render({
|
||||
version: 'v4.16.4',
|
||||
sort: 'date',
|
||||
@@ -31,7 +36,7 @@ class GithubRelease extends GithubAuthV3Service {
|
||||
{
|
||||
title: 'GitHub release (latest by date including pre-releases)',
|
||||
namedParams: { user: 'expressjs', repo: 'express' },
|
||||
queryParams: { include_prereleases: null },
|
||||
queryParams: { include_prereleases: null, display_name: 'tag' },
|
||||
staticPreview: this.render({
|
||||
version: 'v5.0.0-alpha.7',
|
||||
sort: 'date',
|
||||
@@ -42,7 +47,7 @@ class GithubRelease extends GithubAuthV3Service {
|
||||
{
|
||||
title: 'GitHub release (latest SemVer)',
|
||||
namedParams: { user: 'expressjs', repo: 'express' },
|
||||
queryParams: { sort: 'semver' },
|
||||
queryParams: { sort: 'semver', display_name: 'tag' },
|
||||
staticPreview: this.render({
|
||||
version: 'v4.16.4',
|
||||
sort: 'semver',
|
||||
@@ -53,7 +58,11 @@ class GithubRelease extends GithubAuthV3Service {
|
||||
{
|
||||
title: 'GitHub release (latest SemVer including pre-releases)',
|
||||
namedParams: { user: 'expressjs', repo: 'express' },
|
||||
queryParams: { sort: 'semver', include_prereleases: null },
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
display_name: 'tag',
|
||||
},
|
||||
staticPreview: this.render({
|
||||
version: 'v5.0.0-alpha.7',
|
||||
sort: 'semver',
|
||||
@@ -61,6 +70,21 @@ class GithubRelease extends GithubAuthV3Service {
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
{
|
||||
title: 'GitHub release (release name instead of tag name)',
|
||||
namedParams: { user: 'gooddata', repo: 'gooddata-java' },
|
||||
queryParams: {
|
||||
sort: 'date',
|
||||
include_prereleases: null,
|
||||
display_name: 'release',
|
||||
},
|
||||
staticPreview: this.render({
|
||||
version: '3.7.0+api3',
|
||||
sort: 'date',
|
||||
isPrerelease: true,
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'release', namedLogo: 'github' }
|
||||
@@ -72,16 +96,29 @@ class GithubRelease extends GithubAuthV3Service {
|
||||
return { message: addv(version), color }
|
||||
}
|
||||
|
||||
static transform(latestRelease, display) {
|
||||
const { name, tag_name: tagName, prerelease: isPrerelease } = latestRelease
|
||||
if (display === 'tag') {
|
||||
return { isPrerelease, version: tagName }
|
||||
}
|
||||
|
||||
return { version: name || tagName, isPrerelease }
|
||||
}
|
||||
|
||||
async handle({ user, repo }, queryParams) {
|
||||
const latestRelease = await fetchLatestRelease(
|
||||
this,
|
||||
{ user, repo },
|
||||
queryParams
|
||||
)
|
||||
const { version, isPrerelease } = this.constructor.transform(
|
||||
latestRelease,
|
||||
queryParams.display_name
|
||||
)
|
||||
return this.constructor.render({
|
||||
version: latestRelease.tag_name,
|
||||
version,
|
||||
sort: queryParams.sort,
|
||||
isPrerelease: latestRelease.prerelease,
|
||||
isPrerelease,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
25
services/github/github-release.spec.js
Normal file
25
services/github/github-release.spec.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { test, given } from 'sazerac'
|
||||
import { GithubRelease } from './github-release.service.js'
|
||||
|
||||
describe('GithubRelease', function () {
|
||||
test(GithubRelease.transform, () => {
|
||||
given({ name: null, tag_name: '0.1.2', prerelease: true }, 'tag').expect({
|
||||
version: '0.1.2',
|
||||
isPrerelease: true,
|
||||
})
|
||||
given(
|
||||
{ name: null, tag_name: '0.1.3', prerelease: true },
|
||||
'release'
|
||||
).expect({
|
||||
version: '0.1.3',
|
||||
isPrerelease: true,
|
||||
})
|
||||
given(
|
||||
{ name: 'fun name', tag_name: '1.0.0', prerelease: false },
|
||||
'release'
|
||||
).expect({
|
||||
version: 'fun name',
|
||||
isPrerelease: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,12 @@ t.create('Prerelease')
|
||||
color: Joi.string().allow('blue', 'orange').required(),
|
||||
})
|
||||
|
||||
// basic query parameter testing. application of param in transform
|
||||
// logic is tested via unit tests in github-release.spec.js
|
||||
t.create('Release (release name instead of tag name)')
|
||||
.get('/v/release/expressjs/express.json?display_name=release')
|
||||
.expectBadge({ label: 'release', message: isSemver, color: 'blue' })
|
||||
|
||||
t.create('Release (No releases)')
|
||||
.get('/v/release/badges/daily-tests.json')
|
||||
.expectBadge({ label: 'release', message: 'no releases or repo not found' })
|
||||
|
||||
@@ -16,4 +16,50 @@ export default class GitLabBase extends BaseJsonService {
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async fetchPage({ page, requestParams, schema }) {
|
||||
const { res, buffer } = await this._request({
|
||||
...requestParams,
|
||||
...{ options: { qs: { page } } },
|
||||
})
|
||||
|
||||
const json = this._parseJson(buffer)
|
||||
const data = this.constructor._validate(json, schema)
|
||||
return { res, data }
|
||||
}
|
||||
|
||||
async fetchPaginatedArrayData({
|
||||
url,
|
||||
options,
|
||||
schema,
|
||||
errorMessages,
|
||||
firstPageOnly = false,
|
||||
}) {
|
||||
const requestParams = this.authHelper.withBasicAuth({
|
||||
url,
|
||||
options: {
|
||||
headers: { Accept: 'application/json' },
|
||||
qs: { per_page: 100 },
|
||||
...options,
|
||||
},
|
||||
errorMessages,
|
||||
})
|
||||
|
||||
const {
|
||||
res: { headers },
|
||||
data,
|
||||
} = await this.fetchPage({ page: 1, requestParams, schema })
|
||||
const numberOfPages = headers['x-total-pages']
|
||||
|
||||
if (numberOfPages === 1 || firstPageOnly) {
|
||||
return data
|
||||
}
|
||||
|
||||
const pageData = await Promise.all(
|
||||
[...Array(numberOfPages - 1).keys()].map((_, i) =>
|
||||
this.fetchPage({ page: ++i + 1, requestParams, schema })
|
||||
)
|
||||
)
|
||||
return [...data].concat(...pageData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,12 @@ const badgeSchema = Joi.object({
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
gitlab_url: optionalUrl,
|
||||
branch: Joi.string(),
|
||||
}).required()
|
||||
|
||||
const documentation = `
|
||||
<p>
|
||||
Important: If your project is publicly visible, but the badge is like this:
|
||||
Important: You must use the Project Path, not the Project Id. Additionally, if your project is publicly visible, but the badge is like this:
|
||||
<img src="https://img.shields.io/badge/build-not found-red" alt="build not found"/>
|
||||
</p>
|
||||
<p>
|
||||
@@ -39,26 +40,23 @@ class GitlabPipelineStatus extends BaseSvgScrapingService {
|
||||
static category = 'build'
|
||||
|
||||
static route = {
|
||||
base: 'gitlab/pipeline',
|
||||
pattern: ':user/:repo/:branch+',
|
||||
base: 'gitlab/pipeline-status',
|
||||
pattern: ':project+',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Gitlab pipeline status',
|
||||
namedParams: {
|
||||
user: 'gitlab-org',
|
||||
repo: 'gitlab',
|
||||
branch: 'master',
|
||||
},
|
||||
namedParams: { project: 'gitlab-org/gitlab' },
|
||||
queryParams: { branch: 'master' },
|
||||
staticPreview: this.render({ status: 'passed' }),
|
||||
documentation,
|
||||
},
|
||||
{
|
||||
title: 'Gitlab pipeline status (self-hosted)',
|
||||
namedParams: { user: 'GNOME', repo: 'pango', branch: 'master' },
|
||||
queryParams: { gitlab_url: 'https://gitlab.gnome.org' },
|
||||
namedParams: { project: 'GNOME/pango' },
|
||||
queryParams: { gitlab_url: 'https://gitlab.gnome.org', branch: 'master' },
|
||||
staticPreview: this.render({ status: 'passed' }),
|
||||
documentation,
|
||||
},
|
||||
@@ -68,33 +66,67 @@ class GitlabPipelineStatus extends BaseSvgScrapingService {
|
||||
return renderBuildStatusBadge({ status })
|
||||
}
|
||||
|
||||
async handle(
|
||||
{ user, repo, branch },
|
||||
{ gitlab_url: baseUrl = 'https://gitlab.com' }
|
||||
) {
|
||||
const { message: status } = await this._requestSvg({
|
||||
async fetch({ project, branch, baseUrl }) {
|
||||
return this._requestSvg({
|
||||
schema: badgeSchema,
|
||||
url: `${baseUrl}/${user}/${repo}/badges/${branch}/pipeline.svg`,
|
||||
url: `${baseUrl}/${decodeURIComponent(
|
||||
project
|
||||
)}/badges/${branch}/pipeline.svg`,
|
||||
errorMessages: {
|
||||
401: 'repo not found',
|
||||
404: 'repo not found',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
static transform(data) {
|
||||
const { message: status } = data
|
||||
if (status === 'unknown') {
|
||||
throw new NotFound({ prettyMessage: 'branch not found' })
|
||||
}
|
||||
return { status }
|
||||
}
|
||||
|
||||
async handle(
|
||||
{ project },
|
||||
{ gitlab_url: baseUrl = 'https://gitlab.com', branch = 'main' }
|
||||
) {
|
||||
const data = await this.fetch({
|
||||
project,
|
||||
branch,
|
||||
baseUrl,
|
||||
})
|
||||
const { status } = this.constructor.transform(data)
|
||||
return this.constructor.render({ status })
|
||||
}
|
||||
}
|
||||
|
||||
const GitlabPipelineStatusRedirector = redirector({
|
||||
category: 'build',
|
||||
name: 'GitlabPipelineStatusRedirector',
|
||||
route: {
|
||||
base: 'gitlab/pipeline',
|
||||
pattern: ':user/:repo',
|
||||
},
|
||||
transformPath: ({ user, repo }) => `/gitlab/pipeline/${user}/${repo}/master`,
|
||||
transformPath: ({ user, repo }) => `/gitlab/pipeline-status/${user}/${repo}`,
|
||||
transformQueryParams: ({ _b }) => ({ branch: 'master' }),
|
||||
dateAdded: new Date('2020-07-12'),
|
||||
})
|
||||
|
||||
export { GitlabPipelineStatus, GitlabPipelineStatusRedirector }
|
||||
const GitlabPipelineStatusBranchRouteParamRedirector = redirector({
|
||||
category: 'build',
|
||||
name: 'GitlabPipelineStatusBranchRouteParamRedirector',
|
||||
route: {
|
||||
base: 'gitlab/pipeline',
|
||||
pattern: ':user/:repo/:branch+',
|
||||
},
|
||||
transformPath: ({ user, repo }) => `/gitlab/pipeline-status/${user}/${repo}`,
|
||||
transformQueryParams: ({ branch }) => ({ branch }),
|
||||
dateAdded: new Date('2021-10-20'),
|
||||
})
|
||||
|
||||
export {
|
||||
GitlabPipelineStatus,
|
||||
GitlabPipelineStatusRedirector,
|
||||
GitlabPipelineStatusBranchRouteParamRedirector,
|
||||
}
|
||||
|
||||
@@ -3,16 +3,27 @@ import { ServiceTester } from '../tester.js'
|
||||
export const t = new ServiceTester({
|
||||
id: 'GitlabPipeline',
|
||||
title: 'Gitlab Pipeline',
|
||||
pathPrefix: '/gitlab/pipeline',
|
||||
pathPrefix: '/gitlab',
|
||||
})
|
||||
|
||||
t.create('Pipeline status').get('/gitlab-org/gitlab/v10.7.6.json').expectBadge({
|
||||
label: 'build',
|
||||
message: isBuildStatus,
|
||||
})
|
||||
t.create('Pipeline status')
|
||||
.get('/pipeline-status/gitlab-org/gitlab.json?branch=v10.7.6')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isBuildStatus,
|
||||
})
|
||||
|
||||
t.create('Pipeline status (nested groups)')
|
||||
.get(
|
||||
'/pipeline-status/megabyte-labs/dockerfile/ci-pipeline/ansible-lint.json?branch=master'
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isBuildStatus,
|
||||
})
|
||||
|
||||
t.create('Pipeline status (nonexistent branch)')
|
||||
.get('/gitlab-org/gitlab/nope-not-a-branch.json')
|
||||
.get('/pipeline-status/gitlab-org/gitlab.json?branch=nope-not-a-branch')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: 'branch not found',
|
||||
@@ -26,19 +37,25 @@ t.create('Pipeline status (nonexistent branch)')
|
||||
// error message, we will simply display inaccessible
|
||||
// https://github.com/badges/shields/pull/5538
|
||||
t.create('Pipeline status (nonexistent repo)')
|
||||
.get('/this-repo/does-not-exist/master.json')
|
||||
.get('/pipeline-status/this-repo/does-not-exist.json?branch=master')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: 'inaccessible',
|
||||
})
|
||||
|
||||
t.create('Pipeline status (custom gitlab URL)')
|
||||
.get('/GNOME/pango/main.json?gitlab_url=https://gitlab.gnome.org')
|
||||
.get('/pipeline-status/GNOME/pango.json?gitlab_url=https://gitlab.gnome.org')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isBuildStatus,
|
||||
})
|
||||
|
||||
t.create('Pipeline no branch redirect')
|
||||
.get('/gitlab-org/gitlab.svg')
|
||||
.expectRedirect('/gitlab/pipeline/gitlab-org/gitlab/master.svg')
|
||||
.get('/pipeline/gitlab-org/gitlab.svg')
|
||||
.expectRedirect('/gitlab/pipeline-status/gitlab-org/gitlab.svg?branch=master')
|
||||
|
||||
t.create('Pipeline legacy route with branch redirect')
|
||||
.get('/pipeline/gitlab-org/gitlab/v10.7.6?style=flat')
|
||||
.expectRedirect(
|
||||
'/gitlab/pipeline-status/gitlab-org/gitlab.svg?branch=v10.7.6&style=flat'
|
||||
)
|
||||
|
||||
152
services/gitlab/gitlab-release.service.js
Normal file
152
services/gitlab/gitlab-release.service.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import Joi from 'joi'
|
||||
import { optionalUrl } from '../validators.js'
|
||||
import { latest, renderVersionBadge } from '../version.js'
|
||||
import { NotFound } from '../index.js'
|
||||
import GitLabBase from './gitlab-base.js'
|
||||
|
||||
const schema = Joi.array().items(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
tag_name: Joi.string().required(),
|
||||
})
|
||||
)
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
gitlab_url: optionalUrl,
|
||||
include_prereleases: Joi.equal(''),
|
||||
sort: Joi.string().valid('date', 'semver').default('date'),
|
||||
display_name: Joi.string().valid('tag', 'release').default('tag'),
|
||||
date_order_by: Joi.string()
|
||||
.valid('created_at', 'released_at')
|
||||
.default('created_at'),
|
||||
}).required()
|
||||
|
||||
const documentation = `
|
||||
<p>
|
||||
You may use your GitLab Project Id (e.g. 25813592) or your Project Path (e.g. megabyte-labs/dockerfile/ci-pipeline/ansible-lint)
|
||||
</p>
|
||||
`
|
||||
const commonProps = {
|
||||
namedParams: {
|
||||
project: 'shields-ops-group/tag-test',
|
||||
},
|
||||
documentation,
|
||||
}
|
||||
|
||||
export default class GitLabRelease extends GitLabBase {
|
||||
static category = 'version'
|
||||
|
||||
static route = {
|
||||
base: 'gitlab/v/release',
|
||||
pattern: ':project+',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'GitLab Release (latest by date)',
|
||||
...commonProps,
|
||||
queryParams: { sort: 'date', date_order_by: 'created_at' },
|
||||
staticPreview: renderVersionBadge({ version: 'v2.0.0' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab Release (latest by SemVer)',
|
||||
...commonProps,
|
||||
queryParams: { sort: 'semver' },
|
||||
staticPreview: renderVersionBadge({ version: 'v4.0.0' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab Release (latest by SemVer pre-release)',
|
||||
...commonProps,
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
},
|
||||
staticPreview: renderVersionBadge({ version: 'v5.0.0-beta.1' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab Release (custom instance)',
|
||||
namedParams: {
|
||||
project: 'GNOME/librsvg',
|
||||
},
|
||||
documentation,
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
gitlab_url: 'https://gitlab.gnome.org',
|
||||
date_order_by: 'created_at',
|
||||
},
|
||||
staticPreview: renderVersionBadge({ version: 'v2.51.4' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab Release (by release name)',
|
||||
namedParams: {
|
||||
project: 'gitlab-org/gitlab',
|
||||
},
|
||||
documentation,
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
gitlab_url: 'https://gitlab.com',
|
||||
display_name: 'release',
|
||||
date_order_by: 'created_at',
|
||||
},
|
||||
staticPreview: renderVersionBadge({ version: 'GitLab 14.2' }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'release' }
|
||||
|
||||
async fetch({ project, baseUrl, isSemver, orderBy }) {
|
||||
// https://docs.gitlab.com/ee/api/releases/
|
||||
return this.fetchPaginatedArrayData({
|
||||
schema,
|
||||
url: `${baseUrl}/api/v4/projects/${encodeURIComponent(project)}/releases`,
|
||||
errorMessages: {
|
||||
404: 'project not found',
|
||||
},
|
||||
options: {
|
||||
qs: { order_by: orderBy },
|
||||
},
|
||||
firstPageOnly: !isSemver,
|
||||
})
|
||||
}
|
||||
|
||||
static transform({ releases, isSemver, includePrereleases, displayName }) {
|
||||
if (releases.length === 0) {
|
||||
throw new NotFound({ prettyMessage: 'no releases found' })
|
||||
}
|
||||
|
||||
const displayKey = displayName === 'tag' ? 'tag_name' : 'name'
|
||||
|
||||
if (!isSemver) {
|
||||
return releases[0][displayKey]
|
||||
}
|
||||
|
||||
return latest(
|
||||
releases.map(t => t[displayKey]),
|
||||
{ pre: includePrereleases }
|
||||
)
|
||||
}
|
||||
|
||||
async handle(
|
||||
{ project },
|
||||
{
|
||||
gitlab_url: baseUrl = 'https://gitlab.com',
|
||||
include_prereleases: pre,
|
||||
sort,
|
||||
display_name: displayName,
|
||||
date_order_by: orderBy,
|
||||
}
|
||||
) {
|
||||
const isSemver = sort === 'semver'
|
||||
const releases = await this.fetch({ project, baseUrl, isSemver, orderBy })
|
||||
const version = this.constructor.transform({
|
||||
releases,
|
||||
isSemver,
|
||||
includePrereleases: pre !== undefined,
|
||||
displayName,
|
||||
})
|
||||
return renderVersionBadge({ version })
|
||||
}
|
||||
}
|
||||
49
services/gitlab/gitlab-release.tester.js
Normal file
49
services/gitlab/gitlab-release.tester.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { isSemver, withRegex } from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
const isGitLabDisplayVersion = withRegex(/^GitLab [1-9][0-9]*.[0-9]*/)
|
||||
|
||||
t.create('Release (latest by date)')
|
||||
.get('/shields-ops-group/tag-test.json')
|
||||
.expectBadge({ label: 'release', message: 'v2.0.0', color: 'blue' })
|
||||
|
||||
t.create('Release (nested groups latest by date)')
|
||||
.get('/gitlab-org/frontend/eslint-plugin.json')
|
||||
.expectBadge({ label: 'release', message: isSemver, color: 'blue' })
|
||||
|
||||
t.create('Release (latest by date, order by created_at)')
|
||||
.get('/shields-ops-group/tag-test.json?date_order_by=created_at')
|
||||
.expectBadge({ label: 'release', message: 'v2.0.0', color: 'blue' })
|
||||
|
||||
t.create('Release (latest by date, order by released_at)')
|
||||
.get('/shields-ops-group/tag-test.json?date_order_by=released_at')
|
||||
.expectBadge({ label: 'release', message: 'v2.0.0', color: 'blue' })
|
||||
|
||||
t.create('Release (project id latest by date)')
|
||||
.get('/29538796.json')
|
||||
.expectBadge({ label: 'release', message: 'v2.0.0', color: 'blue' })
|
||||
|
||||
t.create('Release (latest by semver)')
|
||||
.get('/shields-ops-group/tag-test.json?sort=semver')
|
||||
.expectBadge({ label: 'release', message: 'v4.0.0', color: 'blue' })
|
||||
|
||||
t.create('Release (latest by semver pre-release)')
|
||||
.get('/shields-ops-group/tag-test.json?sort=semver&include_prereleases')
|
||||
.expectBadge({ label: 'release', message: 'v5.0.0-beta.1', color: 'orange' })
|
||||
|
||||
t.create('Release (release display name)')
|
||||
.get('/gitlab-org/gitlab.json?display_name=release')
|
||||
.expectBadge({ label: 'release', message: isGitLabDisplayVersion })
|
||||
|
||||
t.create('Release (custom instance')
|
||||
.get('/GNOME/librsvg.json?gitlab_url=https://gitlab.gnome.org')
|
||||
.expectBadge({ label: 'release', message: isSemver, color: 'blue' })
|
||||
|
||||
t.create('Release (project not found)')
|
||||
.get('/fdroid/nonexistant.json')
|
||||
.expectBadge({ label: 'release', message: 'project not found' })
|
||||
|
||||
t.create('Release (no tags)')
|
||||
.get('/fdroid/fdroiddata.json')
|
||||
.expectBadge({ label: 'release', message: 'no releases found' })
|
||||
@@ -18,40 +18,43 @@ const queryParamSchema = Joi.object({
|
||||
sort: Joi.string().valid('date', 'semver').default('date'),
|
||||
}).required()
|
||||
|
||||
const documentation = `
|
||||
<p>
|
||||
You may use your GitLab Project Id (e.g. 25813592) or your Project Path (e.g. megabyte-labs/dockerfile/ci-pipeline/ansible-lint)
|
||||
</p>
|
||||
`
|
||||
const commonProps = {
|
||||
namedParams: {
|
||||
project: 'shields-ops-group/tag-test',
|
||||
},
|
||||
documentation,
|
||||
}
|
||||
|
||||
export default class GitlabTag extends GitLabBase {
|
||||
static category = 'version'
|
||||
|
||||
static route = {
|
||||
base: 'gitlab/v/tag',
|
||||
pattern: ':user/:repo',
|
||||
pattern: ':project+',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'GitLab tag (latest by date)',
|
||||
namedParams: {
|
||||
user: 'shields-ops-group',
|
||||
repo: 'tag-test',
|
||||
},
|
||||
...commonProps,
|
||||
queryParams: { sort: 'date' },
|
||||
staticPreview: this.render({ version: 'v2.0.0' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab tag (latest by SemVer)',
|
||||
namedParams: {
|
||||
user: 'shields-ops-group',
|
||||
repo: 'tag-test',
|
||||
},
|
||||
...commonProps,
|
||||
queryParams: { sort: 'semver' },
|
||||
staticPreview: this.render({ version: 'v4.0.0' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab tag (latest by SemVer pre-release)',
|
||||
namedParams: {
|
||||
user: 'shields-ops-group',
|
||||
repo: 'tag-test',
|
||||
},
|
||||
...commonProps,
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
@@ -61,9 +64,9 @@ export default class GitlabTag extends GitLabBase {
|
||||
{
|
||||
title: 'GitLab tag (custom instance)',
|
||||
namedParams: {
|
||||
user: 'GNOME',
|
||||
repo: 'librsvg',
|
||||
project: 'GNOME/librsvg',
|
||||
},
|
||||
documentation,
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
@@ -82,14 +85,16 @@ export default class GitlabTag extends GitLabBase {
|
||||
}
|
||||
}
|
||||
|
||||
async fetch({ user, repo, baseUrl }) {
|
||||
async fetch({ project, baseUrl }) {
|
||||
// https://docs.gitlab.com/ee/api/tags.html
|
||||
// N.B. the documentation has contradictory information about default sort order.
|
||||
// As of 2020-10-11 the default is by date, but we add the `order_by` query param
|
||||
// explicitly in case that changes upstream.
|
||||
return super.fetch({
|
||||
schema,
|
||||
url: `${baseUrl}/api/v4/projects/${user}%2F${repo}/repository/tags`,
|
||||
url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
|
||||
project
|
||||
)}/repository/tags`,
|
||||
options: { qs: { order_by: 'updated' } },
|
||||
errorMessages: {
|
||||
404: 'repo not found',
|
||||
@@ -113,14 +118,14 @@ export default class GitlabTag extends GitLabBase {
|
||||
}
|
||||
|
||||
async handle(
|
||||
{ user, repo },
|
||||
{ project },
|
||||
{
|
||||
gitlab_url: baseUrl = 'https://gitlab.com',
|
||||
include_prereleases: pre,
|
||||
sort,
|
||||
}
|
||||
) {
|
||||
const tags = await this.fetch({ user, repo, baseUrl })
|
||||
const tags = await this.fetch({ project, baseUrl })
|
||||
const version = this.constructor.transform({
|
||||
tags,
|
||||
sort,
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('GitLabTag', function () {
|
||||
await GitLabTag.invoke(
|
||||
defaultContext,
|
||||
config,
|
||||
{ user: 'foo', repo: 'bar' },
|
||||
{ project: 'foo/bar' },
|
||||
{}
|
||||
)
|
||||
).to.deep.equal({
|
||||
|
||||
@@ -6,6 +6,14 @@ t.create('Tag (latest by date)')
|
||||
.get('/shields-ops-group/tag-test.json')
|
||||
.expectBadge({ label: 'tag', message: 'v2.0.0', color: 'blue' })
|
||||
|
||||
t.create('Tag (nested groups)')
|
||||
.get('/megabyte-labs/dockerfile/ci-pipeline/ansible-lint.json')
|
||||
.expectBadge({ label: 'tag', message: isSemver, color: 'blue' })
|
||||
|
||||
t.create('Tag (project id latest by date)')
|
||||
.get('/29538796.json')
|
||||
.expectBadge({ label: 'tag', message: 'v2.0.0', color: 'blue' })
|
||||
|
||||
t.create('Tag (latest by SemVer)')
|
||||
.get('/shields-ops-group/tag-test.json?sort=semver')
|
||||
.expectBadge({ label: 'tag', message: 'v4.0.0', color: 'blue' })
|
||||
@@ -14,7 +22,7 @@ t.create('Tag (latest by SemVer pre-release)')
|
||||
.get('/shields-ops-group/tag-test.json?sort=semver&include_prereleases')
|
||||
.expectBadge({ label: 'tag', message: 'v5.0.0-beta.1', color: 'orange' })
|
||||
|
||||
t.create('Tag (custom instance')
|
||||
t.create('Tag (custom instance)')
|
||||
.get('/GNOME/librsvg.json?gitlab_url=https://gitlab.gnome.org')
|
||||
.expectBadge({ label: 'tag', message: isSemver, color: 'blue' })
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Joi from 'joi'
|
||||
import { metric, addv, maybePluralize } from '../text-formatters.js'
|
||||
import { downloadCount, version as versionColor } from '../color-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { addv, maybePluralize } from '../text-formatters.js'
|
||||
import { version as versionColor } from '../color-formatters.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
|
||||
const hexSchema = Joi.object({
|
||||
@@ -92,24 +93,23 @@ class HexPmVersion extends BaseHexPmService {
|
||||
}
|
||||
}
|
||||
|
||||
function DownloadsForInterval(interval) {
|
||||
const { base, messageSuffix, name } = {
|
||||
function DownloadsForInterval(downloadInterval) {
|
||||
const { base, interval, name } = {
|
||||
day: {
|
||||
base: 'hexpm/dd',
|
||||
messageSuffix: '/day',
|
||||
interval: 'day',
|
||||
name: 'HexPmDownloadsDay',
|
||||
},
|
||||
week: {
|
||||
base: 'hexpm/dw',
|
||||
messageSuffix: '/week',
|
||||
interval: 'week',
|
||||
name: 'HexPmDownloadsWeek',
|
||||
},
|
||||
all: {
|
||||
base: 'hexpm/dt',
|
||||
messageSuffix: '',
|
||||
name: 'HexPmDownloadsTotal',
|
||||
},
|
||||
}[interval]
|
||||
}[downloadInterval]
|
||||
|
||||
return class HexPmDownloads extends BaseHexPmService {
|
||||
static name = name
|
||||
@@ -125,22 +125,16 @@ function DownloadsForInterval(interval) {
|
||||
{
|
||||
title: 'Hex.pm',
|
||||
namedParams: { packageName: 'plug' },
|
||||
staticPreview: this.render({ downloads: 85000 }),
|
||||
staticPreview: renderDownloadsBadge({ downloads: 85000 }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
static render({ downloads }) {
|
||||
return {
|
||||
message: `${metric(downloads)}${messageSuffix}`,
|
||||
color: downloadCount(downloads),
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ packageName }) {
|
||||
const json = await this.fetch({ packageName })
|
||||
return this.constructor.render({ downloads: json.downloads[interval] })
|
||||
const downloads = json.downloads[downloadInterval]
|
||||
return renderDownloadsBadge({ downloads, interval })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Joi from 'joi'
|
||||
import { downloadCount } from '../color-formatters.js'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
|
||||
@@ -19,15 +18,15 @@ function getSchema({ formula }) {
|
||||
const periodMap = {
|
||||
dm: {
|
||||
api_field: '30d',
|
||||
suffix: '/month',
|
||||
interval: 'month',
|
||||
},
|
||||
dq: {
|
||||
api_field: '90d',
|
||||
suffix: '/quarter',
|
||||
interval: 'quarter',
|
||||
},
|
||||
dy: {
|
||||
api_field: '365d',
|
||||
suffix: '/year',
|
||||
interval: 'year',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,19 +42,12 @@ export default class HomebrewDownloads extends BaseJsonService {
|
||||
{
|
||||
title: 'homebrew downloads',
|
||||
namedParams: { interval: 'dm', formula: 'cake' },
|
||||
staticPreview: this.render({ interval: 'dm', downloads: 93 }),
|
||||
staticPreview: renderDownloadsBadge({ interval: 'month', downloads: 93 }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
static render({ interval, downloads }) {
|
||||
return {
|
||||
message: `${metric(downloads)}${periodMap[interval].suffix}`,
|
||||
color: downloadCount(downloads),
|
||||
}
|
||||
}
|
||||
|
||||
async fetch({ formula }) {
|
||||
const schema = getSchema({ formula })
|
||||
return this._requestJson({
|
||||
@@ -66,10 +58,12 @@ export default class HomebrewDownloads extends BaseJsonService {
|
||||
}
|
||||
|
||||
async handle({ interval, formula }) {
|
||||
const data = await this.fetch({ formula })
|
||||
return this.constructor.render({
|
||||
interval,
|
||||
downloads: data.analytics.install[periodMap[interval].api_field][formula],
|
||||
const {
|
||||
analytics: { install },
|
||||
} = await this.fetch({ formula })
|
||||
return renderDownloadsBadge({
|
||||
downloads: install[periodMap[interval].api_field][formula],
|
||||
interval: periodMap[interval].interval,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user